From 4111aebfce4b9947daf95e8df1a9a0ae90b454e0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 16 Mar 2026 17:05:11 -0700 Subject: [PATCH 01/29] feat(foundry): task owner git auth + manual owner change UI (#263) * Add task owner git auth proposal and sandbox architecture docs - Add proposal for primary user per task with OAuth token injection for sandbox git operations (.context/proposal-task-owner-git-auth.md) - Document sandbox architecture constraints in CLAUDE.md: single sandbox per task assumption, OAuth token security implications, git auto-auth requirement, and git error surfacing rules Co-Authored-By: Claude Opus 4.6 (1M context) * Add proposals for reverting to queues and rivetkit sandbox resilience - proposal-revert-actions-to-queues.md: Detailed plan for reverting the actions-only pattern back to queues/workflows now that the RivetKit queue.iter() bug is fixed. Lists what to keep (lazy tasks, resolveTaskRepoId, sync override threading, E2B fixes, frontend fixes) vs what to revert (communication pattern only). - proposal-rivetkit-sandbox-resilience.md: Rivetkit sandbox actor changes for handling destroyed/paused sandboxes, keep-alive, and the UNIQUE constraint crash fix. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(foundry): add manual task owner change via UI dropdown Add an owner dropdown to the Overview tab that lets users reassign task ownership to any organization member. The owner's GitHub credentials are used for git operations in the sandbox. Full-stack implementation: - Backend: changeTaskOwnerManually action on task actor, routed through org actor's changeWorkspaceTaskOwner action, with primaryUser schema columns on both task and org index tables - Client: changeOwner method on workspace client (mock + remote) - Frontend: owner dropdown in right sidebar Overview tab showing org members, with avatar and role display - Shared: TaskWorkspaceChangeOwnerInput type and primaryUser fields on workspace snapshot types Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .context/proposal-revert-actions-to-queues.md | 165 +++++++++++ .../proposal-rivetkit-sandbox-resilience.md | 94 +++++++ .context/proposal-task-owner-git-auth.md | 200 +++++++++++++ foundry/CLAUDE.md | 8 + .../organization/actions/task-mutations.ts | 4 + .../src/actors/organization/actions/tasks.ts | 11 + .../src/actors/organization/db/migrations.ts | 10 + .../src/actors/organization/db/schema.ts | 2 + .../backend/src/actors/task/db/migrations.ts | 16 ++ .../backend/src/actors/task/db/schema.ts | 18 ++ .../backend/src/actors/task/workflow/index.ts | 11 + .../backend/src/actors/task/workspace.ts | 210 +++++++++++++- foundry/packages/client/src/backend-client.ts | 7 + .../client/src/mock/backend-client.ts | 11 + .../client/src/mock/workspace-client.ts | 13 +- .../client/src/remote/workspace-client.ts | 6 + .../packages/client/src/workspace-client.ts | 2 + .../client/test/subscription-manager.test.ts | 4 + .../frontend/src/components/mock-layout.tsx | 30 +- .../components/mock-layout/right-sidebar.tsx | 266 +++++++++++++++++- .../src/components/mock-layout/sidebar.tsx | 17 ++ foundry/packages/shared/src/workspace.ts | 20 ++ 22 files changed, 1114 insertions(+), 11 deletions(-) create mode 100644 .context/proposal-revert-actions-to-queues.md create mode 100644 .context/proposal-rivetkit-sandbox-resilience.md create mode 100644 .context/proposal-task-owner-git-auth.md diff --git a/.context/proposal-revert-actions-to-queues.md b/.context/proposal-revert-actions-to-queues.md new file mode 100644 index 0000000..7eed270 --- /dev/null +++ b/.context/proposal-revert-actions-to-queues.md @@ -0,0 +1,165 @@ +# Proposal: Revert Actions-Only Pattern Back to Queues/Workflows + +## Background + +We converted all actors from queue/workflow-based communication to direct actions as a workaround for a RivetKit bug where `c.queue.iter()` deadlocked for actors created from another actor's context. That bug has since been fixed in RivetKit. We want to revert to queues/workflows because they provide better observability (workflow history in the inspector), replay/recovery semantics, and are the idiomatic RivetKit pattern. + +## Reference branches + +- **`main`** at commit `32f3c6c3` — the original queue/workflow code BEFORE the actions refactor +- **`queues-to-actions`** — the current refactored code using direct actions +- **`task-owner-git-auth`** at commit `f45a4674` — the merged PR #262 that introduced the actions pattern + +Use `main` as the reference for the queue/workflow communication patterns. Use `queues-to-actions` as the reference for bug fixes and new features that MUST be preserved. + +## What to KEEP (do NOT revert these) + +These are bug fixes and improvements made during the actions refactor that are independent of the communication pattern: + +### 1. Lazy task actor creation +- Virtual task entries in org's `taskIndex` + `taskSummaries` tables (no actor fan-out during PR sync) +- `refreshTaskSummaryForBranchMutation` writes directly to org tables instead of spawning task actors +- Task actors self-initialize in `getCurrentRecord()` from `getTaskIndexEntry` when lazily created +- `getTaskIndexEntry` action on org actor +- See CLAUDE.md "Lazy Task Actor Creation" section + +### 2. `resolveTaskRepoId` replacing `requireRepoExists` +- `requireRepoExists` was removed — it did a cross-actor call from org to github-data that was fragile +- Replaced with `resolveTaskRepoId` which reads from the org's local `taskIndex` table +- `getTask` action resolves `repoId` from task index when not provided (sandbox actor only has taskId) + +### 3. `getOrganizationContext` overrides threaded through sync phases +- `fullSyncBranchBatch`, `fullSyncMembers`, `fullSyncPullRequestBatch` now pass `connectedAccount`, `installationStatus`, `installationId` overrides from `FullSyncConfig` +- Without this, phases 2-4 fail with "Organization not initialized" when the org profile doesn't exist yet (webhook-triggered sync before user sign-in) + +### 4. E2B sandbox fixes +- `timeoutMs: 60 * 60 * 1000` in E2B create options (TEMPORARY until rivetkit autoPause lands) +- Sandbox repo path uses `/home/user/repo` for E2B compatibility +- `listProcesses` error handling for expired E2B sandboxes + +### 5. Frontend fixes +- React `useEffect` dependency stability in `mock-layout.tsx` and `organization-dashboard.tsx` (prevents infinite re-render loops) +- Terminal pane ref handling + +### 6. Process crash protection +- `process.on("uncaughtException")` and `process.on("unhandledRejection")` handlers in `foundry/packages/backend/src/index.ts` + +### 7. CLAUDE.md updates +- All new sections: lazy task creation rules, no-silent-catch policy, React hook dependency safety, dev workflow instructions, debugging section + +### 8. `requireWorkspaceTask` uses `getOrCreate` +- User-initiated actions (createSession, sendMessage, etc.) use `getOrCreate` to lazily materialize virtual tasks +- The `getOrCreate` call passes `{ organizationId, repoId, taskId }` as `createWithInput` + +### 9. `getTask` uses `getOrCreate` with `resolveTaskRepoId` +- When `repoId` is not provided (sandbox actor), resolves from task index +- Uses `getOrCreate` since the task may be virtual + +### 10. Audit log deleted workflow file +- `foundry/packages/backend/src/actors/audit-log/workflow.ts` was deleted +- The audit-log actor was simplified to a single `append` action +- Keep this simplification — audit-log doesn't need a workflow + +## What to REVERT (communication pattern only) + +For each actor, revert from direct action calls back to queue sends with `expectQueueResponse` / fire-and-forget patterns. The reference for the queue patterns is `main` at `32f3c6c3`. + +### 1. Organization actor (`foundry/packages/backend/src/actors/organization/`) + +**`index.ts`:** +- Revert from actions-only to `run: workflow(runOrganizationWorkflow)` +- Keep the actions that are pure reads (getAppSnapshot, getOrganizationSummarySnapshot, etc.) +- Mutations should go through the workflow queue command loop + +**`workflow.ts`:** +- Restore `runOrganizationWorkflow` with the `ctx.loop("organization-command-loop", ...)` that dispatches queue names to mutation handlers +- Restore `ORGANIZATION_QUEUE_NAMES` and `COMMAND_HANDLERS` +- Restore `organizationWorkflowQueueName()` helper + +**`app-shell.ts`:** +- Revert direct action calls back to queue sends: `sendOrganizationCommand(org, "organization.command.X", body)` pattern +- Revert `githubData.syncRepos(...)` → `githubData.send(githubDataWorkflowQueueName("syncRepos"), ...)` +- But KEEP the `getOrganizationContext` override threading fix + +**`actions/tasks.ts`:** +- Keep `resolveTaskRepoId` (replacing `requireRepoExists`) +- Keep `requireWorkspaceTask` using `getOrCreate` +- Keep `getTask` using `getOrCreate` with `resolveTaskRepoId` +- Keep `getTaskIndexEntry` +- Revert task actor calls from direct actions to queue sends where applicable + +**`actions/task-mutations.ts`:** +- Keep lazy task creation (virtual entries in org tables) +- Revert `taskHandle.initialize(...)` → `taskHandle.send(taskWorkflowQueueName("task.command.initialize"), ...)` +- Revert `task.pullRequestSync(...)` → `task.send(taskWorkflowQueueName("task.command.pullRequestSync"), ...)` +- Revert `auditLog.append(...)` → `auditLog.send("auditLog.command.append", ...)` + +**`actions/organization.ts`:** +- Revert direct calls to org workflow back to queue sends + +**`actions/github.ts`:** +- Revert direct calls back to queue sends + +### 2. Task actor (`foundry/packages/backend/src/actors/task/`) + +**`index.ts`:** +- Revert from actions-only to `run: workflow(runTaskWorkflow)` (or plain `run` with queue iteration) +- Keep read actions: `get`, `getTaskSummary`, `getTaskDetail`, `getSessionDetail` + +**`workflow/index.ts`:** +- Restore `taskCommandActions` as queue handlers in the workflow command loop +- Restore `TASK_QUEUE_NAMES` and dispatch map + +**`workspace.ts`:** +- Revert sandbox/org action calls back to queue sends where they were queue-based before + +### 3. User actor (`foundry/packages/backend/src/actors/user/`) + +**`index.ts`:** +- Revert from actions-only to `run: workflow(runUserWorkflow)` (or plain run with queue iteration) + +**`workflow.ts`:** +- Restore queue command loop dispatching to mutation functions + +### 4. GitHub-data actor (`foundry/packages/backend/src/actors/github-data/`) + +**`index.ts`:** +- Revert from actions-only to having a run handler with queue iteration +- Keep the `getOrganizationContext` override threading fix +- Keep the `actionTimeout: 10 * 60_000` for long sync operations + +### 5. Audit-log actor +- Keep as actions-only (simplified). No need to revert — it's simpler with just `append`. + +### 6. Callers + +**`foundry/packages/backend/src/services/better-auth.ts`:** +- Revert direct user actor action calls back to queue sends + +**`foundry/packages/backend/src/actors/sandbox/index.ts`:** +- Revert `organization.getTask(...)` → queue send if it was queue-based before +- Keep the E2B timeout fix and listProcesses error handling + +## Step-by-step procedure + +1. Create a new branch from `task-owner-git-auth` (current HEAD) +2. For each actor, open a 3-way comparison: `main` (original queues), `queues-to-actions` (current), and your working copy +3. Restore queue/workflow run handlers and command loops from `main` +4. Restore queue name helpers and constants from `main` +5. Restore caller sites to use queue sends from `main` +6. Carefully preserve all items in the "KEEP" list above +7. Test: `cd foundry && docker compose -f compose.dev.yaml up -d`, sign in, verify GitHub sync completes, verify tasks show in sidebar, verify session creation works +8. Nuke RivetKit data between test runs: `docker volume rm foundry_foundry_rivetkit_storage` + +## Verification checklist + +- [ ] GitHub sync completes (160 repos for rivet-dev) +- [ ] Tasks show in sidebar (from PR sync, lazy/virtual entries) +- [ ] No task actors spawned during sync (check RivetKit inspector — should see 0 task actors until user clicks one) +- [ ] Clicking a task materializes the actor (lazy creation via getOrCreate) +- [ ] Session creation works on sandbox-agent-testing repo +- [ ] E2B sandbox provisions and connects +- [ ] Agent responds to messages +- [ ] No 500 errors in backend logs (except expected E2B sandbox expiry) +- [ ] Workflow history visible in RivetKit inspector for org, task, user actors +- [ ] CLAUDE.md constraints still documented and respected diff --git a/.context/proposal-rivetkit-sandbox-resilience.md b/.context/proposal-rivetkit-sandbox-resilience.md new file mode 100644 index 0000000..1c94982 --- /dev/null +++ b/.context/proposal-rivetkit-sandbox-resilience.md @@ -0,0 +1,94 @@ +# Proposal: RivetKit Sandbox Actor Resilience + +## Context + +The rivetkit sandbox actor (`src/sandbox/actor.ts`) does not handle the case where the underlying cloud sandbox (e.g. E2B VM) is destroyed while the actor is still alive. This causes cascading 500 errors when the actor tries to call the dead sandbox. Additionally, a UNIQUE constraint bug in event persistence crashes the host process. + +The sandbox-agent repo (which defines the E2B provider) will be updated separately to use `autoPause` and expose `pause()`/typed errors. This proposal covers the rivetkit-side changes needed to handle those signals. + +## Changes + +### 1. Fix `persistObservedEnvelope` UNIQUE constraint crash + +**File:** `insertEvent` in the sandbox actor's SQLite persistence layer + +The `sandbox_agent_events` table has a UNIQUE constraint on `(session_id, event_index)`. When the same event is observed twice (reconnection, replay, duplicate WebSocket delivery), the insert throws and crashes the host process as an unhandled rejection. + +**Fix:** Change the INSERT to `INSERT OR IGNORE` / `ON CONFLICT DO NOTHING`. Duplicate events are expected and harmless — they should be silently deduplicated at the persistence layer. + +### 2. Handle destroyed sandbox in `ensureAgent()` + +**File:** `src/sandbox/actor.ts` — `ensureAgent()` function + +When the provider's `start()` is called with an existing `sandboxId` and the sandbox no longer exists, the provider throws a typed `SandboxDestroyedError` (defined in the sandbox-agent provider contract). + +`ensureAgent()` should catch this error and check the `onSandboxExpired` config option: + +```typescript +// New config option on sandboxActor() +onSandboxExpired?: "destroy" | "recreate"; // default: "destroy" +``` + +**`"destroy"` (default):** +- Set `state.sandboxDestroyed = true` +- Emit `sandboxExpired` event to all connected clients +- All subsequent action calls (runProcess, createSession, etc.) return a clear error: "Sandbox has expired. Create a new task to continue." +- The sandbox actor stays alive (preserves session history, audit log) but rejects new work + +**`"recreate"`:** +- Call provider `create()` to provision a fresh sandbox +- Store new `sandboxId` in state +- Emit `sandboxRecreated` event to connected clients with a notice that sessions are lost (new VM, no prior state) +- Resume normal operation with the new sandbox + +### 3. Expose `pause` action + +**File:** `src/sandbox/actor.ts` — actions + +Add a `pause` action that delegates to the provider's `pause()` method. This is user-initiated only (e.g. user clicks "Pause sandbox" in UI to save credits). The sandbox actor should never auto-pause. + +```typescript +async pause(c) { + await c.provider.pause(); + state.sandboxPaused = true; + c.broadcast("sandboxPaused", {}); +} +``` + +### 4. Expose `resume` action + +**File:** `src/sandbox/actor.ts` — actions + +Add a `resume` action for explicit recovery. Calls `provider.start({ sandboxId: state.sandboxId })` which auto-resumes if paused. + +```typescript +async resume(c) { + await ensureAgent(c); // handles reconnect internally + state.sandboxPaused = false; + c.broadcast("sandboxResumed", {}); +} +``` + +### 5. Keep-alive while sessions are active + +**File:** `src/sandbox/actor.ts` + +While the sandbox actor has connected WebSocket clients, periodically extend the underlying sandbox TTL to prevent it from being garbage collected mid-session. + +- On first client connect: start a keep-alive interval (e.g. every 2 minutes) +- Each tick: call `provider.extendTimeout(extensionMs)` (the provider maps this to `sandbox.setTimeout()` for E2B) +- On last client disconnect: clear the interval, let the sandbox idle toward its natural timeout + +This prevents the common case where a user is actively working but the sandbox expires because the E2B default timeout (5 min) is too short. The `timeoutMs` in create options is the initial TTL; keep-alive extends it dynamically. + +## Key invariant + +**Never silently fail.** Every destroyed/expired/error state must be surfaced to connected clients via events. The actor must always tell the UI what happened so the user can act on it. See CLAUDE.md "never silently catch errors" rule. + +## Dependencies + +These changes depend on the sandbox-agent provider contract exposing: +- `pause()` method +- `extendTimeout(ms)` method +- Typed `SandboxDestroyedError` thrown from `start()` when sandbox is gone +- `start()` auto-resuming paused sandboxes via `Sandbox.connect(sandboxId)` diff --git a/.context/proposal-task-owner-git-auth.md b/.context/proposal-task-owner-git-auth.md new file mode 100644 index 0000000..b2a35c8 --- /dev/null +++ b/.context/proposal-task-owner-git-auth.md @@ -0,0 +1,200 @@ +# Proposal: Task Primary Owner & Git Authentication + +## Problem + +Sandbox git operations (commit, push, PR creation) require authentication. +Currently, the sandbox has no user-scoped credentials. The E2B sandbox +clones repos using the GitHub App installation token, but push operations +need user-scoped auth so commits are attributed correctly and branch +protection rules are enforced. + +## Design + +### Concept: Primary User per Task + +Each task has a **primary user** (the "owner"). This is the last user who +sent a message on the task. Their GitHub OAuth credentials are injected +into the sandbox for git operations. When the owner changes, the sandbox +git config and credentials swap to the new user. + +### Data Model + +**Task actor DB** -- new `task_owner` single-row table: +- `primaryUserId` (text) -- better-auth user ID +- `primaryGithubLogin` (text) -- GitHub username (for `git config user.name`) +- `primaryGithubEmail` (text) -- GitHub email (for `git config user.email`) +- `primaryGithubAvatarUrl` (text) -- avatar for UI display +- `updatedAt` (integer) + +**Org coordinator** -- add to `taskSummaries` table: +- `primaryUserLogin` (text, nullable) +- `primaryUserAvatarUrl` (text, nullable) + +### Owner Swap Flow + +Triggered when `sendWorkspaceMessage` is called with a different user than +the current primary: + +1. `sendWorkspaceMessage(authSessionId, ...)` resolves user from auth session +2. Look up user's GitHub identity from auth account table (`providerId = "github"`) +3. Compare `primaryUserId` with current owner. If different: + a. Update `task_owner` row in task actor DB + b. Get user's OAuth `accessToken` from auth account + c. Push into sandbox via `runProcess`: + - `git config user.name "{login}"` + - `git config user.email "{email}"` + - Write token to `/home/user/.git-token` (or equivalent) + d. Push updated task summary to org coordinator (includes `primaryUserLogin`) + e. Broadcast `taskUpdated` to connected clients +4. If same user, no-op (token is still valid) + +### Token Injection + +The user's GitHub OAuth token (stored in better-auth account table) has +`repo` scope (verified -- see `better-auth.ts` line 480: `scope: ["read:org", "repo"]`). + +This is a standard **OAuth App** flow (not GitHub App OAuth). OAuth App +tokens do not expire unless explicitly revoked. No refresh logic is needed. + +**Injection method:** + +On first sandbox repo setup (`ensureSandboxRepo`), configure: + +```bash +# Write token file +echo "{token}" > /home/user/.git-token +chmod 600 /home/user/.git-token + +# Configure git to use it +git config --global credential.helper 'store --file=/home/user/.git-token' + +# Format: https://{login}:{token}@github.com +echo "https://{login}:{token}@github.com" > /home/user/.git-token +``` + +On owner swap, overwrite `/home/user/.git-token` with new user's credentials. + +**Important: git should never prompt for credentials.** The credential +store file ensures all git operations are auto-authenticated. No +`GIT_ASKPASS` prompts, no interactive auth. + +**Race condition (expected behavior):** If User A sends a message and the +agent starts a long git operation, then User B sends a message and triggers +an owner swap, the in-flight git process still has User A's credentials +(already read from the credential store). The next git operation uses +User B's credentials. This is expected behavior -- document in comments. + +### Token Validity + +OAuth App tokens (our flow) do not expire. They persist until the user +revokes them or the OAuth App is deauthorized. No periodic refresh needed. + +If a token becomes invalid (user revokes), git operations will fail with +a 401. The error surfaces through the standard `ensureSandboxRepo` / +`runProcess` error path and is displayed in the UI. + +### User Removal + +When a user is removed from the organization: +1. Org actor queries active tasks with that user as primary owner +2. For each, clear the `task_owner` row +3. Task actor clears the sandbox git credentials (overwrite credential file) +4. Push updated task summaries to org coordinator +5. Subsequent git operations fail with "No active owner -- assign an owner to enable git operations" + +### UI Changes + +**Right sidebar -- new "Overview" tab:** +- Add as a new tab alongside "Changes" and "All Files" +- Shows current primary user: avatar, name, login +- Click on the user -> dropdown of all workspace users (from org member list) +- Select a user -> triggers explicit owner swap (same flow as message-triggered) +- Also shows task metadata: branch, repo, created date + +**Left sidebar -- task items:** +- Show primary user's GitHub login in green text next to task name +- Only shown when there is an active owner + +**Task detail header:** +- Show small avatar of primary user next to task title + +### Org Coordinator + +`commandApplyTaskSummaryUpdate` already receives the full task summary +from the task actor. Add `primaryUserLogin` and `primaryUserAvatarUrl` +to the summary payload. The org writes it to `taskSummaries`. The sidebar +reads it from the org snapshot. + +### Sandbox Architecture Note + +Structurally, the system supports multiple sandboxes per task, but in +practice there is exactly one active sandbox per task. Design the owner +injection assuming one sandbox. The token is injected into the active +sandbox only. If multi-sandbox support is needed in the future, extend +the injection to target specific sandbox IDs. + +## Security Considerations + +### OAuth Token Scope + +The user's GitHub OAuth token has `repo` scope, which grants **full control +of all private repositories** the user has access to. When injected into +the sandbox: + +- The agent can read/write ANY repo the user has access to, not just the + task's target repo +- The token persists in the sandbox filesystem until overwritten +- Any process running in the sandbox can read the credential file + +**Mitigations:** +- Credential file has `chmod 600` (owner-read-only) +- Sandbox is isolated per-task (E2B VM boundary) +- Token is overwritten on owner swap (old user's token removed) +- Token is cleared on user removal from org +- Sandbox has a finite lifetime (E2B timeout + autoPause) + +**Accepted risk:** This is the standard trade-off for OAuth-based git +integrations (same as GitHub Codespaces, Gitpod, etc.). The user consents +to `repo` scope at sign-in time. Document this in user-facing terms in +the product's security/privacy page. + +### Future: Fine-grained tokens + +GitHub supports fine-grained personal access tokens scoped to specific +repos. A future improvement could mint per-repo tokens instead of using +the user's full OAuth token. This requires the user to create and manage +fine-grained tokens, which adds friction. Evaluate based on user feedback. + +## Implementation Order + +1. Add `task_owner` table to task actor schema + migration +2. Add `primaryUserLogin` / `primaryUserAvatarUrl` to `taskSummaries` schema + migration +3. Implement owner swap in `sendWorkspaceMessage` flow +4. Implement credential injection in `ensureSandboxRepo` +5. Implement credential swap via `runProcess` on owner change +6. Implement user removal cleanup in org actor +7. Add "Overview" tab to right sidebar +8. Add owner display to left sidebar task items +9. Add owner picker dropdown in Overview tab +10. Update org coordinator to propagate owner in task summaries + +## Files to Modify + +### Backend +- `foundry/packages/backend/src/actors/task/db/schema.ts` -- add `task_owner` table +- `foundry/packages/backend/src/actors/task/db/migrations.ts` -- add migration +- `foundry/packages/backend/src/actors/organization/db/schema.ts` -- add owner columns to `taskSummaries` +- `foundry/packages/backend/src/actors/organization/db/migrations.ts` -- add migration +- `foundry/packages/backend/src/actors/task/workspace.ts` -- owner swap logic in `sendWorkspaceMessage`, credential injection in `ensureSandboxRepo` +- `foundry/packages/backend/src/actors/task/workflow/index.ts` -- wire owner swap action +- `foundry/packages/backend/src/actors/organization/actions/task-mutations.ts` -- propagate owner in summaries +- `foundry/packages/backend/src/actors/organization/actions/tasks.ts` -- `sendWorkspaceMessage` owner check +- `foundry/packages/backend/src/services/better-auth.ts` -- expose `getAccessTokenForSession` for owner lookup + +### Shared +- `foundry/packages/shared/src/types.ts` -- add `primaryUserLogin` to `TaskSummary` + +### Frontend +- `foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx` -- add Overview tab +- `foundry/packages/frontend/src/components/organization-dashboard.tsx` -- show owner in sidebar task items +- `foundry/packages/frontend/src/components/mock-layout.tsx` -- wire Overview tab state diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 268b04c..5822546 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -136,6 +136,14 @@ The client subscribes to `app` always, `organization` when entering an organizat - Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent organization actor, which broadcasts to organization subscribers. - Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone. +## Sandbox Architecture + +- Structurally, the system supports multiple sandboxes per task, but in practice there is exactly one active sandbox per task. Design features assuming one sandbox per task. If multi-sandbox is needed in the future, extend at that time. +- Each task has a **primary user** (owner) whose GitHub OAuth credentials are injected into the sandbox for git operations. The owner swaps when a different user sends a message. See `.context/proposal-task-owner-git-auth.md` for the full design. +- **Security: OAuth token scope.** The user's GitHub OAuth token has `repo` scope, granting full control of all private repositories the user has access to. When the user is the active task owner, their token is injected into the sandbox. This means the agent can read/write ANY repo the user has access to, not just the task's target repo. This is the standard trade-off for OAuth-based git integrations (same as GitHub Codespaces, Gitpod). The user consents to `repo` scope at sign-in time. Credential files in the sandbox are `chmod 600` and overwritten on owner swap. +- All git operations in the sandbox must be auto-authenticated. Never configure git to prompt for credentials (no interactive `GIT_ASKPASS` prompts). Use a credential store file that is pre-populated with the active owner's token. +- All git operation errors (push 401, clone failure, branch protection rejection) must surface in the UI with actionable context. Never silently swallow git errors. + ## Git State Policy - The backend stores zero git state. No local clones, no refs, no working trees, and no git-spice. diff --git a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts index 73abea2..3049bb4 100644 --- a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts +++ b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts @@ -64,6 +64,8 @@ function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) { branch: taskSummary.branch, pullRequestJson: JSON.stringify(taskSummary.pullRequest), sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + primaryUserLogin: taskSummary.primaryUserLogin ?? null, + primaryUserAvatarUrl: taskSummary.primaryUserAvatarUrl ?? null, }; } @@ -78,6 +80,8 @@ export function taskSummaryFromRow(repoId: string, row: any): WorkspaceTaskSumma branch: row.branch ?? null, pullRequest: parseJsonValue(row.pullRequestJson, null), sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + primaryUserLogin: row.primaryUserLogin ?? null, + primaryUserAvatarUrl: row.primaryUserAvatarUrl ?? null, }; } diff --git a/foundry/packages/backend/src/actors/organization/actions/tasks.ts b/foundry/packages/backend/src/actors/organization/actions/tasks.ts index 118ff15..c3794e1 100644 --- a/foundry/packages/backend/src/actors/organization/actions/tasks.ts +++ b/foundry/packages/backend/src/actors/organization/actions/tasks.ts @@ -10,6 +10,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceDiffInput, TaskWorkspaceRenameInput, @@ -217,6 +218,16 @@ export const organizationTaskActions = { void task.publishPr({}).catch(() => {}); }, + async changeWorkspaceTaskOwner(c: any, input: TaskWorkspaceChangeOwnerInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.changeOwner({ + primaryUserId: input.targetUserId, + primaryGithubLogin: input.targetUserName, + primaryGithubEmail: input.targetUserEmail, + primaryGithubAvatarUrl: null, + }); + }, + async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); void task.revertFile(input).catch(() => {}); diff --git a/foundry/packages/backend/src/actors/organization/db/migrations.ts b/foundry/packages/backend/src/actors/organization/db/migrations.ts index a7e8abc..6a19075 100644 --- a/foundry/packages/backend/src/actors/organization/db/migrations.ts +++ b/foundry/packages/backend/src/actors/organization/db/migrations.ts @@ -16,6 +16,12 @@ const journal = { tag: "0001_add_auth_and_task_tables", breakpoints: true, }, + { + idx: 2, + when: 1773984000000, + tag: "0002_add_task_owner_columns", + breakpoints: true, + }, ], } as const; @@ -165,6 +171,10 @@ CREATE TABLE \`task_summaries\` ( \`pull_request_json\` text, \`sessions_summary_json\` text DEFAULT '[]' NOT NULL ); +`, + m0002: `ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_login\` text; +--> statement-breakpoint +ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_avatar_url\` text; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/organization/db/schema.ts b/foundry/packages/backend/src/actors/organization/db/schema.ts index 5071a25..3978a5f 100644 --- a/foundry/packages/backend/src/actors/organization/db/schema.ts +++ b/foundry/packages/backend/src/actors/organization/db/schema.ts @@ -40,6 +40,8 @@ export const taskSummaries = sqliteTable("task_summaries", { branch: text("branch"), pullRequestJson: text("pull_request_json"), sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), + primaryUserLogin: text("primary_user_login"), + primaryUserAvatarUrl: text("primary_user_avatar_url"), }); export const organizationProfile = sqliteTable( diff --git a/foundry/packages/backend/src/actors/task/db/migrations.ts b/foundry/packages/backend/src/actors/task/db/migrations.ts index 1e6ff76..61b0dff 100644 --- a/foundry/packages/backend/src/actors/task/db/migrations.ts +++ b/foundry/packages/backend/src/actors/task/db/migrations.ts @@ -10,6 +10,12 @@ const journal = { tag: "0000_charming_maestro", breakpoints: true, }, + { + idx: 1, + when: 1773984000000, + tag: "0001_add_task_owner", + breakpoints: true, + }, ], } as const; @@ -65,6 +71,16 @@ CREATE TABLE \`task_workspace_sessions\` ( \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL ); +`, + m0001: `CREATE TABLE \`task_owner\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`primary_user_id\` text, + \`primary_github_login\` text, + \`primary_github_email\` text, + \`primary_github_avatar_url\` text, + \`updated_at\` integer NOT NULL, + CONSTRAINT "task_owner_singleton_id_check" CHECK("task_owner"."id" = 1) +); `, } as const, }; diff --git a/foundry/packages/backend/src/actors/task/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts index 651ff76..bdb7cf7 100644 --- a/foundry/packages/backend/src/actors/task/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -47,6 +47,24 @@ export const taskSandboxes = sqliteTable("task_sandboxes", { updatedAt: integer("updated_at").notNull(), }); +/** + * Single-row table tracking the primary user (owner) of this task. + * The owner's GitHub OAuth credentials are injected into the sandbox + * for git operations. Updated when a different user sends a message. + */ +export const taskOwner = sqliteTable( + "task_owner", + { + id: integer("id").primaryKey(), + primaryUserId: text("primary_user_id"), + primaryGithubLogin: text("primary_github_login"), + primaryGithubEmail: text("primary_github_email"), + primaryGithubAvatarUrl: text("primary_github_avatar_url"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("task_owner_singleton_id_check", sql`${table.id} = 1`)], +); + /** * Coordinator index of workspace sessions within this task. * The task actor is the coordinator for sessions. Each row holds session diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index 69004ee..de25813 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -12,6 +12,7 @@ import { killWriteDbActivity, } from "./commands.js"; import { + changeTaskOwnerManually, changeWorkspaceModel, closeWorkspaceSession, createWorkspaceSession, @@ -176,6 +177,16 @@ export const taskCommandActions = { return { ok: true }; }, + async changeOwner(c: any, body: any) { + await changeTaskOwnerManually(c, { + primaryUserId: body.primaryUserId, + primaryGithubLogin: body.primaryGithubLogin, + primaryGithubEmail: body.primaryGithubEmail, + primaryGithubAvatarUrl: body.primaryGithubAvatarUrl ?? null, + }); + return { ok: true }; + }, + async createSession(c: any, body: any) { return await createWorkspaceSession(c, body?.model, body?.authSessionId); }, diff --git a/foundry/packages/backend/src/actors/task/workspace.ts b/foundry/packages/backend/src/actors/task/workspace.ts index 7505d01..ac2430f 100644 --- a/foundry/packages/backend/src/actors/task/workspace.ts +++ b/foundry/packages/backend/src/actors/task/workspace.ts @@ -19,7 +19,7 @@ import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { githubRepoFullNameFromRemote } from "../../services/repo.js"; // organization actions called directly (no queue) -import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; +import { task as taskTable, taskOwner, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; function emptyGitState() { @@ -123,6 +123,193 @@ function parseGitState(value: string | null | undefined): { fileChanges: Array { + const row = await c.db.select().from(taskOwner).where(eq(taskOwner.id, 1)).get(); + if (!row) { + return null; + } + return { + primaryUserId: row.primaryUserId ?? null, + primaryGithubLogin: row.primaryGithubLogin ?? null, + primaryGithubEmail: row.primaryGithubEmail ?? null, + primaryGithubAvatarUrl: row.primaryGithubAvatarUrl ?? null, + }; +} + +async function upsertTaskOwner( + c: any, + owner: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + const now = Date.now(); + await c.db + .insert(taskOwner) + .values({ + id: 1, + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: taskOwner.id, + set: { + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }, + }) + .run(); +} + +/** + * Inject the user's GitHub OAuth token into the sandbox as a git credential store file. + * Also configures git user.name and user.email so commits are attributed correctly. + * The credential file is overwritten on each owner swap. + * + * Race condition note: If User A sends a message and the agent starts a long git operation, + * then User B triggers an owner swap, the in-flight git process still has User A's credentials + * (already read from the credential store). The next git operation uses User B's credentials. + */ +async function injectGitCredentials(sandbox: any, login: string, email: string, token: string): Promise { + const script = [ + "set -euo pipefail", + `git config --global user.name ${JSON.stringify(login)}`, + `git config --global user.email ${JSON.stringify(email)}`, + `git config --global credential.helper 'store --file=/home/user/.git-token'`, + `printf '%s\\n' ${JSON.stringify(`https://${login}:${token}@github.com`)} > /home/user/.git-token`, + `chmod 600 /home/user/.git-token`, + ]; + const result = await sandbox.runProcess({ + command: "bash", + args: ["-lc", script.join("; ")], + cwd: "/", + timeoutMs: 30_000, + }); + if ((result.exitCode ?? 0) !== 0) { + logActorWarning("task", "git credential injection failed", { + exitCode: result.exitCode, + output: [result.stdout, result.stderr].filter(Boolean).join(""), + }); + } +} + +/** + * Resolves the current user's GitHub identity from their auth session. + * Returns null if the session is invalid or the user has no GitHub account. + */ +async function resolveGithubIdentity(authSessionId: string): Promise<{ + userId: string; + login: string; + email: string; + avatarUrl: string | null; + accessToken: string; +} | null> { + const authService = getBetterAuthService(); + const authState = await authService.getAuthState(authSessionId); + if (!authState?.user?.id) { + return null; + } + + const tokenResult = await authService.getAccessTokenForSession(authSessionId); + if (!tokenResult?.accessToken) { + return null; + } + + const githubAccount = authState.accounts?.find((account: any) => account.providerId === "github"); + if (!githubAccount) { + return null; + } + + // Resolve the GitHub login from the API since Better Auth only stores the + // numeric account ID, not the login username. + let login = authState.user.name ?? "unknown"; + let avatarUrl = authState.user.image ?? null; + try { + const resp = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenResult.accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + if (resp.ok) { + const ghUser = (await resp.json()) as { login?: string; avatar_url?: string }; + if (ghUser.login) { + login = ghUser.login; + } + if (ghUser.avatar_url) { + avatarUrl = ghUser.avatar_url; + } + } + } catch (error) { + console.warn("resolveGithubIdentity: failed to fetch GitHub user", error); + } + + return { + userId: authState.user.id, + login, + email: authState.user.email ?? `${githubAccount.accountId}@users.noreply.github.com`, + avatarUrl, + accessToken: tokenResult.accessToken, + }; +} + +/** + * Check if the task owner needs to swap, and if so, update the owner record + * and inject new git credentials into the sandbox. + * Returns true if an owner swap occurred. + */ +async function maybeSwapTaskOwner(c: any, authSessionId: string | null | undefined, sandbox: any | null): Promise { + if (!authSessionId) { + return false; + } + + const identity = await resolveGithubIdentity(authSessionId); + if (!identity) { + return false; + } + + const currentOwner = await readTaskOwner(c); + if (currentOwner?.primaryUserId === identity.userId) { + return false; + } + + await upsertTaskOwner(c, { + primaryUserId: identity.userId, + primaryGithubLogin: identity.login, + primaryGithubEmail: identity.email, + primaryGithubAvatarUrl: identity.avatarUrl, + }); + + if (sandbox) { + await injectGitCredentials(sandbox, identity.login, identity.email, identity.accessToken); + } + + return true; +} + +/** + * Manually change the task owner. Updates the owner record and broadcasts the + * change to subscribers. Git credentials are NOT injected here — they will be + * injected the next time the target user sends a message (auto-swap path). + */ +export async function changeTaskOwnerManually( + c: any, + input: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + await upsertTaskOwner(c, input); + await broadcastTaskUpdate(c); +} + export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { if (status === "running") { return false; @@ -443,7 +630,7 @@ async function getTaskSandboxRuntime( */ let sandboxRepoPrepared = false; -async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean }): Promise { +async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean; authSessionId?: string | null }): Promise { if (!record.branchName) { throw new Error("cannot prepare a sandbox repo before the task branch exists"); } @@ -489,6 +676,12 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski throw new Error(`sandbox repo preparation failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`); } + // On first repo preparation, inject the task owner's git credentials into the sandbox + // so that push/commit operations are authenticated and attributed to the correct user. + if (!sandboxRepoPrepared && opts?.authSessionId) { + await maybeSwapTaskOwner(c, opts.authSessionId, sandbox); + } + sandboxRepoPrepared = true; } @@ -862,6 +1055,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P const activeSessionId = userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null; + const owner = await readTaskOwner(c); + return { id: c.state.taskId, repoId: c.state.repoId, @@ -873,6 +1068,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P pullRequest: record.pullRequest ?? null, activeSessionId, sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))), + primaryUserLogin: owner?.primaryGithubLogin ?? null, + primaryUserAvatarUrl: owner?.primaryGithubAvatarUrl ?? null, }; } @@ -1212,7 +1409,14 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri const runtime = await getTaskSandboxRuntime(c, record); // Skip git fetch on subsequent messages — the repo was already prepared during session // creation. This avoids a 5-30s network round-trip to GitHub on every prompt. - await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true }); + await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true, authSessionId }); + + // Check if the task owner needs to swap. If a different user is sending this message, + // update the owner record and inject their git credentials into the sandbox. + const ownerSwapped = await maybeSwapTaskOwner(c, authSessionId, runtime.sandbox); + if (ownerSwapped) { + await broadcastTaskUpdate(c); + } const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)].filter( Boolean, ); diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 0903aa8..c2222cc 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -12,6 +12,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -110,6 +111,7 @@ interface OrganizationHandle { stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise; + changeWorkspaceTaskOwner(input: TaskWorkspaceChangeOwnerInput & AuthSessionScopedInput): Promise; revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(input: { repoId: string }): Promise; @@ -304,6 +306,7 @@ export interface BackendClient { stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise; + changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise; revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise; adminReloadGithubOrganization(organizationId: string): Promise; adminReloadGithubRepository(organizationId: string, repoId: string): Promise; @@ -1282,6 +1285,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input)); }, + async changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise { + await (await organization(organizationId)).changeWorkspaceTaskOwner(await withAuthSessionInput(input)); + }, + async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise { await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input)); }, diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index fc6470c..5ef675d 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -188,6 +188,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back unread: tab.unread, created: tab.created, })), + primaryUserLogin: null, + primaryUserAvatarUrl: null, }); const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({ @@ -750,6 +752,15 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back emitTaskUpdate(input.taskId); }, + async changeWorkspaceTaskOwner( + _organizationId: string, + input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }, + ): Promise { + await workspace.changeOwner(input); + emitOrganizationSnapshot(); + emitTaskUpdate(input.taskId); + }, + async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise { await workspace.revertFile(input); emitOrganizationSnapshot(); diff --git a/foundry/packages/client/src/mock/workspace-client.ts b/foundry/packages/client/src/mock/workspace-client.ts index c51b2e8..7983e0f 100644 --- a/foundry/packages/client/src/mock/workspace-client.ts +++ b/foundry/packages/client/src/mock/workspace-client.ts @@ -349,7 +349,10 @@ class MockWorkspaceStore implements TaskWorkspaceClient { return { ...currentTask, - activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId, + activeSessionId: + currentTask.activeSessionId === input.sessionId + ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) + : currentTask.activeSessionId, sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId), }; }); @@ -396,6 +399,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient { })); } + async changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise { + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + primaryUserLogin: input.targetUserName, + primaryUserAvatarUrl: null, + })); + } + private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void { const nextSnapshot = updater(this.snapshot); this.snapshot = { diff --git a/foundry/packages/client/src/remote/workspace-client.ts b/foundry/packages/client/src/remote/workspace-client.ts index 1b6bc8e..2a11f51 100644 --- a/foundry/packages/client/src/remote/workspace-client.ts +++ b/foundry/packages/client/src/remote/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -140,6 +141,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient { await this.refresh(); } + async changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise { + await this.backend.changeWorkspaceTaskOwner(this.organizationId, input); + await this.refresh(); + } + private ensureStarted(): void { if (!this.unsubscribeWorkspace) { this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => { diff --git a/foundry/packages/client/src/workspace-client.ts b/foundry/packages/client/src/workspace-client.ts index c3293a0..6662352 100644 --- a/foundry/packages/client/src/workspace-client.ts +++ b/foundry/packages/client/src/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -43,6 +44,7 @@ export interface TaskWorkspaceClient { closeSession(input: TaskWorkspaceSessionInput): Promise; addSession(input: TaskWorkspaceSelectInput): Promise; changeModel(input: TaskWorkspaceChangeModelInput): Promise; + changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise; } export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient { diff --git a/foundry/packages/client/test/subscription-manager.test.ts b/foundry/packages/client/test/subscription-manager.test.ts index c064606..f0a29c2 100644 --- a/foundry/packages/client/test/subscription-manager.test.ts +++ b/foundry/packages/client/test/subscription-manager.test.ts @@ -77,6 +77,8 @@ function organizationSnapshot(): OrganizationSummarySnapshot { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }; @@ -159,6 +161,8 @@ describe("RemoteSubscriptionManager", () => { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }, diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index 042b5a4..797b650 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -42,7 +42,7 @@ import { type Message, type ModelId, } from "./mock-layout/view-model"; -import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import { activeMockOrganization, activeMockUser, getMockOrganizationById, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; import { backendClient } from "../lib/backend"; import { subscriptionManager } from "../lib/subscription"; import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status"; @@ -188,6 +188,8 @@ function toTaskModel( fileTree: detail?.fileTree ?? [], minutesUsed: detail?.minutesUsed ?? 0, activeSandboxId: detail?.activeSandboxId ?? null, + primaryUserLogin: detail?.primaryUserLogin ?? summary.primaryUserLogin ?? null, + primaryUserAvatarUrl: detail?.primaryUserAvatarUrl ?? summary.primaryUserAvatarUrl ?? null, }; } @@ -264,6 +266,7 @@ interface WorkspaceActions { closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise; addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>; changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise; + changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(repoId: string): Promise; } @@ -1069,6 +1072,8 @@ const RightRail = memo(function RightRail({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { organizationId: string; @@ -1078,6 +1083,8 @@ const RightRail = memo(function RightRail({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); @@ -1170,6 +1177,8 @@ const RightRail = memo(function RightRail({ onArchive={onArchive} onRevertFile={onRevertFile} onPublishPr={onPublishPr} + onChangeOwner={onChangeOwner} + members={members} onToggleSidebar={onToggleSidebar} /> @@ -1311,6 +1320,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input), addSession: (input) => backendClient.createWorkspaceSession(organizationId, input), changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input), + changeOwner: (input) => backendClient.changeWorkspaceTaskOwner(organizationId, input), adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId), adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId), }), @@ -1741,6 +1751,22 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } [tasks], ); + const changeOwner = useCallback( + (member: { id: string; name: string; email: string }) => { + if (!activeTask) { + throw new Error("Cannot change owner without an active task"); + } + void taskWorkspaceClient.changeOwner({ + repoId: activeTask.repoId, + taskId: activeTask.id, + targetUserId: member.id, + targetUserName: member.name, + targetUserEmail: member.email, + }); + }, + [activeTask], + ); + const archiveTask = useCallback(() => { if (!activeTask) { throw new Error("Cannot archive without an active task"); @@ -2167,6 +2193,8 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onArchive={archiveTask} onRevertFile={revertFile} onPublishPr={publishPr} + onChangeOwner={changeOwner} + members={getMockOrganizationById(appSnapshot, organizationId)?.members ?? []} onToggleSidebar={() => setRightSidebarOpen(false)} /> diff --git a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx index cd4c33a..ca7b580 100644 --- a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -1,7 +1,20 @@ -import { memo, useCallback, useMemo, useState, type MouseEvent } from "react"; +import { memo, useCallback, useMemo, useRef, useState, type MouseEvent } from "react"; import { useStyletron } from "baseui"; -import { LabelSmall } from "baseui/typography"; -import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react"; +import { LabelSmall, LabelXSmall } from "baseui/typography"; +import { + Archive, + ArrowUpFromLine, + ChevronDown, + ChevronRight, + FileCode, + FilePlus, + FileX, + FolderOpen, + GitBranch, + GitPullRequest, + PanelRight, + User, +} from "lucide-react"; import { useFoundryTokens } from "../../app/theme"; import { createErrorContext } from "@sandbox-agent/foundry-shared"; @@ -99,6 +112,8 @@ export const RightSidebar = memo(function RightSidebar({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { task: Task; @@ -107,11 +122,13 @@ export const RightSidebar = memo(function RightSidebar({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); - const [rightTab, setRightTab] = useState<"changes" | "files">("changes"); + const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("changes"); const contextMenu = useContextMenu(); const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); const isTerminal = task.status === "archived"; @@ -125,6 +142,8 @@ export const RightSidebar = memo(function RightSidebar({ }); observer.observe(node); }, []); + const [ownerDropdownOpen, setOwnerDropdownOpen] = useState(false); + const ownerDropdownRef = useRef(null); const pullRequestUrl = task.pullRequest?.url ?? null; const copyFilePath = useCallback(async (path: string) => { @@ -310,7 +329,7 @@ export const RightSidebar = memo(function RightSidebar({ })} > + + + + + + {error &&
{error}
} + {screenshotError &&
{screenshotError}
} + +
+
+ + + Desktop Runtime + + + {status?.state ?? "unknown"} + +
+ +
+
+
Display
+
{status?.display ?? "Not assigned"}
+
+
+
Resolution
+
{resolutionLabel}
+
+
+
Started
+
{formatStartedAt(status?.startedAt)}
+
+
+ +
+
+ + setWidth(event.target.value)} inputMode="numeric" /> +
+
+ + setHeight(event.target.value)} inputMode="numeric" /> +
+
+ + setDpi(event.target.value)} inputMode="numeric" /> +
+
+ +
+ + +
+
+ + {status?.missingDependencies && status.missingDependencies.length > 0 && ( +
+
+ Missing Dependencies +
+
+ {status.missingDependencies.map((dependency) => ( + + {dependency} + + ))} +
+ {status.installCommand && ( + <> +
+ Install command +
+
{status.installCommand}
+ + )} +
+ )} + + {(status?.lastError || status?.runtimeLogPath || (status?.processes?.length ?? 0) > 0) && ( +
+
+ Diagnostics +
+ {status?.lastError && ( +
+
Last error
+
{status.lastError.code}
+
{status.lastError.message}
+
+ )} + {status?.runtimeLogPath && ( +
+
Runtime log
+
{status.runtimeLogPath}
+
+ )} + {status?.processes && status.processes.length > 0 && ( +
+
Processes
+
+ {status.processes.map((process) => ( +
+
+ {process.name} + + {process.running ? "running" : "stopped"} + +
+
{process.pid ? `pid ${process.pid}` : "no pid"}
+ {process.logPath &&
{process.logPath}
} +
+ ))} +
+
+ )} +
+ )} + +
+
+ Latest Screenshot + {status?.state === "active" ? Manual refresh only : null} +
+ + {loading ?
Loading...
: null} + {!loading && !screenshotUrl && ( +
+ {status?.state === "active" ? "No screenshot loaded yet." : "Start the desktop runtime to capture a screenshot."} +
+ )} + {screenshotUrl && ( +
+ Desktop screenshot +
+ )} +
+ + ); +}; + +export default DesktopTab; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8824736..fe82ce0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - '@types/react': ^19.1.12 - '@types/react-dom': ^19.1.6 + '@types/react': ^18.3.3 + '@types/react-dom': ^18.3.0 importers: @@ -29,7 +29,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) examples/boxlite: dependencies: @@ -73,16 +73,16 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: latest - version: 4.20260316.1 + version: 4.20260313.1 '@types/node': specifier: latest version: 25.5.0 '@types/react': - specifier: ^19.1.12 - version: 19.1.12 + specifier: ^18.3.3 + version: 18.3.27 '@types/react-dom': - specifier: ^19.1.6 - version: 19.2.3(@types/react@19.1.12) + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^4.5.0 version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) @@ -94,10 +94,10 @@ importers: version: 6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: latest - version: 4.73.0(@cloudflare/workers-types@4.20260316.1) + version: 4.73.0(@cloudflare/workers-types@4.20260313.1) examples/computesdk: dependencies: @@ -122,7 +122,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) examples/daytona: dependencies: @@ -154,13 +154,13 @@ importers: dockerode: specifier: latest version: 4.0.9 - get-port: - specifier: latest - version: 7.1.0 sandbox-agent: specifier: workspace:* version: link:../../sdks/typescript devDependencies: + '@types/dockerode': + specifier: latest + version: 4.0.1 '@types/node': specifier: latest version: 25.5.0 @@ -172,7 +172,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) examples/e2b: dependencies: @@ -197,7 +197,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) examples/file-system: dependencies: @@ -300,7 +300,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) examples/permissions: dependencies: @@ -345,6 +345,9 @@ importers: '@sandbox-agent/example-shared': specifier: workspace:* version: link:../shared + '@sandbox-agent/persist-postgres': + specifier: workspace:* + version: link:../../sdks/persist-postgres pg: specifier: latest version: 8.20.0 @@ -370,16 +373,13 @@ importers: '@sandbox-agent/example-shared': specifier: workspace:* version: link:../shared - better-sqlite3: - specifier: ^11.0.0 - version: 11.10.0 + '@sandbox-agent/persist-sqlite': + specifier: workspace:* + version: link:../../sdks/persist-sqlite sandbox-agent: specifier: workspace:* version: link:../../sdks/typescript devDependencies: - '@types/better-sqlite3': - specifier: ^7.0.0 - version: 7.6.13 '@types/node': specifier: latest version: 25.5.0 @@ -473,7 +473,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) foundry/packages/backend: dependencies: @@ -492,9 +492,12 @@ importers: '@sandbox-agent/foundry-shared': specifier: workspace:* version: link:../shared + '@sandbox-agent/persist-rivet': + specifier: workspace:* + version: link:../../../sdks/persist-rivet better-auth: specifier: ^1.5.5 - version: 1.5.5(@cloudflare/workers-types@4.20260316.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.5.5(@cloudflare/workers-types@4.20260313.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)) dockerode: specifier: ^4.0.9 version: 4.0.9 @@ -503,7 +506,7 @@ importers: version: 0.31.9 drizzle-orm: specifier: ^0.44.5 - version: 0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) + version: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) hono: specifier: ^4.11.9 version: 4.12.2 @@ -512,7 +515,7 @@ importers: version: 10.3.1 rivetkit: specifier: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a - version: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0) + version: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0) sandbox-agent: specifier: workspace:* version: link:../../../sdks/typescript @@ -543,14 +546,14 @@ importers: version: 19.2.4 rivetkit: specifier: 2.1.6 - version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0) + version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0) sandbox-agent: specifier: workspace:* version: link:../../../sdks/typescript devDependencies: '@types/react': - specifier: ^19.1.12 - version: 19.1.12 + specifier: ^18.3.3 + version: 18.3.27 tsup: specifier: ^8.5.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -577,7 +580,7 @@ importers: version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4) baseui: specifier: ^16.1.1 - version: 16.1.1(@types/react@19.1.12)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)) + version: 16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)) lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.2.4) @@ -599,19 +602,19 @@ importers: devDependencies: '@react-grab/mcp': specifier: ^0.1.13 - version: 0.1.27(@types/react@19.1.12)(react@19.2.4) + version: 0.1.27(@types/react@18.3.27)(react@19.2.4) '@types/react': - specifier: ^19.1.12 - version: 19.1.12 + specifier: ^18.3.3 + version: 18.3.27 '@types/react-dom': - specifier: ^19.1.6 - version: 19.2.3(@types/react@19.1.12) + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^5.0.3 version: 5.1.4(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) react-grab: specifier: ^0.1.13 - version: 0.1.27(@types/react@19.1.12)(react@19.2.4) + version: 0.1.27(@types/react@18.3.27)(react@19.2.4) tsup: specifier: ^8.5.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -637,6 +640,9 @@ importers: frontend/packages/inspector: dependencies: + '@sandbox-agent/persist-indexeddb': + specifier: workspace:* + version: link:../../../sdks/persist-indexeddb lucide-react: specifier: ^0.469.0 version: 0.469.0(react@18.3.1) @@ -651,17 +657,20 @@ importers: specifier: workspace:* version: link:../../../sdks/react '@types/react': - specifier: ^19.1.12 - version: 19.1.12 + specifier: ^18.3.3 + version: 18.3.27 '@types/react-dom': - specifier: ^19.1.6 - version: 19.2.3(@types/react@19.1.12) + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.7.0(vite@5.4.21(@types/node@25.5.0)) fake-indexeddb: specifier: ^6.2.4 version: 6.2.5 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 sandbox-agent: specifier: workspace:* version: link:../../../sdks/typescript @@ -673,13 +682,13 @@ importers: version: 5.4.21(@types/node@25.5.0) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) frontend/packages/website: dependencies: '@astrojs/react': specifier: ^4.2.0 - version: 4.4.2(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.1.12))(@types/react@19.1.12)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) + version: 4.4.2(@types/node@25.5.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) '@astrojs/sitemap': specifier: ^3.2.0 version: 3.7.0 @@ -706,11 +715,11 @@ importers: version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) devDependencies: '@types/react': - specifier: ^19.1.12 - version: 19.1.12 + specifier: ^18.3.3 + version: 18.3.27 '@types/react-dom': - specifier: ^19.1.6 - version: 19.2.3(@types/react@19.1.12) + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -790,8 +799,8 @@ importers: sdks/acp-http-client: dependencies: '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^0.14.1 + version: 0.14.1(zod@4.3.6) devDependencies: '@types/node': specifier: ^22.0.0 @@ -804,7 +813,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/cli: dependencies: @@ -830,7 +839,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/cli-shared: devDependencies: @@ -878,7 +887,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/gigacode/platforms/darwin-arm64: {} @@ -891,30 +900,57 @@ importers: sdks/gigacode/platforms/win32-x64: {} sdks/persist-indexeddb: + dependencies: + sandbox-agent: + specifier: workspace:* + version: link:../typescript devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.7 + fake-indexeddb: + specifier: ^6.2.4 + version: 6.2.5 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/persist-postgres: + dependencies: + pg: + specifier: ^8.16.3 + version: 8.18.0 + sandbox-agent: + specifier: workspace:* + version: link:../typescript devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.7 + '@types/pg': + specifier: ^8.15.6 + version: 8.16.0 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/persist-rivet: + dependencies: + sandbox-agent: + specifier: workspace:* + version: link:../typescript devDependencies: '@types/node': specifier: ^22.0.0 @@ -925,9 +961,22 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/persist-sqlite: + dependencies: + better-sqlite3: + specifier: ^11.0.0 + version: 11.10.0 + sandbox-agent: + specifier: workspace:* + version: link:../typescript devDependencies: + '@types/better-sqlite3': + specifier: ^7.0.0 + version: 7.6.13 '@types/node': specifier: ^22.0.0 version: 22.19.7 @@ -937,22 +986,25 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) sdks/react: dependencies: '@tanstack/react-virtual': specifier: ^3.13.22 - version: 3.13.22(react-dom@19.2.4(react@19.1.1))(react@19.1.1) + version: 3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1) ghostty-web: specifier: ^0.4.0 version: 0.4.0 devDependencies: '@types/react': - specifier: ^19.1.12 - version: 19.1.12 + specifier: ^18.3.3 + version: 18.3.27 react: - specifier: ^19.1.1 - version: 19.1.1 + specifier: ^18.3.1 + version: 18.3.1 sandbox-agent: specifier: workspace:* version: link:../typescript @@ -976,39 +1028,12 @@ importers: specifier: workspace:* version: link:../cli devDependencies: - '@cloudflare/sandbox': - specifier: '>=0.1.0' - version: 0.7.17(@opencode-ai/sdk@1.2.24) - '@daytonaio/sdk': - specifier: '>=0.12.0' - version: 0.151.0(ws@8.19.0) - '@e2b/code-interpreter': - specifier: '>=1.0.0' - version: 2.3.3 - '@types/dockerode': - specifier: ^4.0.0 - version: 4.0.1 '@types/node': specifier: ^22.0.0 version: 22.19.7 '@types/ws': specifier: ^8.18.1 version: 8.18.1 - '@vercel/sandbox': - specifier: '>=0.1.0' - version: 1.8.1 - computesdk: - specifier: '>=0.1.0' - version: 2.5.0 - dockerode: - specifier: '>=4.0.0' - version: 4.0.9 - get-port: - specifier: '>=7.0.0' - version: 7.1.0 - modal: - specifier: '>=0.1.0' - version: 0.7.3 openapi-typescript: specifier: ^6.7.0 version: 6.7.6 @@ -1020,7 +1045,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) ws: specifier: ^8.19.0 version: 8.19.0 @@ -1039,7 +1064,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -1048,11 +1073,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@agentclientprotocol/sdk@0.16.1': - resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1061,6 +1081,9 @@ packages: resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==} hasBin: true + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asteasolutions/zod-to-openapi@8.4.3': resolution: {integrity: sha512-lwfMTN7kDbFDwMniYZUebiGGHxVGBw9ZSI4IBYjm6Ey22Kd5z/fsQb2k+Okr8WMbCCC553vi/ZM9utl5/XcvuQ==} peerDependencies: @@ -1083,8 +1106,8 @@ packages: resolution: {integrity: sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} peerDependencies: - '@types/react': ^19.1.12 - '@types/react-dom': ^19.1.6 + '@types/react': ^18.3.3 + '@types/react-dom': ^18.3.0 react: ^17.0.2 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 @@ -1644,8 +1667,8 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260316.1': - resolution: {integrity: sha512-HUZ+vQD8/1A4Fz/8WAlzYWcS5W5u3Nu7Dv9adkIkmLfeKqMIRn01vc4nSUBar60KkmohyQHkPi8jtWV/zazvAg==} + '@cloudflare/workers-types@4.20260313.1': + resolution: {integrity: sha512-jMEeX3RKfOSVqqXRKr/ulgglcTloeMzSH3FdzIfqJHtvc12/ELKd5Ldsg8ZHahKX/4eRxYdw3kbzb8jLXbq/jQ==} '@computesdk/cmd@0.4.1': resolution: {integrity: sha512-hhcYrwMnOpRSwWma3gkUeAVsDFG56nURwSaQx8vCepv0IuUv39bK4mMkgszolnUQrVjBDdW7b3lV+l5B2S8fRA==} @@ -1665,6 +1688,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@date-io/core@2.17.0': resolution: {integrity: sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==} @@ -3585,21 +3636,27 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/pg@8.18.0': resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: - '@types/react': ^19.1.12 + '@types/react': ^18.3.3 '@types/react-reconciler@0.28.9': resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} peerDependencies: - '@types/react': ^19.1.12 + '@types/react': ^18.3.3 - '@types/react@19.1.12': - resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} @@ -3690,6 +3747,10 @@ packages: acp-http-client@0.3.2: resolution: {integrity: sha512-btRUDXAA9BlcTQURsJogdWthoXsKOnMeFhtYlEYQxgt0vq7H6xMfMrewlIgFjRXgRTbru4Fre2T6wS/amTTyjQ==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + aggregate-error@5.0.0: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} @@ -4222,6 +4283,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@2.6.11: resolution: {integrity: sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw==} @@ -4355,6 +4420,10 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + date-fns-tz@1.3.8: resolution: {integrity: sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==} peerDependencies: @@ -4373,6 +4442,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -5001,6 +5073,10 @@ packages: resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -5014,6 +5090,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@3.0.1: resolution: {integrity: sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==} engines: {node: '>=12.20.0'} @@ -5123,6 +5207,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -5186,6 +5273,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -5656,6 +5752,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5795,6 +5894,9 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + pg-connection-string@2.12.0: resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} @@ -5802,6 +5904,11 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + pg-pool@3.13.0: resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: @@ -5817,6 +5924,15 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pg@8.20.0: resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} @@ -6021,6 +6137,10 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -6073,7 +6193,7 @@ packages: react-focus-lock@2.13.7: resolution: {integrity: sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==} peerDependencies: - '@types/react': ^19.1.12 + '@types/react': ^18.3.3 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -6142,7 +6262,7 @@ packages: resolution: {integrity: sha512-tsPZ77GR0pISGYmpCLHAbZTabKXZ7zBniKPVqVMMfnXFyo39zq5g/psIlD5vLTKkjQEhWOO8JhqcHnxkwNu6eA==} engines: {node: '>=8.5.0'} peerDependencies: - '@types/react': ^19.1.12 + '@types/react': ^18.3.3 react: ^16.8.0 peerDependenciesMeta: '@types/react': @@ -6172,10 +6292,6 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - react@19.1.1: - resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} - engines: {node: '>=0.10.0'} - react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -6367,6 +6483,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -6390,6 +6509,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -6630,6 +6753,9 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -6714,6 +6840,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6722,6 +6855,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -6981,7 +7122,7 @@ packages: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^19.1.12 + '@types/react': ^18.3.3 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -6991,7 +7132,7 @@ packages: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^19.1.12 + '@types/react': ^18.3.3 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -7203,12 +7344,33 @@ packages: vt-pbf@3.1.3: resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -7289,6 +7451,13 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -7367,14 +7536,18 @@ snapshots: dependencies: zod: 4.3.6 - '@agentclientprotocol/sdk@0.16.1(zod@4.3.6)': - dependencies: - zod: 4.3.6 - '@alloc/quick-lru@5.2.0': {} '@antfu/ni@0.23.2': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@asteasolutions/zod-to-openapi@8.4.3(zod@4.3.6)': dependencies: openapi3-ts: 4.5.0 @@ -7414,10 +7587,10 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.1.12))(@types/react@19.1.12)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': + '@astrojs/react@4.4.2(@types/node@25.5.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': dependencies: - '@types/react': 19.1.12 - '@types/react-dom': 19.2.3(@types/react@19.1.12) + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -8199,7 +8372,7 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)': + '@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -8210,39 +8383,39 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 optionalDependencies: - '@cloudflare/workers-types': 4.20260316.1 + '@cloudflare/workers-types': 4.20260313.1 - '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))': + '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) - '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': + '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 kysely: 0.28.11 - '@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))': + '@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -8352,7 +8525,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260312.1': optional: true - '@cloudflare/workers-types@4.20260316.1': {} + '@cloudflare/workers-types@4.20260313.1': {} '@computesdk/cmd@0.4.1': {} @@ -8369,6 +8542,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@date-io/core@2.17.0': {} '@date-io/date-fns@2.17.0(date-fns@2.30.0)': @@ -9417,11 +9610,11 @@ snapshots: prompts: 2.4.2 smol-toml: 1.6.0 - '@react-grab/mcp@0.1.27(@types/react@19.1.12)(react@19.2.4)': + '@react-grab/mcp@0.1.27(@types/react@18.3.27)(react@19.2.4)': dependencies: '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) fkill: 9.0.0 - react-grab: 0.1.27(@types/react@19.1.12)(react@19.2.4) + react-grab: 0.1.27(@types/react@18.3.27)(react@19.2.4) zod: 3.25.76 transitivePeerDependencies: - '@cfworker/json-schema' @@ -10034,11 +10227,11 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@19.1.1))(react@19.1.1)': + '@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.13.22 - react: 19.1.1 - react-dom: 19.2.4(react@19.1.1) + react: 18.3.1 + react-dom: 19.2.4(react@18.3.1) '@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -10149,22 +10342,31 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pg@8.16.0': + dependencies: + '@types/node': 24.10.9 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/pg@8.18.0': dependencies: '@types/node': 24.10.9 pg-protocol: 1.11.0 pg-types: 2.2.0 - '@types/react-dom@19.2.3(@types/react@19.1.12)': - dependencies: - '@types/react': 19.1.12 + '@types/prop-types@15.7.15': {} - '@types/react-reconciler@0.28.9(@types/react@19.1.12)': + '@types/react-dom@18.3.7(@types/react@18.3.27)': dependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.27 - '@types/react@19.1.12': + '@types/react-reconciler@0.28.9(@types/react@18.3.27)': dependencies: + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 csstype: 3.2.3 '@types/retry@0.12.2': {} @@ -10317,6 +10519,8 @@ snapshots: transitivePeerDependencies: - zod + agent-base@7.1.4: {} + aggregate-error@5.0.0: dependencies: clean-stack: 5.3.0 @@ -10521,7 +10725,7 @@ snapshots: baseline-browser-mapping@2.9.18: {} - baseui@16.1.1(@types/react@19.1.12)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)): + baseui@16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)): dependencies: '@date-io/date-fns': 2.17.0(date-fns@2.30.0) '@date-io/moment': 2.17.0(moment@2.30.1) @@ -10541,7 +10745,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) react-dropzone: 9.0.0(react@19.2.4) - react-focus-lock: 2.13.7(@types/react@19.1.12)(react@19.2.4) + react-focus-lock: 2.13.7(@types/react@18.3.27)(react@19.2.4) react-hook-form: 7.71.2(react@19.2.4) react-input-mask: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-is: 17.0.2 @@ -10549,7 +10753,7 @@ snapshots: react-movable: 3.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-multi-ref: 1.0.2 react-range: 1.10.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-uid: 2.3.0(@types/react@19.1.12)(react@19.2.4) + react-uid: 2.3.0(@types/react@18.3.27)(react@19.2.4) react-virtualized: 9.22.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-virtualized-auto-sizer: 1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-window: 1.8.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10563,15 +10767,15 @@ snapshots: dependencies: tweetnacl: 0.14.5 - better-auth@1.5.5(@cloudflare/workers-types@4.20260316.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): + better-auth@1.5.5(@cloudflare/workers-types@4.20260313.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)) - '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) - '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260316.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)) + '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) + '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -10584,12 +10788,12 @@ snapshots: zod: 4.3.6 optionalDependencies: drizzle-kit: 0.31.9 - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) pg: 8.20.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) solid-js: 1.9.11 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -10613,9 +10817,9 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - bippy@0.5.32(@types/react@19.1.12)(react@19.2.4): + bippy@0.5.32(@types/react@18.3.27)(react@19.2.4): dependencies: - '@types/react-reconciler': 0.28.9(@types/react@19.1.12) + '@types/react-reconciler': 0.28.9(@types/react@18.3.27) react: 19.2.4 transitivePeerDependencies: - '@types/react' @@ -10919,6 +11123,11 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@2.6.11: {} csstype@3.2.3: {} @@ -11075,6 +11284,11 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + date-fns-tz@1.3.8(date-fns@2.30.0): dependencies: date-fns: 2.30.0 @@ -11087,6 +11301,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -11193,9 +11409,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0): + drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0): optionalDependencies: - '@cloudflare/workers-types': 4.20260316.1 + '@cloudflare/workers-types': 4.20260313.1 '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.18.0 @@ -11223,7 +11439,7 @@ snapshots: glob: 11.1.0 openapi-fetch: 0.14.1 platform: 1.3.6 - tar: 7.5.7 + tar: 7.5.6 earcut@2.2.4: {} @@ -11827,6 +12043,10 @@ snapshots: hono@4.12.2: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -11841,6 +12061,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@3.0.1: {} human-signals@5.0.0: {} @@ -11920,6 +12154,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-stream@3.0.0: {} @@ -11966,6 +12202,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-schema-traverse@1.0.0: {} @@ -12590,6 +12853,8 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.23: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -12733,10 +12998,16 @@ snapshots: pg-cloudflare@1.3.0: optional: true + pg-connection-string@2.11.0: {} + pg-connection-string@2.12.0: {} pg-int8@1.0.1: {} + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + pg-pool@3.13.0(pg@8.20.0): dependencies: pg: 8.20.0 @@ -12753,6 +13024,16 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + pg@8.20.0: dependencies: pg-connection-string: 2.12.0 @@ -12968,6 +13249,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -13007,9 +13290,9 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-dom@19.2.4(react@19.1.1): + react-dom@19.2.4(react@18.3.1): dependencies: - react: 19.1.1 + react: 18.3.1 scheduler: 0.27.0 react-dom@19.2.4(react@19.2.4): @@ -13025,23 +13308,23 @@ snapshots: prop-types-extra: 1.1.1(react@19.2.4) react: 19.2.4 - react-focus-lock@2.13.7(@types/react@19.1.12)(react@19.2.4): + react-focus-lock@2.13.7(@types/react@18.3.27)(react@19.2.4): dependencies: '@babel/runtime': 7.28.6 focus-lock: 1.3.6 prop-types: 15.8.1 react: 19.2.4 react-clientside-effect: 1.2.8(react@19.2.4) - use-callback-ref: 1.3.3(@types/react@19.1.12)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.1.12)(react@19.2.4) + use-callback-ref: 1.3.3(@types/react@18.3.27)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@18.3.27)(react@19.2.4) optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.27 - react-grab@0.1.27(@types/react@19.1.12)(react@19.2.4): + react-grab@0.1.27(@types/react@18.3.27)(react@19.2.4): dependencies: '@medv/finder': 4.0.2 '@react-grab/cli': 0.1.27 - bippy: 0.5.32(@types/react@19.1.12)(react@19.2.4) + bippy: 0.5.32(@types/react@18.3.27)(react@19.2.4) solid-js: 1.9.11 optionalDependencies: react: 19.2.4 @@ -13095,12 +13378,12 @@ snapshots: react-refresh@0.18.0: {} - react-uid@2.3.0(@types/react@19.1.12)(react@19.2.4): + react-uid@2.3.0(@types/react@18.3.27)(react@19.2.4): dependencies: react: 19.2.4 tslib: 1.14.1 optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.27 react-virtualized-auto-sizer@1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -13129,8 +13412,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - react@19.1.1: {} - react@19.2.4: {} read-cache@1.0.0: @@ -13290,7 +13571,7 @@ snapshots: reusify@1.1.0: {} - rivetkit@2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0): + rivetkit@2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0): dependencies: '@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2) '@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6) @@ -13318,14 +13599,14 @@ snapshots: '@hono/node-server': 1.19.9(hono@4.12.2) '@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2) drizzle-kit: 0.31.9 - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) ws: 8.19.0 transitivePeerDependencies: - '@standard-schema/spec' - bufferutil - utf-8-validate - rivetkit@https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0): + rivetkit@https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0): dependencies: '@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2) '@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6) @@ -13356,7 +13637,7 @@ snapshots: '@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2) dockerode: 4.0.9 drizzle-kit: 0.31.9 - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0) ws: 8.19.0 transitivePeerDependencies: - '@standard-schema/spec' @@ -13408,6 +13689,8 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -13431,6 +13714,10 @@ snapshots: sax@1.4.4: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -13732,6 +14019,8 @@ snapshots: picocolors: 1.1.1 sax: 1.4.4 + symbol-tree@3.2.4: {} + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 @@ -13851,12 +14140,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -14068,20 +14371,20 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-callback-ref@1.3.3(@types/react@19.1.12)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@18.3.27)(react@19.2.4): dependencies: react: 19.2.4 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.27 - use-sidecar@1.1.3(@types/react@19.1.12)(react@19.2.4): + use-sidecar@1.1.3(@types/react@18.3.27)(react@19.2.4): dependencies: detect-node-es: 1.1.0 react: 19.2.4 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.12 + '@types/react': 18.3.27 use-sync-external-store@1.6.0(react@19.2.4): dependencies: @@ -14256,7 +14559,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -14284,6 +14587,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.7 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -14298,7 +14602,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -14326,6 +14630,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.9 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -14340,7 +14645,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -14368,6 +14673,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.5.0 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -14392,12 +14698,29 @@ snapshots: '@mapbox/vector-tile': 1.3.1 pbf: 3.3.0 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + warning@4.0.3: dependencies: loose-envify: 1.4.0 web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which-pm-runs@1.1.0: {} which@2.0.2: @@ -14421,7 +14744,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260312.1 '@cloudflare/workerd-windows-64': 1.20260312.1 - wrangler@4.73.0(@cloudflare/workers-types@4.20260316.1): + wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260312.1) @@ -14432,7 +14755,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260312.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260316.1 + '@cloudflare/workers-types': 4.20260313.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -14470,6 +14793,10 @@ snapshots: dependencies: os-paths: 4.4.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} xxhash-wasm@1.1.0: {} diff --git a/research/acp/friction.md b/research/acp/friction.md index 983b966..e5273b8 100644 --- a/research/acp/friction.md +++ b/research/acp/friction.md @@ -277,3 +277,13 @@ Update this file continuously during the migration. - Owner: Unassigned. - Status: resolved - Links: `sdks/acp-http-client/src/index.ts`, `sdks/acp-http-client/tests/smoke.test.ts`, `sdks/typescript/tests/integration.test.ts` + +- Date: 2026-03-07 +- Area: Desktop host/runtime API boundary +- Issue: Desktop automation needed screenshot/input/file-transfer-like host capabilities, but routing it through ACP would have mixed agent protocol semantics with host-owned runtime control and binary payloads. +- Impact: A desktop feature built as ACP methods would blur the division between agent/session behavior and Sandbox Agent host/runtime APIs, and would complicate binary screenshot transport. +- Proposed direction: Ship desktop as first-party HTTP endpoints under `/v1/desktop/*`, keep health/install/remediation in the server runtime, and expose the feature through the SDK and inspector without ACP extension methods. +- Decision: Accepted and implemented for phase one. +- Owner: Unassigned. +- Status: resolved +- Links: `server/packages/sandbox-agent/src/router.rs`, `server/packages/sandbox-agent/src/desktop_runtime.rs`, `sdks/typescript/src/client.ts`, `frontend/packages/inspector/src/components/debug/DesktopTab.tsx` diff --git a/sdks/react/src/DesktopViewer.tsx b/sdks/react/src/DesktopViewer.tsx new file mode 100644 index 0000000..f1ce711 --- /dev/null +++ b/sdks/react/src/DesktopViewer.tsx @@ -0,0 +1,257 @@ +"use client"; + +import type { CSSProperties, MouseEvent, WheelEvent } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { DesktopMouseButton, DesktopStreamErrorStatus, DesktopStreamReadyStatus, SandboxAgent } from "sandbox-agent"; + +type ConnectionState = "connecting" | "ready" | "closed" | "error"; + +export type DesktopViewerClient = Pick; + +export interface DesktopViewerProps { + client: DesktopViewerClient; + className?: string; + style?: CSSProperties; + imageStyle?: CSSProperties; + height?: number | string; + onConnect?: (status: DesktopStreamReadyStatus) => void; + onDisconnect?: () => void; + onError?: (error: DesktopStreamErrorStatus | Error) => void; +} + +const shellStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + overflow: "hidden", + border: "1px solid rgba(15, 23, 42, 0.14)", + borderRadius: 14, + background: "linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(226, 232, 240, 0.92) 100%)", + boxShadow: "0 20px 40px rgba(15, 23, 42, 0.08)", +}; + +const statusBarStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + padding: "10px 14px", + borderBottom: "1px solid rgba(15, 23, 42, 0.08)", + background: "rgba(255, 255, 255, 0.78)", + color: "#0f172a", + fontSize: 12, + lineHeight: 1.4, +}; + +const viewportStyle: CSSProperties = { + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + background: "radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 45%), linear-gradient(180deg, #0f172a 0%, #111827 100%)", +}; + +const imageBaseStyle: CSSProperties = { + display: "block", + width: "100%", + height: "100%", + objectFit: "contain", + userSelect: "none", +}; + +const hintStyle: CSSProperties = { + opacity: 0.66, +}; + +const getStatusColor = (state: ConnectionState): string => { + switch (state) { + case "ready": + return "#15803d"; + case "error": + return "#b91c1c"; + case "closed": + return "#b45309"; + default: + return "#475569"; + } +}; + +export const DesktopViewer = ({ client, className, style, imageStyle, height = 480, onConnect, onDisconnect, onError }: DesktopViewerProps) => { + const wrapperRef = useRef(null); + const sessionRef = useRef | null>(null); + const [connectionState, setConnectionState] = useState("connecting"); + const [statusMessage, setStatusMessage] = useState("Starting desktop stream..."); + const [frameUrl, setFrameUrl] = useState(null); + const [resolution, setResolution] = useState<{ width: number; height: number } | null>(null); + + useEffect(() => { + let cancelled = false; + let lastObjectUrl: string | null = null; + let session: ReturnType | null = null; + + setConnectionState("connecting"); + setStatusMessage("Starting desktop stream..."); + setResolution(null); + + const connect = async () => { + try { + await client.startDesktopStream(); + if (cancelled) { + return; + } + + session = client.connectDesktopStream(); + sessionRef.current = session; + session.onReady((status) => { + if (cancelled) { + return; + } + setConnectionState("ready"); + setStatusMessage("Desktop stream connected."); + setResolution({ width: status.width, height: status.height }); + onConnect?.(status); + }); + session.onFrame((frame) => { + if (cancelled) { + return; + } + const nextUrl = URL.createObjectURL(new Blob([frame.slice().buffer], { type: "image/jpeg" })); + setFrameUrl((current) => { + if (current) { + URL.revokeObjectURL(current); + } + return nextUrl; + }); + if (lastObjectUrl) { + URL.revokeObjectURL(lastObjectUrl); + } + lastObjectUrl = nextUrl; + }); + session.onError((error) => { + if (cancelled) { + return; + } + setConnectionState("error"); + setStatusMessage(error instanceof Error ? error.message : error.message); + onError?.(error); + }); + session.onClose(() => { + if (cancelled) { + return; + } + setConnectionState((current) => (current === "error" ? current : "closed")); + setStatusMessage((current) => (current === "Desktop stream connected." ? "Desktop stream disconnected." : current)); + onDisconnect?.(); + }); + } catch (error) { + if (cancelled) { + return; + } + const nextError = error instanceof Error ? error : new Error("Failed to initialize desktop stream."); + setConnectionState("error"); + setStatusMessage(nextError.message); + onError?.(nextError); + } + }; + + void connect(); + + return () => { + cancelled = true; + session?.close(); + sessionRef.current = null; + void client.stopDesktopStream().catch(() => undefined); + setFrameUrl((current) => { + if (current) { + URL.revokeObjectURL(current); + } + return null; + }); + if (lastObjectUrl) { + URL.revokeObjectURL(lastObjectUrl); + } + }; + }, [client, onConnect, onDisconnect, onError]); + + const scalePoint = (clientX: number, clientY: number) => { + const wrapper = wrapperRef.current; + if (!wrapper || !resolution) { + return null; + } + const rect = wrapper.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return null; + } + const x = Math.max(0, Math.min(resolution.width, ((clientX - rect.left) / rect.width) * resolution.width)); + const y = Math.max(0, Math.min(resolution.height, ((clientY - rect.top) / rect.height) * resolution.height)); + return { + x: Math.round(x), + y: Math.round(y), + }; + }; + + const buttonFromMouseEvent = (event: MouseEvent): DesktopMouseButton => { + switch (event.button) { + case 1: + return "middle"; + case 2: + return "right"; + default: + return "left"; + } + }; + + const withSession = (callback: (session: NonNullable>) => void) => { + const session = sessionRef.current; + if (session) { + callback(session); + } + }; + + return ( +
+
+ {statusMessage} + {resolution ? `${resolution.width}×${resolution.height}` : "Awaiting frames"} +
+
{ + const point = scalePoint(event.clientX, event.clientY); + if (!point) { + return; + } + withSession((session) => session.moveMouse(point.x, point.y)); + }} + onMouseDown={(event) => { + event.preventDefault(); + const point = scalePoint(event.clientX, event.clientY); + withSession((session) => session.mouseDown(buttonFromMouseEvent(event), point?.x, point?.y)); + }} + onMouseUp={(event) => { + const point = scalePoint(event.clientX, event.clientY); + withSession((session) => session.mouseUp(buttonFromMouseEvent(event), point?.x, point?.y)); + }} + onWheel={(event: WheelEvent) => { + event.preventDefault(); + const point = scalePoint(event.clientX, event.clientY); + if (!point) { + return; + } + withSession((session) => session.scroll(point.x, point.y, Math.round(event.deltaX), Math.round(event.deltaY))); + }} + onKeyDown={(event) => { + withSession((session) => session.keyDown(event.key)); + }} + onKeyUp={(event) => { + withSession((session) => session.keyUp(event.key)); + }} + > + {frameUrl ? Desktop stream : null} +
+
+ ); +}; diff --git a/sdks/react/src/index.ts b/sdks/react/src/index.ts index 55d4a91..1d8d1e1 100644 --- a/sdks/react/src/index.ts +++ b/sdks/react/src/index.ts @@ -1,6 +1,7 @@ export { AgentConversation } from "./AgentConversation.tsx"; export { AgentTranscript } from "./AgentTranscript.tsx"; export { ChatComposer } from "./ChatComposer.tsx"; +export { DesktopViewer } from "./DesktopViewer.tsx"; export { ProcessTerminal } from "./ProcessTerminal.tsx"; export { useTranscriptVirtualizer } from "./useTranscriptVirtualizer.ts"; @@ -23,6 +24,11 @@ export type { ChatComposerProps, } from "./ChatComposer.tsx"; +export type { + DesktopViewerClient, + DesktopViewerProps, +} from "./DesktopViewer.tsx"; + export type { ProcessTerminalClient, ProcessTerminalProps, diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 10200bc..94a3375 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -23,12 +23,35 @@ import { type SetSessionModeRequest, } from "acp-http-client"; import type { SandboxProvider } from "./providers/types.ts"; +import { DesktopStreamSession, type DesktopStreamConnectOptions } from "./desktop-stream.ts"; import { type AcpServerListResponse, type AgentInfo, type AgentInstallRequest, type AgentInstallResponse, type AgentListResponse, + type DesktopActionResponse, + type DesktopDisplayInfoResponse, + type DesktopKeyboardDownRequest, + type DesktopKeyboardPressRequest, + type DesktopKeyboardTypeRequest, + type DesktopMouseClickRequest, + type DesktopMouseDownRequest, + type DesktopMouseDragRequest, + type DesktopMouseMoveRequest, + type DesktopMousePositionResponse, + type DesktopMouseScrollRequest, + type DesktopMouseUpRequest, + type DesktopKeyboardUpRequest, + type DesktopRecordingInfo, + type DesktopRecordingListResponse, + type DesktopRecordingStartRequest, + type DesktopRegionScreenshotQuery, + type DesktopScreenshotQuery, + type DesktopStartRequest, + type DesktopStatusResponse, + type DesktopStreamStatusResponse, + type DesktopWindowListResponse, type FsActionResponse, type FsDeleteQuery, type FsEntriesQuery, @@ -53,7 +76,9 @@ import { type ProcessInfo, type ProcessInputRequest, type ProcessInputResponse, + type ProcessListQuery, type ProcessListResponse, + type ProcessOwner, type ProcessLogEntry, type ProcessLogsQuery, type ProcessLogsResponse, @@ -201,6 +226,7 @@ export interface ProcessTerminalConnectOptions extends ProcessTerminalWebSocketU } export type ProcessTerminalSessionOptions = ProcessTerminalConnectOptions; +export type DesktopStreamSessionOptions = DesktopStreamConnectOptions; export class SandboxAgentError extends Error { readonly status: number; @@ -1533,6 +1559,148 @@ export class SandboxAgent { return this.requestHealth(); } + async startDesktop(request: DesktopStartRequest = {}): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/start`, { + body: request, + }); + } + + async stopDesktop(): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/stop`); + } + + async getDesktopStatus(): Promise { + return this.requestJson("GET", `${API_PREFIX}/desktop/status`); + } + + async getDesktopDisplayInfo(): Promise { + return this.requestJson("GET", `${API_PREFIX}/desktop/display/info`); + } + + async takeDesktopScreenshot(query: DesktopScreenshotQuery = {}): Promise { + const response = await this.requestRaw("GET", `${API_PREFIX}/desktop/screenshot`, { + query, + accept: "image/*", + }); + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + + async takeDesktopRegionScreenshot(query: DesktopRegionScreenshotQuery): Promise { + const response = await this.requestRaw("GET", `${API_PREFIX}/desktop/screenshot/region`, { + query, + accept: "image/*", + }); + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + + async getDesktopMousePosition(): Promise { + return this.requestJson("GET", `${API_PREFIX}/desktop/mouse/position`); + } + + async moveDesktopMouse(request: DesktopMouseMoveRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/mouse/move`, { + body: request, + }); + } + + async clickDesktop(request: DesktopMouseClickRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/mouse/click`, { + body: request, + }); + } + + async mouseDownDesktop(request: DesktopMouseDownRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/mouse/down`, { + body: request, + }); + } + + async mouseUpDesktop(request: DesktopMouseUpRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/mouse/up`, { + body: request, + }); + } + + async dragDesktopMouse(request: DesktopMouseDragRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/mouse/drag`, { + body: request, + }); + } + + async scrollDesktop(request: DesktopMouseScrollRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/mouse/scroll`, { + body: request, + }); + } + + async typeDesktopText(request: DesktopKeyboardTypeRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/keyboard/type`, { + body: request, + }); + } + + async pressDesktopKey(request: DesktopKeyboardPressRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/keyboard/press`, { + body: request, + }); + } + + async keyDownDesktop(request: DesktopKeyboardDownRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/keyboard/down`, { + body: request, + }); + } + + async keyUpDesktop(request: DesktopKeyboardUpRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/keyboard/up`, { + body: request, + }); + } + + async listDesktopWindows(): Promise { + return this.requestJson("GET", `${API_PREFIX}/desktop/windows`); + } + + async startDesktopRecording(request: DesktopRecordingStartRequest = {}): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/recording/start`, { + body: request, + }); + } + + async stopDesktopRecording(): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/recording/stop`); + } + + async listDesktopRecordings(): Promise { + return this.requestJson("GET", `${API_PREFIX}/desktop/recordings`); + } + + async getDesktopRecording(id: string): Promise { + return this.requestJson("GET", `${API_PREFIX}/desktop/recordings/${encodeURIComponent(id)}`); + } + + async downloadDesktopRecording(id: string): Promise { + const response = await this.requestRaw("GET", `${API_PREFIX}/desktop/recordings/${encodeURIComponent(id)}/download`, { + accept: "video/mp4", + }); + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + + async deleteDesktopRecording(id: string): Promise { + await this.requestRaw("DELETE", `${API_PREFIX}/desktop/recordings/${encodeURIComponent(id)}`); + } + + async startDesktopStream(): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/stream/start`); + } + + async stopDesktopStream(): Promise { + return this.requestJson("POST", `${API_PREFIX}/desktop/stream/stop`); + } + async listAgents(options?: AgentQueryOptions): Promise { return this.requestJson("GET", `${API_PREFIX}/agents`, { query: toAgentQuery(options), @@ -1665,8 +1833,10 @@ export class SandboxAgent { }); } - async listProcesses(): Promise { - return this.requestJson("GET", `${API_PREFIX}/processes`); + async listProcesses(query?: ProcessListQuery): Promise { + return this.requestJson("GET", `${API_PREFIX}/processes`, { + query, + }); } async getProcess(id: string): Promise { @@ -1754,6 +1924,32 @@ export class SandboxAgent { return new ProcessTerminalSession(this.connectProcessTerminalWebSocket(id, options)); } + buildDesktopStreamWebSocketUrl(options: ProcessTerminalWebSocketUrlOptions = {}): string { + return toWebSocketUrl( + this.buildUrl(`${API_PREFIX}/desktop/stream/ws`, { + access_token: options.accessToken ?? this.token, + }), + ); + } + + connectDesktopStreamWebSocket(options: DesktopStreamConnectOptions = {}): WebSocket { + const WebSocketCtor = options.WebSocket ?? globalThis.WebSocket; + if (!WebSocketCtor) { + throw new Error("WebSocket API is not available; provide a WebSocket implementation."); + } + + return new WebSocketCtor( + this.buildDesktopStreamWebSocketUrl({ + accessToken: options.accessToken, + }), + options.protocols, + ); + } + + connectDesktopStream(options: DesktopStreamSessionOptions = {}): DesktopStreamSession { + return new DesktopStreamSession(this.connectDesktopStreamWebSocket(options)); + } + private async getLiveConnection(agent: string): Promise { await this.awaitHealthy(); diff --git a/sdks/typescript/src/desktop-stream.ts b/sdks/typescript/src/desktop-stream.ts new file mode 100644 index 0000000..1bbf76f --- /dev/null +++ b/sdks/typescript/src/desktop-stream.ts @@ -0,0 +1,236 @@ +import type { DesktopMouseButton } from "./types.ts"; + +const WS_READY_STATE_CONNECTING = 0; +const WS_READY_STATE_OPEN = 1; +const WS_READY_STATE_CLOSED = 3; + +export interface DesktopStreamReadyStatus { + type: "ready"; + width: number; + height: number; +} + +export interface DesktopStreamErrorStatus { + type: "error"; + message: string; +} + +export type DesktopStreamStatusMessage = DesktopStreamReadyStatus | DesktopStreamErrorStatus; + +export interface DesktopStreamConnectOptions { + accessToken?: string; + WebSocket?: typeof WebSocket; + protocols?: string | string[]; +} + +type DesktopStreamClientFrame = + | { + type: "moveMouse"; + x: number; + y: number; + } + | { + type: "mouseDown" | "mouseUp"; + x?: number; + y?: number; + button?: DesktopMouseButton; + } + | { + type: "scroll"; + x: number; + y: number; + deltaX?: number; + deltaY?: number; + } + | { + type: "keyDown" | "keyUp"; + key: string; + } + | { + type: "close"; + }; + +export class DesktopStreamSession { + readonly socket: WebSocket; + readonly closed: Promise; + + private readonly readyListeners = new Set<(status: DesktopStreamReadyStatus) => void>(); + private readonly frameListeners = new Set<(frame: Uint8Array) => void>(); + private readonly errorListeners = new Set<(error: DesktopStreamErrorStatus | Error) => void>(); + private readonly closeListeners = new Set<() => void>(); + + private closeSignalSent = false; + private closedResolve!: () => void; + + constructor(socket: WebSocket) { + this.socket = socket; + this.socket.binaryType = "arraybuffer"; + this.closed = new Promise((resolve) => { + this.closedResolve = resolve; + }); + + this.socket.addEventListener("message", (event) => { + void this.handleMessage(event.data); + }); + this.socket.addEventListener("error", () => { + this.emitError(new Error("Desktop stream websocket connection failed.")); + }); + this.socket.addEventListener("close", () => { + this.closedResolve(); + for (const listener of this.closeListeners) { + listener(); + } + }); + } + + onReady(listener: (status: DesktopStreamReadyStatus) => void): () => void { + this.readyListeners.add(listener); + return () => { + this.readyListeners.delete(listener); + }; + } + + onFrame(listener: (frame: Uint8Array) => void): () => void { + this.frameListeners.add(listener); + return () => { + this.frameListeners.delete(listener); + }; + } + + onError(listener: (error: DesktopStreamErrorStatus | Error) => void): () => void { + this.errorListeners.add(listener); + return () => { + this.errorListeners.delete(listener); + }; + } + + onClose(listener: () => void): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + moveMouse(x: number, y: number): void { + this.sendFrame({ type: "moveMouse", x, y }); + } + + mouseDown(button?: DesktopMouseButton, x?: number, y?: number): void { + this.sendFrame({ type: "mouseDown", button, x, y }); + } + + mouseUp(button?: DesktopMouseButton, x?: number, y?: number): void { + this.sendFrame({ type: "mouseUp", button, x, y }); + } + + scroll(x: number, y: number, deltaX?: number, deltaY?: number): void { + this.sendFrame({ type: "scroll", x, y, deltaX, deltaY }); + } + + keyDown(key: string): void { + this.sendFrame({ type: "keyDown", key }); + } + + keyUp(key: string): void { + this.sendFrame({ type: "keyUp", key }); + } + + close(): void { + if (this.socket.readyState === WS_READY_STATE_CONNECTING) { + this.socket.addEventListener( + "open", + () => { + this.close(); + }, + { once: true }, + ); + return; + } + + if (this.socket.readyState === WS_READY_STATE_OPEN) { + if (!this.closeSignalSent) { + this.closeSignalSent = true; + this.sendFrame({ type: "close" }); + } + this.socket.close(); + return; + } + + if (this.socket.readyState !== WS_READY_STATE_CLOSED) { + this.socket.close(); + } + } + + private async handleMessage(data: unknown): Promise { + try { + if (typeof data === "string") { + const frame = parseStatusFrame(data); + if (!frame) { + this.emitError(new Error("Received invalid desktop stream control frame.")); + return; + } + + if (frame.type === "ready") { + for (const listener of this.readyListeners) { + listener(frame); + } + return; + } + + this.emitError(frame); + return; + } + + const bytes = await decodeBinaryFrame(data); + for (const listener of this.frameListeners) { + listener(bytes); + } + } catch (error) { + this.emitError(error instanceof Error ? error : new Error(String(error))); + } + } + + private sendFrame(frame: DesktopStreamClientFrame): void { + if (this.socket.readyState !== WS_READY_STATE_OPEN) { + return; + } + this.socket.send(JSON.stringify(frame)); + } + + private emitError(error: DesktopStreamErrorStatus | Error): void { + for (const listener of this.errorListeners) { + listener(error); + } + } +} + +function parseStatusFrame(payload: string): DesktopStreamStatusMessage | null { + const value = JSON.parse(payload) as Record; + if (value.type === "ready" && typeof value.width === "number" && typeof value.height === "number") { + return { + type: "ready", + width: value.width, + height: value.height, + }; + } + if (value.type === "error" && typeof value.message === "string") { + return { + type: "error", + message: value.message, + }; + } + return null; +} + +async function decodeBinaryFrame(data: unknown): Promise { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof Blob !== "undefined" && data instanceof Blob) { + return new Uint8Array(await data.arrayBuffer()); + } + throw new Error("Unsupported desktop stream binary frame type."); +} diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 18374fb..195c481 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -3,7 +3,6 @@ * Do not make direct changes to the file. */ - export interface paths { "/v1/acp": { get: operations["get_v1_acp_servers"]; @@ -32,6 +31,213 @@ export interface paths { put: operations["put_v1_config_skills"]; delete: operations["delete_v1_config_skills"]; }; + "/v1/desktop/display/info": { + /** + * Get desktop display information. + * @description Performs a health-gated display query against the managed desktop and + * returns the current display identifier and resolution. + */ + get: operations["get_v1_desktop_display_info"]; + }; + "/v1/desktop/keyboard/down": { + /** + * Press and hold a desktop keyboard key. + * @description Performs a health-gated `xdotool keydown` operation against the managed + * desktop. + */ + post: operations["post_v1_desktop_keyboard_down"]; + }; + "/v1/desktop/keyboard/press": { + /** + * Press a desktop keyboard shortcut. + * @description Performs a health-gated `xdotool key` operation against the managed + * desktop. + */ + post: operations["post_v1_desktop_keyboard_press"]; + }; + "/v1/desktop/keyboard/type": { + /** + * Type desktop keyboard text. + * @description Performs a health-gated `xdotool type` operation against the managed + * desktop. + */ + post: operations["post_v1_desktop_keyboard_type"]; + }; + "/v1/desktop/keyboard/up": { + /** + * Release a desktop keyboard key. + * @description Performs a health-gated `xdotool keyup` operation against the managed + * desktop. + */ + post: operations["post_v1_desktop_keyboard_up"]; + }; + "/v1/desktop/mouse/click": { + /** + * Click on the desktop. + * @description Performs a health-gated pointer move and click against the managed desktop + * and returns the resulting mouse position. + */ + post: operations["post_v1_desktop_mouse_click"]; + }; + "/v1/desktop/mouse/down": { + /** + * Press and hold a desktop mouse button. + * @description Performs a health-gated optional pointer move followed by `xdotool mousedown` + * and returns the resulting mouse position. + */ + post: operations["post_v1_desktop_mouse_down"]; + }; + "/v1/desktop/mouse/drag": { + /** + * Drag the desktop mouse. + * @description Performs a health-gated drag gesture against the managed desktop and + * returns the resulting mouse position. + */ + post: operations["post_v1_desktop_mouse_drag"]; + }; + "/v1/desktop/mouse/move": { + /** + * Move the desktop mouse. + * @description Performs a health-gated absolute pointer move on the managed desktop and + * returns the resulting mouse position. + */ + post: operations["post_v1_desktop_mouse_move"]; + }; + "/v1/desktop/mouse/position": { + /** + * Get the current desktop mouse position. + * @description Performs a health-gated mouse position query against the managed desktop. + */ + get: operations["get_v1_desktop_mouse_position"]; + }; + "/v1/desktop/mouse/scroll": { + /** + * Scroll the desktop mouse wheel. + * @description Performs a health-gated scroll gesture at the requested coordinates and + * returns the resulting mouse position. + */ + post: operations["post_v1_desktop_mouse_scroll"]; + }; + "/v1/desktop/mouse/up": { + /** + * Release a desktop mouse button. + * @description Performs a health-gated optional pointer move followed by `xdotool mouseup` + * and returns the resulting mouse position. + */ + post: operations["post_v1_desktop_mouse_up"]; + }; + "/v1/desktop/recording/start": { + /** + * Start desktop recording. + * @description Starts an ffmpeg x11grab recording against the managed desktop and returns + * the created recording metadata. + */ + post: operations["post_v1_desktop_recording_start"]; + }; + "/v1/desktop/recording/stop": { + /** + * Stop desktop recording. + * @description Stops the active desktop recording and returns the finalized recording + * metadata. + */ + post: operations["post_v1_desktop_recording_stop"]; + }; + "/v1/desktop/recordings": { + /** + * List desktop recordings. + * @description Returns the current desktop recording catalog. + */ + get: operations["get_v1_desktop_recordings"]; + }; + "/v1/desktop/recordings/{id}": { + /** + * Get desktop recording metadata. + * @description Returns metadata for a single desktop recording. + */ + get: operations["get_v1_desktop_recording"]; + /** + * Delete a desktop recording. + * @description Removes a completed desktop recording and its file from disk. + */ + delete: operations["delete_v1_desktop_recording"]; + }; + "/v1/desktop/recordings/{id}/download": { + /** + * Download a desktop recording. + * @description Serves the recorded MP4 bytes for a completed desktop recording. + */ + get: operations["get_v1_desktop_recording_download"]; + }; + "/v1/desktop/screenshot": { + /** + * Capture a full desktop screenshot. + * @description Performs a health-gated full-frame screenshot of the managed desktop and + * returns the requested image bytes. + */ + get: operations["get_v1_desktop_screenshot"]; + }; + "/v1/desktop/screenshot/region": { + /** + * Capture a desktop screenshot region. + * @description Performs a health-gated screenshot crop against the managed desktop and + * returns the requested region image bytes. + */ + get: operations["get_v1_desktop_screenshot_region"]; + }; + "/v1/desktop/start": { + /** + * Start the private desktop runtime. + * @description Lazily launches the managed Xvfb/openbox stack, validates display health, + * and returns the resulting desktop status snapshot. + */ + post: operations["post_v1_desktop_start"]; + }; + "/v1/desktop/status": { + /** + * Get desktop runtime status. + * @description Returns the current desktop runtime state, dependency status, active + * display metadata, and supervised process information. + */ + get: operations["get_v1_desktop_status"]; + }; + "/v1/desktop/stop": { + /** + * Stop the private desktop runtime. + * @description Terminates the managed openbox/Xvfb/dbus processes owned by the desktop + * runtime and returns the resulting status snapshot. + */ + post: operations["post_v1_desktop_stop"]; + }; + "/v1/desktop/stream/start": { + /** + * Start desktop streaming. + * @description Enables desktop websocket streaming for the managed desktop. + */ + post: operations["post_v1_desktop_stream_start"]; + }; + "/v1/desktop/stream/stop": { + /** + * Stop desktop streaming. + * @description Disables desktop websocket streaming for the managed desktop. + */ + post: operations["post_v1_desktop_stream_stop"]; + }; + "/v1/desktop/stream/ws": { + /** + * Open a desktop websocket streaming session. + * @description Upgrades the connection to a websocket that streams JPEG desktop frames and + * accepts mouse and keyboard control frames. + */ + get: operations["get_v1_desktop_stream_ws"]; + }; + "/v1/desktop/windows": { + /** + * List visible desktop windows. + * @description Performs a health-gated visible-window enumeration against the managed + * desktop and returns the current window metadata. + */ + get: operations["get_v1_desktop_windows"]; + }; "/v1/fs/entries": { get: operations["get_v1_fs_entries"]; }; @@ -234,8 +440,215 @@ export interface components { AgentListResponse: { agents: components["schemas"]["AgentInfo"][]; }; + DesktopActionResponse: { + ok: boolean; + }; + DesktopDisplayInfoResponse: { + display: string; + resolution: components["schemas"]["DesktopResolution"]; + }; + DesktopErrorInfo: { + code: string; + message: string; + }; + DesktopKeyModifiers: { + alt?: boolean | null; + cmd?: boolean | null; + ctrl?: boolean | null; + shift?: boolean | null; + }; + DesktopKeyboardDownRequest: { + key: string; + }; + DesktopKeyboardPressRequest: { + key: string; + modifiers?: components["schemas"]["DesktopKeyModifiers"] | null; + }; + DesktopKeyboardTypeRequest: { + /** Format: int32 */ + delayMs?: number | null; + text: string; + }; + DesktopKeyboardUpRequest: { + key: string; + }; /** @enum {string} */ - ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "not_found" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout"; + DesktopMouseButton: "left" | "middle" | "right"; + DesktopMouseClickRequest: { + button?: components["schemas"]["DesktopMouseButton"] | null; + /** Format: int32 */ + clickCount?: number | null; + /** Format: int32 */ + x: number; + /** Format: int32 */ + y: number; + }; + DesktopMouseDownRequest: { + button?: components["schemas"]["DesktopMouseButton"] | null; + /** Format: int32 */ + x?: number | null; + /** Format: int32 */ + y?: number | null; + }; + DesktopMouseDragRequest: { + button?: components["schemas"]["DesktopMouseButton"] | null; + /** Format: int32 */ + endX: number; + /** Format: int32 */ + endY: number; + /** Format: int32 */ + startX: number; + /** Format: int32 */ + startY: number; + }; + DesktopMouseMoveRequest: { + /** Format: int32 */ + x: number; + /** Format: int32 */ + y: number; + }; + DesktopMousePositionResponse: { + /** Format: int32 */ + screen?: number | null; + window?: string | null; + /** Format: int32 */ + x: number; + /** Format: int32 */ + y: number; + }; + DesktopMouseScrollRequest: { + /** Format: int32 */ + deltaX?: number | null; + /** Format: int32 */ + deltaY?: number | null; + /** Format: int32 */ + x: number; + /** Format: int32 */ + y: number; + }; + DesktopMouseUpRequest: { + button?: components["schemas"]["DesktopMouseButton"] | null; + /** Format: int32 */ + x?: number | null; + /** Format: int32 */ + y?: number | null; + }; + DesktopProcessInfo: { + logPath?: string | null; + name: string; + /** Format: int32 */ + pid?: number | null; + running: boolean; + }; + DesktopRecordingInfo: { + /** Format: int64 */ + bytes: number; + endedAt?: string | null; + fileName: string; + id: string; + processId?: string | null; + startedAt: string; + status: components["schemas"]["DesktopRecordingStatus"]; + }; + DesktopRecordingListResponse: { + recordings: components["schemas"]["DesktopRecordingInfo"][]; + }; + DesktopRecordingStartRequest: { + /** Format: int32 */ + fps?: number | null; + }; + /** @enum {string} */ + DesktopRecordingStatus: "recording" | "completed" | "failed"; + DesktopRegionScreenshotQuery: { + format?: components["schemas"]["DesktopScreenshotFormat"] | null; + /** Format: int32 */ + height: number; + /** Format: int32 */ + quality?: number | null; + /** Format: float */ + scale?: number | null; + /** Format: int32 */ + width: number; + /** Format: int32 */ + x: number; + /** Format: int32 */ + y: number; + }; + DesktopResolution: { + /** Format: int32 */ + dpi?: number | null; + /** Format: int32 */ + height: number; + /** Format: int32 */ + width: number; + }; + /** @enum {string} */ + DesktopScreenshotFormat: "png" | "jpeg" | "webp"; + DesktopScreenshotQuery: { + format?: components["schemas"]["DesktopScreenshotFormat"] | null; + /** Format: int32 */ + quality?: number | null; + /** Format: float */ + scale?: number | null; + }; + DesktopStartRequest: { + /** Format: int32 */ + dpi?: number | null; + /** Format: int32 */ + height?: number | null; + /** Format: int32 */ + width?: number | null; + }; + /** @enum {string} */ + DesktopState: "inactive" | "install_required" | "starting" | "active" | "stopping" | "failed"; + DesktopStatusResponse: { + display?: string | null; + installCommand?: string | null; + lastError?: components["schemas"]["DesktopErrorInfo"] | null; + missingDependencies?: string[]; + processes?: components["schemas"]["DesktopProcessInfo"][]; + resolution?: components["schemas"]["DesktopResolution"] | null; + runtimeLogPath?: string | null; + startedAt?: string | null; + state: components["schemas"]["DesktopState"]; + }; + DesktopStreamStatusResponse: { + active: boolean; + }; + DesktopWindowInfo: { + /** Format: int32 */ + height: number; + id: string; + isActive: boolean; + title: string; + /** Format: int32 */ + width: number; + /** Format: int32 */ + x: number; + /** Format: int32 */ + y: number; + }; + DesktopWindowListResponse: { + windows: components["schemas"]["DesktopWindowInfo"][]; + }; + /** @enum {string} */ + ErrorType: + | "invalid_request" + | "conflict" + | "unsupported_agent" + | "agent_not_installed" + | "install_failed" + | "agent_process_exited" + | "token_invalid" + | "permission_denied" + | "not_acceptable" + | "unsupported_media_type" + | "not_found" + | "session_not_found" + | "session_already_exists" + | "mode_not_supported" + | "stream_error" + | "timeout"; FsActionResponse: { path: string; }; @@ -294,35 +707,37 @@ export interface components { directory: string; mcpName: string; }; - McpServerConfig: ({ - args?: string[]; - command: string; - cwd?: string | null; - enabled?: boolean | null; - env?: { - [key: string]: string; - } | null; - /** Format: int64 */ - timeoutMs?: number | null; - /** @enum {string} */ - type: "local"; - }) | ({ - bearerTokenEnvVar?: string | null; - enabled?: boolean | null; - envHeaders?: { - [key: string]: string; - } | null; - headers?: { - [key: string]: string; - } | null; - oauth?: Record | null | null; - /** Format: int64 */ - timeoutMs?: number | null; - transport?: string | null; - /** @enum {string} */ - type: "remote"; - url: string; - }); + McpServerConfig: + | { + args?: string[]; + command: string; + cwd?: string | null; + enabled?: boolean | null; + env?: { + [key: string]: string; + } | null; + /** Format: int64 */ + timeoutMs?: number | null; + /** @enum {string} */ + type: "local"; + } + | { + bearerTokenEnvVar?: string | null; + enabled?: boolean | null; + envHeaders?: { + [key: string]: string; + } | null; + headers?: { + [key: string]: string; + } | null; + oauth?: Record | null | null; + /** Format: int64 */ + timeoutMs?: number | null; + transport?: string | null; + /** @enum {string} */ + type: "remote"; + url: string; + }; ProblemDetails: { detail?: string | null; instance?: string | null; @@ -364,6 +779,7 @@ export interface components { exitedAtMs?: number | null; id: string; interactive: boolean; + owner: components["schemas"]["ProcessOwner"]; /** Format: int32 */ pid?: number | null; status: components["schemas"]["ProcessState"]; @@ -376,6 +792,9 @@ export interface components { ProcessInputResponse: { bytesWritten: number; }; + ProcessListQuery: { + owner?: components["schemas"]["ProcessOwner"] | null; + }; ProcessListResponse: { processes: components["schemas"]["ProcessInfo"][]; }; @@ -402,6 +821,8 @@ export interface components { }; /** @enum {string} */ ProcessLogsStream: "stdout" | "stderr" | "combined" | "pty"; + /** @enum {string} */ + ProcessOwner: "user" | "desktop" | "system"; ProcessRunRequest: { args?: string[]; command: string; @@ -476,7 +897,6 @@ export type $defs = Record; export type external = Record; export interface operations { - get_v1_acp_servers: { responses: { /** @description Active ACP server instances */ @@ -811,6 +1231,850 @@ export interface operations { }; }; }; + /** + * Get desktop display information. + * @description Performs a health-gated display query against the managed desktop and + * returns the current display identifier and resolution. + */ + get_v1_desktop_display_info: { + responses: { + /** @description Desktop display information */ + 200: { + content: { + "application/json": components["schemas"]["DesktopDisplayInfoResponse"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or display query failed */ + 503: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Press and hold a desktop keyboard key. + * @description Performs a health-gated `xdotool keydown` operation against the managed + * desktop. + */ + post_v1_desktop_keyboard_down: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopKeyboardDownRequest"]; + }; + }; + responses: { + /** @description Desktop keyboard action result */ + 200: { + content: { + "application/json": components["schemas"]["DesktopActionResponse"]; + }; + }; + /** @description Invalid keyboard down request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Press a desktop keyboard shortcut. + * @description Performs a health-gated `xdotool key` operation against the managed + * desktop. + */ + post_v1_desktop_keyboard_press: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopKeyboardPressRequest"]; + }; + }; + responses: { + /** @description Desktop keyboard action result */ + 200: { + content: { + "application/json": components["schemas"]["DesktopActionResponse"]; + }; + }; + /** @description Invalid keyboard press request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Type desktop keyboard text. + * @description Performs a health-gated `xdotool type` operation against the managed + * desktop. + */ + post_v1_desktop_keyboard_type: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopKeyboardTypeRequest"]; + }; + }; + responses: { + /** @description Desktop keyboard action result */ + 200: { + content: { + "application/json": components["schemas"]["DesktopActionResponse"]; + }; + }; + /** @description Invalid keyboard type request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Release a desktop keyboard key. + * @description Performs a health-gated `xdotool keyup` operation against the managed + * desktop. + */ + post_v1_desktop_keyboard_up: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopKeyboardUpRequest"]; + }; + }; + responses: { + /** @description Desktop keyboard action result */ + 200: { + content: { + "application/json": components["schemas"]["DesktopActionResponse"]; + }; + }; + /** @description Invalid keyboard up request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Click on the desktop. + * @description Performs a health-gated pointer move and click against the managed desktop + * and returns the resulting mouse position. + */ + post_v1_desktop_mouse_click: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopMouseClickRequest"]; + }; + }; + responses: { + /** @description Desktop mouse position after click */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Invalid mouse click request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Press and hold a desktop mouse button. + * @description Performs a health-gated optional pointer move followed by `xdotool mousedown` + * and returns the resulting mouse position. + */ + post_v1_desktop_mouse_down: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopMouseDownRequest"]; + }; + }; + responses: { + /** @description Desktop mouse position after button press */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Invalid mouse down request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Drag the desktop mouse. + * @description Performs a health-gated drag gesture against the managed desktop and + * returns the resulting mouse position. + */ + post_v1_desktop_mouse_drag: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopMouseDragRequest"]; + }; + }; + responses: { + /** @description Desktop mouse position after drag */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Invalid mouse drag request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Move the desktop mouse. + * @description Performs a health-gated absolute pointer move on the managed desktop and + * returns the resulting mouse position. + */ + post_v1_desktop_mouse_move: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopMouseMoveRequest"]; + }; + }; + responses: { + /** @description Desktop mouse position after move */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Invalid mouse move request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Get the current desktop mouse position. + * @description Performs a health-gated mouse position query against the managed desktop. + */ + get_v1_desktop_mouse_position: { + responses: { + /** @description Desktop mouse position */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input check failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Scroll the desktop mouse wheel. + * @description Performs a health-gated scroll gesture at the requested coordinates and + * returns the resulting mouse position. + */ + post_v1_desktop_mouse_scroll: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopMouseScrollRequest"]; + }; + }; + responses: { + /** @description Desktop mouse position after scroll */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Invalid mouse scroll request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Release a desktop mouse button. + * @description Performs a health-gated optional pointer move followed by `xdotool mouseup` + * and returns the resulting mouse position. + */ + post_v1_desktop_mouse_up: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopMouseUpRequest"]; + }; + }; + responses: { + /** @description Desktop mouse position after button release */ + 200: { + content: { + "application/json": components["schemas"]["DesktopMousePositionResponse"]; + }; + }; + /** @description Invalid mouse up request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or input failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Start desktop recording. + * @description Starts an ffmpeg x11grab recording against the managed desktop and returns + * the created recording metadata. + */ + post_v1_desktop_recording_start: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopRecordingStartRequest"]; + }; + }; + responses: { + /** @description Desktop recording started */ + 200: { + content: { + "application/json": components["schemas"]["DesktopRecordingInfo"]; + }; + }; + /** @description Desktop runtime is not ready or a recording is already active */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop recording failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Stop desktop recording. + * @description Stops the active desktop recording and returns the finalized recording + * metadata. + */ + post_v1_desktop_recording_stop: { + responses: { + /** @description Desktop recording stopped */ + 200: { + content: { + "application/json": components["schemas"]["DesktopRecordingInfo"]; + }; + }; + /** @description No active desktop recording */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop recording stop failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * List desktop recordings. + * @description Returns the current desktop recording catalog. + */ + get_v1_desktop_recordings: { + responses: { + /** @description Desktop recordings */ + 200: { + content: { + "application/json": components["schemas"]["DesktopRecordingListResponse"]; + }; + }; + /** @description Desktop recordings query failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Get desktop recording metadata. + * @description Returns metadata for a single desktop recording. + */ + get_v1_desktop_recording: { + parameters: { + path: { + /** @description Desktop recording ID */ + id: string; + }; + }; + responses: { + /** @description Desktop recording metadata */ + 200: { + content: { + "application/json": components["schemas"]["DesktopRecordingInfo"]; + }; + }; + /** @description Unknown desktop recording */ + 404: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Delete a desktop recording. + * @description Removes a completed desktop recording and its file from disk. + */ + delete_v1_desktop_recording: { + parameters: { + path: { + /** @description Desktop recording ID */ + id: string; + }; + }; + responses: { + /** @description Desktop recording deleted */ + 204: { + content: never; + }; + /** @description Unknown desktop recording */ + 404: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop recording is still active */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Download a desktop recording. + * @description Serves the recorded MP4 bytes for a completed desktop recording. + */ + get_v1_desktop_recording_download: { + parameters: { + path: { + /** @description Desktop recording ID */ + id: string; + }; + }; + responses: { + /** @description Desktop recording as MP4 bytes */ + 200: { + content: never; + }; + /** @description Unknown desktop recording */ + 404: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Capture a full desktop screenshot. + * @description Performs a health-gated full-frame screenshot of the managed desktop and + * returns the requested image bytes. + */ + get_v1_desktop_screenshot: { + parameters: { + query?: { + format?: components["schemas"]["DesktopScreenshotFormat"] | null; + quality?: number | null; + scale?: number | null; + }; + }; + responses: { + /** @description Desktop screenshot as image bytes */ + 200: { + content: never; + }; + /** @description Invalid screenshot query */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or screenshot capture failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Capture a desktop screenshot region. + * @description Performs a health-gated screenshot crop against the managed desktop and + * returns the requested region image bytes. + */ + get_v1_desktop_screenshot_region: { + parameters: { + query: { + x: number; + y: number; + width: number; + height: number; + format?: components["schemas"]["DesktopScreenshotFormat"] | null; + quality?: number | null; + scale?: number | null; + }; + }; + responses: { + /** @description Desktop screenshot region as image bytes */ + 200: { + content: never; + }; + /** @description Invalid screenshot region */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or screenshot capture failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Start the private desktop runtime. + * @description Lazily launches the managed Xvfb/openbox stack, validates display health, + * and returns the resulting desktop status snapshot. + */ + post_v1_desktop_start: { + requestBody: { + content: { + "application/json": components["schemas"]["DesktopStartRequest"]; + }; + }; + responses: { + /** @description Desktop runtime status after start */ + 200: { + content: { + "application/json": components["schemas"]["DesktopStatusResponse"]; + }; + }; + /** @description Invalid desktop start request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime is already transitioning */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop API unsupported on this platform */ + 501: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime could not be started */ + 503: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Get desktop runtime status. + * @description Returns the current desktop runtime state, dependency status, active + * display metadata, and supervised process information. + */ + get_v1_desktop_status: { + responses: { + /** @description Desktop runtime status */ + 200: { + content: { + "application/json": components["schemas"]["DesktopStatusResponse"]; + }; + }; + /** @description Authentication required */ + 401: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Stop the private desktop runtime. + * @description Terminates the managed openbox/Xvfb/dbus processes owned by the desktop + * runtime and returns the resulting status snapshot. + */ + post_v1_desktop_stop: { + responses: { + /** @description Desktop runtime status after stop */ + 200: { + content: { + "application/json": components["schemas"]["DesktopStatusResponse"]; + }; + }; + /** @description Desktop runtime is already transitioning */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * Start desktop streaming. + * @description Enables desktop websocket streaming for the managed desktop. + */ + post_v1_desktop_stream_start: { + responses: { + /** @description Desktop streaming started */ + 200: { + content: { + "application/json": components["schemas"]["DesktopStreamStatusResponse"]; + }; + }; + }; + }; + /** + * Stop desktop streaming. + * @description Disables desktop websocket streaming for the managed desktop. + */ + post_v1_desktop_stream_stop: { + responses: { + /** @description Desktop streaming stopped */ + 200: { + content: { + "application/json": components["schemas"]["DesktopStreamStatusResponse"]; + }; + }; + }; + }; + /** + * Open a desktop websocket streaming session. + * @description Upgrades the connection to a websocket that streams JPEG desktop frames and + * accepts mouse and keyboard control frames. + */ + get_v1_desktop_stream_ws: { + parameters: { + query?: { + /** @description Bearer token alternative for WS auth */ + access_token?: string | null; + }; + }; + responses: { + /** @description WebSocket upgraded */ + 101: { + content: never; + }; + /** @description Desktop runtime or streaming session is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop stream failed */ + 502: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + /** + * List visible desktop windows. + * @description Performs a health-gated visible-window enumeration against the managed + * desktop and returns the current window metadata. + */ + get_v1_desktop_windows: { + responses: { + /** @description Visible desktop windows */ + 200: { + content: { + "application/json": components["schemas"]["DesktopWindowListResponse"]; + }; + }; + /** @description Desktop runtime is not ready */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Desktop runtime health or window query failed */ + 503: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; get_v1_fs_entries: { parameters: { query?: { @@ -966,6 +2230,11 @@ export interface operations { * by the runtime, sorted by process ID. */ get_v1_processes: { + parameters: { + query?: { + owner?: components["schemas"]["ProcessOwner"] | null; + }; + }; responses: { /** @description List processes */ 200: { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 15537dd..8c05760 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -14,10 +14,18 @@ export { export { AcpRpcError } from "acp-http-client"; export { buildInspectorUrl } from "./inspector.ts"; +export { DesktopStreamSession } from "./desktop-stream.ts"; +export type { + DesktopStreamConnectOptions, + DesktopStreamErrorStatus, + DesktopStreamReadyStatus, + DesktopStreamStatusMessage, +} from "./desktop-stream.ts"; export type { SandboxAgentHealthWaitOptions, AgentQueryOptions, + DesktopStreamSessionOptions, ProcessLogFollowQuery, ProcessLogListener, ProcessLogSubscription, @@ -50,6 +58,37 @@ export type { AgentInstallRequest, AgentInstallResponse, AgentListResponse, + DesktopActionResponse, + DesktopDisplayInfoResponse, + DesktopErrorInfo, + DesktopKeyboardDownRequest, + DesktopKeyboardUpRequest, + DesktopKeyModifiers, + DesktopKeyboardPressRequest, + DesktopKeyboardTypeRequest, + DesktopMouseButton, + DesktopMouseClickRequest, + DesktopMouseDownRequest, + DesktopMouseDragRequest, + DesktopMouseMoveRequest, + DesktopMousePositionResponse, + DesktopMouseScrollRequest, + DesktopMouseUpRequest, + DesktopProcessInfo, + DesktopRecordingInfo, + DesktopRecordingListResponse, + DesktopRecordingStartRequest, + DesktopRecordingStatus, + DesktopRegionScreenshotQuery, + DesktopResolution, + DesktopScreenshotFormat, + DesktopScreenshotQuery, + DesktopStartRequest, + DesktopState, + DesktopStatusResponse, + DesktopStreamStatusResponse, + DesktopWindowInfo, + DesktopWindowListResponse, FsActionResponse, FsDeleteQuery, FsEntriesQuery, @@ -74,10 +113,12 @@ export type { ProcessInfo, ProcessInputRequest, ProcessInputResponse, + ProcessListQuery, ProcessListResponse, ProcessLogEntry, ProcessLogsQuery, ProcessLogsResponse, + ProcessOwner, ProcessLogsStream, ProcessRunRequest, ProcessRunResponse, diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index f2a7af3..080e62c 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -4,6 +4,38 @@ import type { components, operations } from "./generated/openapi.ts"; export type ProblemDetails = components["schemas"]["ProblemDetails"]; export type HealthResponse = JsonResponse; +export type DesktopState = components["schemas"]["DesktopState"]; +export type DesktopResolution = components["schemas"]["DesktopResolution"]; +export type DesktopErrorInfo = components["schemas"]["DesktopErrorInfo"]; +export type DesktopProcessInfo = components["schemas"]["DesktopProcessInfo"]; +export type DesktopStatusResponse = JsonResponse; +export type DesktopStartRequest = JsonRequestBody; +export type DesktopScreenshotFormat = components["schemas"]["DesktopScreenshotFormat"]; +export type DesktopScreenshotQuery = + QueryParams extends never ? Record : QueryParams; +export type DesktopRegionScreenshotQuery = QueryParams; +export type DesktopMousePositionResponse = JsonResponse; +export type DesktopMouseButton = components["schemas"]["DesktopMouseButton"]; +export type DesktopMouseMoveRequest = JsonRequestBody; +export type DesktopMouseClickRequest = JsonRequestBody; +export type DesktopMouseDownRequest = JsonRequestBody; +export type DesktopMouseUpRequest = JsonRequestBody; +export type DesktopMouseDragRequest = JsonRequestBody; +export type DesktopMouseScrollRequest = JsonRequestBody; +export type DesktopKeyboardTypeRequest = JsonRequestBody; +export type DesktopKeyModifiers = components["schemas"]["DesktopKeyModifiers"]; +export type DesktopKeyboardPressRequest = JsonRequestBody; +export type DesktopKeyboardDownRequest = JsonRequestBody; +export type DesktopKeyboardUpRequest = JsonRequestBody; +export type DesktopActionResponse = JsonResponse; +export type DesktopDisplayInfoResponse = JsonResponse; +export type DesktopWindowInfo = components["schemas"]["DesktopWindowInfo"]; +export type DesktopWindowListResponse = JsonResponse; +export type DesktopRecordingStartRequest = JsonRequestBody; +export type DesktopRecordingStatus = components["schemas"]["DesktopRecordingStatus"]; +export type DesktopRecordingInfo = JsonResponse; +export type DesktopRecordingListResponse = JsonResponse; +export type DesktopStreamStatusResponse = JsonResponse; export type AgentListResponse = JsonResponse; export type AgentInfo = components["schemas"]["AgentInfo"]; export type AgentQuery = QueryParams; @@ -37,11 +69,13 @@ export type ProcessCreateRequest = JsonRequestBody; export type ProcessInputResponse = JsonResponse; +export type ProcessListQuery = QueryParams; export type ProcessListResponse = JsonResponse; export type ProcessLogEntry = components["schemas"]["ProcessLogEntry"]; export type ProcessLogsQuery = QueryParams; export type ProcessLogsResponse = JsonResponse; export type ProcessLogsStream = components["schemas"]["ProcessLogsStream"]; +export type ProcessOwner = components["schemas"]["ProcessOwner"]; export type ProcessRunRequest = JsonRequestBody; export type ProcessRunResponse = JsonResponse; export type ProcessSignalQuery = QueryParams; diff --git a/sdks/typescript/tests/helpers/docker.ts b/sdks/typescript/tests/helpers/docker.ts new file mode 100644 index 0000000..c15c03c --- /dev/null +++ b/sdks/typescript/tests/helpers/docker.ts @@ -0,0 +1,244 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "../../../.."); +const CONTAINER_PORT = 3000; +const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const DEFAULT_IMAGE_TAG = "sandbox-agent-test:dev"; +const STANDARD_PATHS = new Set(["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"]); + +let cachedImage: string | undefined; +let containerCounter = 0; + +export type DockerSandboxAgentHandle = { + baseUrl: string; + token: string; + dispose: () => Promise; +}; + +export type DockerSandboxAgentOptions = { + env?: Record; + pathMode?: "merge" | "replace"; + timeoutMs?: number; +}; + +type TestLayout = { + rootDir: string; + homeDir: string; + xdgDataHome: string; + xdgStateHome: string; + appDataDir: string; + localAppDataDir: string; + installDir: string; +}; + +export function createDockerTestLayout(): TestLayout { + const tempRoot = join(REPO_ROOT, ".context", "docker-test-"); + mkdirSync(resolve(REPO_ROOT, ".context"), { recursive: true }); + const rootDir = mkdtempSync(tempRoot); + const homeDir = join(rootDir, "home"); + const xdgDataHome = join(rootDir, "xdg-data"); + const xdgStateHome = join(rootDir, "xdg-state"); + const appDataDir = join(rootDir, "appdata", "Roaming"); + const localAppDataDir = join(rootDir, "appdata", "Local"); + const installDir = join(xdgDataHome, "sandbox-agent", "bin"); + + for (const dir of [homeDir, xdgDataHome, xdgStateHome, appDataDir, localAppDataDir, installDir]) { + mkdirSync(dir, { recursive: true }); + } + + return { + rootDir, + homeDir, + xdgDataHome, + xdgStateHome, + appDataDir, + localAppDataDir, + installDir, + }; +} + +export function disposeDockerTestLayout(layout: TestLayout): void { + try { + rmSync(layout.rootDir, { recursive: true, force: true }); + } catch (error) { + if (typeof process.getuid === "function" && typeof process.getgid === "function") { + try { + execFileSync( + "docker", + [ + "run", + "--rm", + "--user", + "0:0", + "--entrypoint", + "sh", + "-v", + `${layout.rootDir}:${layout.rootDir}`, + ensureImage(), + "-c", + `chown -R ${process.getuid()}:${process.getgid()} '${layout.rootDir}'`, + ], + { stdio: "pipe" }, + ); + rmSync(layout.rootDir, { recursive: true, force: true }); + return; + } catch {} + } + throw error; + } +} + +export async function startDockerSandboxAgent(layout: TestLayout, options: DockerSandboxAgentOptions = {}): Promise { + const image = ensureImage(); + const containerId = uniqueContainerId(); + const env = buildEnv(layout, options.env ?? {}, options.pathMode ?? "merge"); + const mounts = buildMounts(layout.rootDir, env); + + const args = ["run", "-d", "--rm", "--name", containerId, "-p", `127.0.0.1::${CONTAINER_PORT}`]; + + if (typeof process.getuid === "function" && typeof process.getgid === "function") { + args.push("--user", `${process.getuid()}:${process.getgid()}`); + } + + if (process.platform === "linux") { + args.push("--add-host", "host.docker.internal:host-gateway"); + } + + for (const mount of mounts) { + args.push("-v", `${mount}:${mount}`); + } + + for (const [key, value] of Object.entries(env)) { + args.push("-e", `${key}=${value}`); + } + + args.push(image, "server", "--host", "0.0.0.0", "--port", String(CONTAINER_PORT), "--no-token"); + + execFileSync("docker", args, { stdio: "pipe" }); + + try { + const mapping = execFileSync("docker", ["port", containerId, `${CONTAINER_PORT}/tcp`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const mappingParts = mapping.split(":"); + const hostPort = mappingParts[mappingParts.length - 1]?.trim(); + if (!hostPort) { + throw new Error(`missing mapped host port in ${mapping}`); + } + const baseUrl = `http://127.0.0.1:${hostPort}`; + await waitForHealth(baseUrl, options.timeoutMs ?? 30_000); + + return { + baseUrl, + token: "", + dispose: async () => { + try { + execFileSync("docker", ["rm", "-f", containerId], { stdio: "pipe" }); + } catch {} + }, + }; + } catch (error) { + try { + execFileSync("docker", ["rm", "-f", containerId], { stdio: "pipe" }); + } catch {} + throw error; + } +} + +function ensureImage(): string { + if (cachedImage) { + return cachedImage; + } + + cachedImage = process.env.SANDBOX_AGENT_TEST_IMAGE ?? DEFAULT_IMAGE_TAG; + execFileSync("docker", ["build", "--tag", cachedImage, "--file", resolve(REPO_ROOT, "docker/test-agent/Dockerfile"), REPO_ROOT], { + cwd: REPO_ROOT, + stdio: ["ignore", "ignore", "pipe"], + }); + return cachedImage; +} + +function buildEnv(layout: TestLayout, extraEnv: Record, pathMode: "merge" | "replace"): Record { + const env: Record = { + HOME: layout.homeDir, + USERPROFILE: layout.homeDir, + XDG_DATA_HOME: layout.xdgDataHome, + XDG_STATE_HOME: layout.xdgStateHome, + APPDATA: layout.appDataDir, + LOCALAPPDATA: layout.localAppDataDir, + PATH: DEFAULT_PATH, + }; + + const customPathEntries = new Set(); + for (const entry of (extraEnv.PATH ?? "").split(":")) { + if (!entry || entry === DEFAULT_PATH || !entry.startsWith("/")) continue; + if (entry.startsWith(layout.rootDir)) { + customPathEntries.add(entry); + } + } + if (pathMode === "replace") { + env.PATH = extraEnv.PATH ?? ""; + } else if (customPathEntries.size > 0) { + env.PATH = `${Array.from(customPathEntries).join(":")}:${DEFAULT_PATH}`; + } + + for (const [key, value] of Object.entries(extraEnv)) { + if (key === "PATH") { + continue; + } + env[key] = rewriteLocalhostUrl(key, value); + } + + return env; +} + +function buildMounts(rootDir: string, env: Record): string[] { + const mounts = new Set([rootDir]); + + for (const key of ["HOME", "USERPROFILE", "XDG_DATA_HOME", "XDG_STATE_HOME", "APPDATA", "LOCALAPPDATA", "SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR"]) { + const value = env[key]; + if (value?.startsWith("/")) { + mounts.add(value); + } + } + + for (const entry of (env.PATH ?? "").split(":")) { + if (entry.startsWith("/") && !STANDARD_PATHS.has(entry)) { + mounts.add(entry); + } + } + + return Array.from(mounts); +} + +async function waitForHealth(baseUrl: string, timeoutMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(`${baseUrl}/v1/health`); + if (response.ok) { + return; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + throw new Error(`timed out waiting for sandbox-agent health at ${baseUrl}`); +} + +function uniqueContainerId(): string { + containerCounter += 1; + return `sandbox-agent-ts-${process.pid}-${Date.now().toString(36)}-${containerCounter.toString(36)}`; +} + +function rewriteLocalhostUrl(key: string, value: string): string { + if (key.endsWith("_URL") || key.endsWith("_URI")) { + return value.replace("http://127.0.0.1", "http://host.docker.internal").replace("http://localhost", "http://host.docker.internal"); + } + return value; +} diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 295e688..d5ae278 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -1,9 +1,6 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { existsSync } from "node:fs"; -import { mkdtempSync, rmSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { InMemorySessionPersistDriver, @@ -14,36 +11,11 @@ import { type SessionPersistDriver, type SessionRecord, } from "../src/index.ts"; -import { spawnSandboxAgent, isNodeRuntime, type SandboxAgentSpawnHandle } from "../src/spawn.ts"; +import { isNodeRuntime } from "../src/spawn.ts"; +import { createDockerTestLayout, disposeDockerTestLayout, startDockerSandboxAgent, type DockerSandboxAgentHandle } from "./helpers/docker.ts"; import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts"; import WebSocket from "ws"; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -110,6 +82,15 @@ async function waitForAsync(fn: () => Promise, timeoutM throw new Error("timed out waiting for condition"); } +async function withTimeout(promise: Promise, label: string, timeoutMs = 15_000): Promise { + return await Promise.race([ + promise, + sleep(timeoutMs).then(() => { + throw new Error(`${label} timed out after ${timeoutMs}ms`); + }), + ]); +} + function buildTarArchive(entries: Array<{ name: string; content: string }>): Uint8Array { const blocks: Buffer[] = []; @@ -174,34 +155,77 @@ function decodeProcessLogData(data: string, encoding: string): string { function nodeCommand(source: string): { command: string; args: string[] } { return { - command: process.execPath, + command: "node", args: ["-e", source], }; } +function forwardRequest(defaultFetch: typeof fetch, baseUrl: string, outgoing: Request, parsed: URL): Promise { + const forwardedInit: RequestInit & { duplex?: "half" } = { + method: outgoing.method, + headers: new Headers(outgoing.headers), + signal: outgoing.signal, + }; + + if (outgoing.method !== "GET" && outgoing.method !== "HEAD") { + forwardedInit.body = outgoing.body; + forwardedInit.duplex = "half"; + } + + const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl); + return defaultFetch(forwardedUrl, forwardedInit); +} + +async function launchDesktopFocusWindow(sdk: SandboxAgent, display: string): Promise { + const windowProcess = await sdk.createProcess({ + command: "xterm", + args: ["-geometry", "80x24+40+40", "-title", "Sandbox Desktop Test", "-e", "sh", "-lc", "sleep 60"], + env: { DISPLAY: display }, + }); + + await waitForAsync( + async () => { + const result = await sdk.runProcess({ + command: "sh", + args: [ + "-lc", + 'wid="$(xdotool search --onlyvisible --name \'Sandbox Desktop Test\' 2>/dev/null | head -n 1 || true)"; if [ -z "$wid" ]; then exit 3; fi; xdotool windowactivate "$wid"', + ], + env: { DISPLAY: display }, + timeoutMs: 5_000, + }); + + return result.exitCode === 0 ? true : undefined; + }, + 10_000, + 200, + ); + + return windowProcess.id; +} + describe("Integration: TypeScript SDK flat session API", () => { - let handle: SandboxAgentSpawnHandle; + let handle: DockerSandboxAgentHandle; let baseUrl: string; let token: string; - let dataHome: string; + let layout: ReturnType; - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-")); - const agentEnv = prepareMockAgentDataHome(dataHome); + beforeEach(async () => { + layout = createDockerTestLayout(); + prepareMockAgentDataHome(layout.xdgDataHome); - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", + handle = await startDockerSandboxAgent(layout, { timeoutMs: 30000, - env: agentEnv, }); baseUrl = handle.baseUrl; token = handle.token; }); - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); + afterEach(async () => { + await handle?.dispose?.(); + if (layout) { + disposeDockerTestLayout(layout); + } }); it("detects Node.js runtime", () => { @@ -280,11 +304,12 @@ describe("Integration: TypeScript SDK flat session API", () => { token, }); - const directory = mkdtempSync(join(tmpdir(), "sdk-fs-")); + const directory = join(layout.rootDir, "fs-test"); const nestedDir = join(directory, "nested"); const filePath = join(directory, "notes.txt"); const movedPath = join(directory, "notes-moved.txt"); const uploadDir = join(directory, "uploaded"); + mkdirSync(directory, { recursive: true }); try { const listedAgents = await sdk.listAgents({ config: true, noCache: true }); @@ -341,25 +366,30 @@ describe("Integration: TypeScript SDK flat session API", () => { const parsed = new URL(outgoing.url); seenPaths.push(parsed.pathname); - const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl); - const forwarded = new Request(forwardedUrl.toString(), outgoing); - return defaultFetch(forwarded); + return forwardRequest(defaultFetch, baseUrl, outgoing, parsed); }; const sdk = await SandboxAgent.connect({ token, fetch: customFetch, }); + let sessionId: string | undefined; - await sdk.getHealth(); - const session = await sdk.createSession({ agent: "mock" }); - const prompt = await session.prompt([{ type: "text", text: "custom fetch integration test" }]); - expect(prompt.stopReason).toBe("end_turn"); + try { + await withTimeout(sdk.getHealth(), "custom fetch getHealth"); + const session = await withTimeout(sdk.createSession({ agent: "mock" }), "custom fetch createSession"); + sessionId = session.id; + expect(session.agent).toBe("mock"); + await withTimeout(sdk.destroySession(session.id), "custom fetch destroySession"); - expect(seenPaths).toContain("/v1/health"); - expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true); - - await sdk.dispose(); + expect(seenPaths).toContain("/v1/health"); + expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true); + } finally { + if (sessionId) { + await sdk.destroySession(sessionId).catch(() => {}); + } + await withTimeout(sdk.dispose(), "custom fetch dispose"); + } }, 60_000); it("requires baseUrl when fetch is not provided", async () => { @@ -386,9 +416,7 @@ describe("Integration: TypeScript SDK flat session API", () => { } } - const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl); - const forwarded = new Request(forwardedUrl.toString(), outgoing); - return defaultFetch(forwarded); + return forwardRequest(defaultFetch, baseUrl, outgoing, parsed); }; const sdk = await SandboxAgent.connect({ @@ -710,7 +738,9 @@ describe("Integration: TypeScript SDK flat session API", () => { token, }); - const directory = mkdtempSync(join(tmpdir(), "sdk-config-")); + const directory = join(layout.rootDir, "config-test"); + + mkdirSync(directory, { recursive: true }); const mcpConfig = { type: "local" as const, @@ -957,4 +987,98 @@ describe("Integration: TypeScript SDK flat session API", () => { await sdk.dispose(); } }); + + it("covers desktop status, screenshot, display, mouse, and keyboard helpers", async () => { + const sdk = await SandboxAgent.connect({ + baseUrl, + token, + }); + let focusWindowProcessId: string | undefined; + + try { + const initialStatus = await sdk.getDesktopStatus(); + expect(initialStatus.state).toBe("inactive"); + + const started = await sdk.startDesktop({ + width: 1440, + height: 900, + dpi: 96, + }); + expect(started.state).toBe("active"); + expect(started.display?.startsWith(":")).toBe(true); + expect(started.missingDependencies).toEqual([]); + + const displayInfo = await sdk.getDesktopDisplayInfo(); + expect(displayInfo.display).toBe(started.display); + expect(displayInfo.resolution.width).toBe(1440); + expect(displayInfo.resolution.height).toBe(900); + + const screenshot = await sdk.takeDesktopScreenshot(); + expect(Buffer.from(screenshot.subarray(0, 8)).equals(Buffer.from("\x89PNG\r\n\x1a\n", "binary"))).toBe(true); + + const region = await sdk.takeDesktopRegionScreenshot({ + x: 10, + y: 20, + width: 40, + height: 50, + }); + expect(Buffer.from(region.subarray(0, 8)).equals(Buffer.from("\x89PNG\r\n\x1a\n", "binary"))).toBe(true); + + const moved = await sdk.moveDesktopMouse({ x: 40, y: 50 }); + expect(moved.x).toBe(40); + expect(moved.y).toBe(50); + + const dragged = await sdk.dragDesktopMouse({ + startX: 40, + startY: 50, + endX: 80, + endY: 90, + button: "left", + }); + expect(dragged.x).toBe(80); + expect(dragged.y).toBe(90); + + const clicked = await sdk.clickDesktop({ + x: 80, + y: 90, + button: "left", + clickCount: 1, + }); + expect(clicked.x).toBe(80); + expect(clicked.y).toBe(90); + + const scrolled = await sdk.scrollDesktop({ + x: 80, + y: 90, + deltaY: -2, + }); + expect(scrolled.x).toBe(80); + expect(scrolled.y).toBe(90); + + const position = await sdk.getDesktopMousePosition(); + expect(position.x).toBe(80); + expect(position.y).toBe(90); + + focusWindowProcessId = await launchDesktopFocusWindow(sdk, started.display!); + + const typed = await sdk.typeDesktopText({ + text: "hello desktop", + delayMs: 5, + }); + expect(typed.ok).toBe(true); + + const pressed = await sdk.pressDesktopKey({ key: "ctrl+l" }); + expect(pressed.ok).toBe(true); + + const stopped = await sdk.stopDesktop(); + expect(stopped.state).toBe("inactive"); + } finally { + if (focusWindowProcessId) { + await sdk.killProcess(focusWindowProcessId, { waitMs: 5_000 }).catch(() => {}); + await sdk.deleteProcess(focusWindowProcessId).catch(() => {}); + } + await sdk.stopDesktop().catch(() => {}); + await sdk.dispose(); + } + }); }); diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts index e83d10a..a3ba3f3 100644 --- a/sdks/typescript/vitest.config.ts +++ b/sdks/typescript/vitest.config.ts @@ -4,7 +4,6 @@ export default defineConfig({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30000, - teardownTimeout: 10000, - pool: "forks", + hookTimeout: 120000, }, }); diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index 51757b6..000ea41 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -11,6 +11,7 @@ mod build_version { include!(concat!(env!("OUT_DIR"), "/version.rs")); } +use crate::desktop_install::{install_desktop, DesktopInstallRequest, DesktopPackageManager}; use crate::router::{ build_router_with_state, shutdown_servers, AppState, AuthConfig, BrandingMode, }; @@ -75,6 +76,8 @@ pub enum Command { Server(ServerArgs), /// Call the HTTP API without writing client code. Api(ApiArgs), + /// Install first-party runtime dependencies. + Install(InstallArgs), /// EXPERIMENTAL: OpenCode compatibility layer (disabled until ACP Phase 7). Opencode(OpencodeArgs), /// Manage the sandbox-agent background daemon. @@ -118,6 +121,12 @@ pub struct ApiArgs { command: ApiCommand, } +#[derive(Args, Debug)] +pub struct InstallArgs { + #[command(subcommand)] + command: InstallCommand, +} + #[derive(Args, Debug)] pub struct OpencodeArgs { #[arg(long, short = 'H', default_value = DEFAULT_HOST)] @@ -156,6 +165,12 @@ pub struct DaemonArgs { command: DaemonCommand, } +#[derive(Subcommand, Debug)] +pub enum InstallCommand { + /// Install desktop runtime dependencies. + Desktop(InstallDesktopArgs), +} + #[derive(Subcommand, Debug)] pub enum DaemonCommand { /// Start the daemon in the background. @@ -310,6 +325,18 @@ pub struct InstallAgentArgs { agent_process_version: Option, } +#[derive(Args, Debug)] +pub struct InstallDesktopArgs { + #[arg(long, default_value_t = false)] + yes: bool, + #[arg(long, default_value_t = false)] + print_only: bool, + #[arg(long, value_enum)] + package_manager: Option, + #[arg(long, default_value_t = false)] + no_fonts: bool, +} + #[derive(Args, Debug)] pub struct CredentialsExtractArgs { #[arg(long, short = 'a', value_enum)] @@ -405,6 +432,7 @@ pub fn run_command(command: &Command, cli: &CliConfig) -> Result<(), CliError> { match command { Command::Server(args) => run_server(cli, args), Command::Api(subcommand) => run_api(&subcommand.command, cli), + Command::Install(subcommand) => run_install(&subcommand.command), Command::Opencode(args) => run_opencode(cli, args), Command::Daemon(subcommand) => run_daemon(&subcommand.command, cli), Command::InstallAgent(args) => install_agent_local(args), @@ -413,6 +441,12 @@ pub fn run_command(command: &Command, cli: &CliConfig) -> Result<(), CliError> { } } +fn run_install(command: &InstallCommand) -> Result<(), CliError> { + match command { + InstallCommand::Desktop(args) => install_desktop_local(args), + } +} + fn run_server(cli: &CliConfig, server: &ServerArgs) -> Result<(), CliError> { let auth = if let Some(token) = cli.token.clone() { AuthConfig::with_token(token) @@ -477,6 +511,17 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> { } } +fn install_desktop_local(args: &InstallDesktopArgs) -> Result<(), CliError> { + install_desktop(DesktopInstallRequest { + yes: args.yes, + print_only: args.print_only, + package_manager: args.package_manager, + no_fonts: args.no_fonts, + }) + .map(|_| ()) + .map_err(CliError::Server) +} + fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError> { match command { AgentsCommand::List(args) => { diff --git a/server/packages/sandbox-agent/src/desktop_errors.rs b/server/packages/sandbox-agent/src/desktop_errors.rs new file mode 100644 index 0000000..67f99b9 --- /dev/null +++ b/server/packages/sandbox-agent/src/desktop_errors.rs @@ -0,0 +1,217 @@ +use sandbox_agent_error::ProblemDetails; +use serde_json::{json, Map, Value}; + +use crate::desktop_types::{DesktopErrorInfo, DesktopProcessInfo}; + +#[derive(Debug, Clone)] +pub struct DesktopProblem { + status: u16, + title: &'static str, + code: &'static str, + message: String, + missing_dependencies: Vec, + install_command: Option, + processes: Vec, +} + +impl DesktopProblem { + pub fn unsupported_platform(message: impl Into) -> Self { + Self::new( + 501, + "Desktop Unsupported", + "desktop_unsupported_platform", + message, + ) + } + + pub fn dependencies_missing( + missing_dependencies: Vec, + install_command: Option, + processes: Vec, + ) -> Self { + let mut message = if missing_dependencies.is_empty() { + "Desktop dependencies are not installed".to_string() + } else { + format!( + "Desktop dependencies are not installed: {}", + missing_dependencies.join(", ") + ) + }; + if let Some(command) = install_command.as_ref() { + message.push_str(&format!( + ". Run `{command}` to install them, or install the required tools manually." + )); + } + Self::new( + 503, + "Desktop Dependencies Missing", + "desktop_dependencies_missing", + message, + ) + .with_missing_dependencies(missing_dependencies) + .with_install_command(install_command) + .with_processes(processes) + } + + pub fn runtime_inactive(message: impl Into) -> Self { + Self::new( + 409, + "Desktop Runtime Inactive", + "desktop_runtime_inactive", + message, + ) + } + + pub fn runtime_starting(message: impl Into) -> Self { + Self::new( + 409, + "Desktop Runtime Starting", + "desktop_runtime_starting", + message, + ) + } + + pub fn runtime_failed( + message: impl Into, + install_command: Option, + processes: Vec, + ) -> Self { + Self::new( + 503, + "Desktop Runtime Failed", + "desktop_runtime_failed", + message, + ) + .with_install_command(install_command) + .with_processes(processes) + } + + pub fn invalid_action(message: impl Into) -> Self { + Self::new( + 400, + "Desktop Invalid Action", + "desktop_invalid_action", + message, + ) + } + + pub fn screenshot_failed( + message: impl Into, + processes: Vec, + ) -> Self { + Self::new( + 502, + "Desktop Screenshot Failed", + "desktop_screenshot_failed", + message, + ) + .with_processes(processes) + } + + pub fn input_failed(message: impl Into, processes: Vec) -> Self { + Self::new(502, "Desktop Input Failed", "desktop_input_failed", message) + .with_processes(processes) + } + + pub fn to_problem_details(&self) -> ProblemDetails { + let mut extensions = Map::new(); + extensions.insert("code".to_string(), Value::String(self.code.to_string())); + if !self.missing_dependencies.is_empty() { + extensions.insert( + "missingDependencies".to_string(), + Value::Array( + self.missing_dependencies + .iter() + .cloned() + .map(Value::String) + .collect(), + ), + ); + } + if let Some(install_command) = self.install_command.as_ref() { + extensions.insert( + "installCommand".to_string(), + Value::String(install_command.clone()), + ); + } + if !self.processes.is_empty() { + extensions.insert("processes".to_string(), json!(self.processes)); + } + + ProblemDetails { + type_: format!("urn:sandbox-agent:error:{}", self.code), + title: self.title.to_string(), + status: self.status, + detail: Some(self.message.clone()), + instance: None, + extensions, + } + } + + pub fn to_error_info(&self) -> DesktopErrorInfo { + DesktopErrorInfo { + code: self.code.to_string(), + message: self.message.clone(), + } + } + + pub fn code(&self) -> &'static str { + self.code + } + + fn new( + status: u16, + title: &'static str, + code: &'static str, + message: impl Into, + ) -> Self { + Self { + status, + title, + code, + message: message.into(), + missing_dependencies: Vec::new(), + install_command: None, + processes: Vec::new(), + } + } + + fn with_missing_dependencies(mut self, missing_dependencies: Vec) -> Self { + self.missing_dependencies = missing_dependencies; + self + } + + fn with_install_command(mut self, install_command: Option) -> Self { + self.install_command = install_command; + self + } + + fn with_processes(mut self, processes: Vec) -> Self { + self.processes = processes; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dependencies_missing_detail_includes_install_command() { + let problem = DesktopProblem::dependencies_missing( + vec!["Xvfb".to_string(), "openbox".to_string()], + Some("sandbox-agent install desktop --yes".to_string()), + Vec::new(), + ); + let details = problem.to_problem_details(); + let detail = details.detail.expect("detail"); + assert!(detail.contains("Desktop dependencies are not installed: Xvfb, openbox")); + assert!(detail.contains("sandbox-agent install desktop --yes")); + assert_eq!( + details.extensions.get("installCommand"), + Some(&Value::String( + "sandbox-agent install desktop --yes".to_string() + )) + ); + } +} diff --git a/server/packages/sandbox-agent/src/desktop_install.rs b/server/packages/sandbox-agent/src/desktop_install.rs new file mode 100644 index 0000000..480da7d --- /dev/null +++ b/server/packages/sandbox-agent/src/desktop_install.rs @@ -0,0 +1,324 @@ +use std::fmt; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::Command as ProcessCommand; + +use clap::ValueEnum; + +const AUTOMATIC_INSTALL_SUPPORTED_DISTROS: &str = + "Automatic desktop dependency installation is supported on Debian/Ubuntu (apt), Fedora/RHEL (dnf), and Alpine (apk)."; +const AUTOMATIC_INSTALL_UNSUPPORTED_ENVS: &str = + "Automatic installation is not supported on macOS, Windows, or Linux distributions without apt, dnf, or apk."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum DesktopPackageManager { + Apt, + Dnf, + Apk, +} + +#[derive(Debug, Clone)] +pub struct DesktopInstallRequest { + pub yes: bool, + pub print_only: bool, + pub package_manager: Option, + pub no_fonts: bool, +} + +pub(crate) fn desktop_platform_support_message() -> String { + format!("Desktop APIs are only supported on Linux. {AUTOMATIC_INSTALL_SUPPORTED_DISTROS}") +} + +fn linux_install_support_message() -> String { + format!("{AUTOMATIC_INSTALL_SUPPORTED_DISTROS} {AUTOMATIC_INSTALL_UNSUPPORTED_ENVS}") +} + +pub fn install_desktop(request: DesktopInstallRequest) -> Result<(), String> { + if std::env::consts::OS != "linux" { + return Err(format!( + "desktop installation is only supported on Linux. {}", + linux_install_support_message() + )); + } + + let package_manager = match request.package_manager { + Some(value) => value, + None => detect_package_manager().ok_or_else(|| { + format!( + "could not detect a supported package manager. {} Install the desktop dependencies manually on this distribution.", + linux_install_support_message() + ) + })?, + }; + + let packages = desktop_packages(package_manager, request.no_fonts); + let used_sudo = !running_as_root() && find_binary("sudo").is_some(); + if !running_as_root() && !used_sudo { + return Err( + "desktop installation requires root or sudo access; rerun as root or install dependencies manually" + .to_string(), + ); + } + + println!("Desktop package manager: {}", package_manager); + println!("Desktop packages:"); + for package in &packages { + println!(" - {package}"); + } + println!("Install command:"); + println!( + " {}", + render_install_command(package_manager, used_sudo, &packages) + ); + + if request.print_only { + return Ok(()); + } + + if !request.yes && !prompt_yes_no("Proceed with desktop dependency installation? [y/N] ")? { + return Err("installation cancelled".to_string()); + } + + run_install_commands(package_manager, used_sudo, &packages)?; + + println!("Desktop dependencies installed."); + Ok(()) +} + +fn detect_package_manager() -> Option { + if find_binary("apt-get").is_some() { + return Some(DesktopPackageManager::Apt); + } + if find_binary("dnf").is_some() { + return Some(DesktopPackageManager::Dnf); + } + if find_binary("apk").is_some() { + return Some(DesktopPackageManager::Apk); + } + None +} + +fn desktop_packages(package_manager: DesktopPackageManager, no_fonts: bool) -> Vec { + let mut packages = match package_manager { + DesktopPackageManager::Apt => vec![ + "xvfb", + "openbox", + "xdotool", + "imagemagick", + "ffmpeg", + "x11-xserver-utils", + "dbus-x11", + "xauth", + "fonts-dejavu-core", + ], + DesktopPackageManager::Dnf => vec![ + "xorg-x11-server-Xvfb", + "openbox", + "xdotool", + "ImageMagick", + "ffmpeg", + "xrandr", + "dbus-x11", + "xauth", + "dejavu-sans-fonts", + ], + DesktopPackageManager::Apk => vec![ + "xvfb", + "openbox", + "xdotool", + "imagemagick", + "ffmpeg", + "xrandr", + "dbus", + "xauth", + "ttf-dejavu", + ], + } + .into_iter() + .map(str::to_string) + .collect::>(); + + if no_fonts { + packages.retain(|package| { + package != "fonts-dejavu-core" + && package != "dejavu-sans-fonts" + && package != "ttf-dejavu" + }); + } + + packages +} + +fn render_install_command( + package_manager: DesktopPackageManager, + used_sudo: bool, + packages: &[String], +) -> String { + let sudo = if used_sudo { "sudo " } else { "" }; + match package_manager { + DesktopPackageManager::Apt => format!( + "{sudo}apt-get update && {sudo}env DEBIAN_FRONTEND=noninteractive apt-get install -y {}", + packages.join(" ") + ), + DesktopPackageManager::Dnf => { + format!("{sudo}dnf install -y {}", packages.join(" ")) + } + DesktopPackageManager::Apk => { + format!("{sudo}apk add --no-cache {}", packages.join(" ")) + } + } +} + +fn run_install_commands( + package_manager: DesktopPackageManager, + used_sudo: bool, + packages: &[String], +) -> Result<(), String> { + match package_manager { + DesktopPackageManager::Apt => { + run_command(command_with_privilege( + used_sudo, + "apt-get", + vec!["update".to_string()], + ))?; + let mut args = vec![ + "DEBIAN_FRONTEND=noninteractive".to_string(), + "apt-get".to_string(), + "install".to_string(), + "-y".to_string(), + ]; + args.extend(packages.iter().cloned()); + run_command(command_with_privilege(used_sudo, "env", args))?; + } + DesktopPackageManager::Dnf => { + let mut args = vec!["install".to_string(), "-y".to_string()]; + args.extend(packages.iter().cloned()); + run_command(command_with_privilege(used_sudo, "dnf", args))?; + } + DesktopPackageManager::Apk => { + let mut args = vec!["add".to_string(), "--no-cache".to_string()]; + args.extend(packages.iter().cloned()); + run_command(command_with_privilege(used_sudo, "apk", args))?; + } + } + Ok(()) +} + +fn command_with_privilege( + used_sudo: bool, + program: &str, + args: Vec, +) -> (String, Vec) { + if used_sudo { + let mut sudo_args = vec![program.to_string()]; + sudo_args.extend(args); + ("sudo".to_string(), sudo_args) + } else { + (program.to_string(), args) + } +} + +fn run_command((program, args): (String, Vec)) -> Result<(), String> { + let status = ProcessCommand::new(&program) + .args(&args) + .status() + .map_err(|err| format!("failed to run `{program}`: {err}"))?; + if !status.success() { + return Err(format!( + "command `{}` exited with status {}", + format_command(&program, &args), + status + )); + } + Ok(()) +} + +fn prompt_yes_no(prompt: &str) -> Result { + print!("{prompt}"); + io::stdout() + .flush() + .map_err(|err| format!("failed to flush prompt: {err}"))?; + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .map_err(|err| format!("failed to read confirmation: {err}"))?; + let normalized = input.trim().to_ascii_lowercase(); + Ok(matches!(normalized.as_str(), "y" | "yes")) +} + +fn running_as_root() -> bool { + #[cfg(unix)] + unsafe { + return libc::geteuid() == 0; + } + #[cfg(not(unix))] + { + false + } +} + +fn find_binary(name: &str) -> Option { + let path_env = std::env::var_os("PATH")?; + for path in std::env::split_paths(&path_env) { + let candidate = path.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn format_command(program: &str, args: &[String]) -> String { + let mut parts = vec![program.to_string()]; + parts.extend(args.iter().cloned()); + parts.join(" ") +} + +impl fmt::Display for DesktopPackageManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DesktopPackageManager::Apt => write!(f, "apt"), + DesktopPackageManager::Dnf => write!(f, "dnf"), + DesktopPackageManager::Apk => write!(f, "apk"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_platform_support_message_mentions_linux_and_supported_distros() { + let message = desktop_platform_support_message(); + assert!(message.contains("only supported on Linux")); + assert!(message.contains("Debian/Ubuntu (apt)")); + assert!(message.contains("Fedora/RHEL (dnf)")); + assert!(message.contains("Alpine (apk)")); + } + + #[test] + fn linux_install_support_message_mentions_unsupported_environments() { + let message = linux_install_support_message(); + assert!(message.contains("Debian/Ubuntu (apt)")); + assert!(message.contains("Fedora/RHEL (dnf)")); + assert!(message.contains("Alpine (apk)")); + assert!(message.contains("macOS")); + assert!(message.contains("Windows")); + assert!(message.contains("without apt, dnf, or apk")); + } + + #[test] + fn desktop_packages_support_no_fonts() { + let packages = desktop_packages(DesktopPackageManager::Apt, true); + assert!(!packages.iter().any(|value| value == "fonts-dejavu-core")); + assert!(packages.iter().any(|value| value == "xvfb")); + } + + #[test] + fn render_install_command_matches_package_manager() { + let packages = vec!["xvfb".to_string(), "openbox".to_string()]; + let command = render_install_command(DesktopPackageManager::Apk, false, &packages); + assert_eq!(command, "apk add --no-cache xvfb openbox"); + } +} diff --git a/server/packages/sandbox-agent/src/desktop_recording.rs b/server/packages/sandbox-agent/src/desktop_recording.rs new file mode 100644 index 0000000..39b174a --- /dev/null +++ b/server/packages/sandbox-agent/src/desktop_recording.rs @@ -0,0 +1,329 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use sandbox_agent_error::SandboxError; + +use crate::desktop_types::{ + DesktopRecordingInfo, DesktopRecordingListResponse, DesktopRecordingStartRequest, + DesktopRecordingStatus, DesktopResolution, +}; +use crate::process_runtime::{ + ProcessOwner, ProcessRuntime, ProcessStartSpec, ProcessStatus, RestartPolicy, +}; + +#[derive(Debug, Clone)] +pub struct DesktopRecordingContext { + pub display: String, + pub environment: std::collections::HashMap, + pub resolution: DesktopResolution, +} + +#[derive(Debug, Clone)] +pub struct DesktopRecordingManager { + process_runtime: Arc, + recordings_dir: PathBuf, + inner: Arc>, +} + +#[derive(Debug, Default)] +struct DesktopRecordingState { + next_id: u64, + current_id: Option, + recordings: BTreeMap, +} + +#[derive(Debug, Clone)] +struct RecordingEntry { + info: DesktopRecordingInfo, + path: PathBuf, +} + +impl DesktopRecordingManager { + pub fn new(process_runtime: Arc, state_dir: PathBuf) -> Self { + Self { + process_runtime, + recordings_dir: state_dir.join("recordings"), + inner: Arc::new(Mutex::new(DesktopRecordingState::default())), + } + } + + pub async fn start( + &self, + context: DesktopRecordingContext, + request: DesktopRecordingStartRequest, + ) -> Result { + if find_binary("ffmpeg").is_none() { + return Err(SandboxError::Conflict { + message: "ffmpeg is required for desktop recording".to_string(), + }); + } + + self.ensure_recordings_dir()?; + + { + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + if state.current_id.is_some() { + return Err(SandboxError::Conflict { + message: "a desktop recording is already active".to_string(), + }); + } + } + + let mut state = self.inner.lock().await; + let id_num = state.next_id + 1; + state.next_id = id_num; + let id = format!("rec_{id_num}"); + let file_name = format!("{id}.mp4"); + let path = self.recordings_dir.join(&file_name); + let fps = request.fps.unwrap_or(30).clamp(1, 60); + let args = vec![ + "-y".to_string(), + "-video_size".to_string(), + format!("{}x{}", context.resolution.width, context.resolution.height), + "-framerate".to_string(), + fps.to_string(), + "-f".to_string(), + "x11grab".to_string(), + "-i".to_string(), + context.display, + "-c:v".to_string(), + "libx264".to_string(), + "-preset".to_string(), + "ultrafast".to_string(), + "-pix_fmt".to_string(), + "yuv420p".to_string(), + path.to_string_lossy().to_string(), + ]; + let snapshot = self + .process_runtime + .start_process(ProcessStartSpec { + command: "ffmpeg".to_string(), + args, + cwd: None, + env: context.environment, + tty: false, + interactive: false, + owner: ProcessOwner::Desktop, + restart_policy: Some(RestartPolicy::Never), + }) + .await?; + + let info = DesktopRecordingInfo { + id: id.clone(), + status: DesktopRecordingStatus::Recording, + process_id: Some(snapshot.id), + file_name, + bytes: 0, + started_at: chrono::Utc::now().to_rfc3339(), + ended_at: None, + }; + state.current_id = Some(id.clone()); + state.recordings.insert( + id, + RecordingEntry { + info: info.clone(), + path, + }, + ); + Ok(info) + } + + pub async fn stop(&self) -> Result { + let (recording_id, process_id) = { + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + let recording_id = state + .current_id + .clone() + .ok_or_else(|| SandboxError::Conflict { + message: "no desktop recording is active".to_string(), + })?; + let process_id = state + .recordings + .get(&recording_id) + .and_then(|entry| entry.info.process_id.clone()); + (recording_id, process_id) + }; + + if let Some(process_id) = process_id { + let snapshot = self + .process_runtime + .stop_process(&process_id, Some(5_000)) + .await?; + if snapshot.status == ProcessStatus::Running { + let _ = self + .process_runtime + .kill_process(&process_id, Some(1_000)) + .await; + } + } + + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + let entry = state + .recordings + .get(&recording_id) + .ok_or_else(|| SandboxError::NotFound { + resource: "desktop_recording".to_string(), + id: recording_id.clone(), + })?; + Ok(entry.info.clone()) + } + + pub async fn list(&self) -> Result { + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + Ok(DesktopRecordingListResponse { + recordings: state + .recordings + .values() + .map(|entry| entry.info.clone()) + .collect(), + }) + } + + pub async fn get(&self, id: &str) -> Result { + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + state + .recordings + .get(id) + .map(|entry| entry.info.clone()) + .ok_or_else(|| SandboxError::NotFound { + resource: "desktop_recording".to_string(), + id: id.to_string(), + }) + } + + pub async fn download_path(&self, id: &str) -> Result { + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + let entry = state + .recordings + .get(id) + .ok_or_else(|| SandboxError::NotFound { + resource: "desktop_recording".to_string(), + id: id.to_string(), + })?; + if !entry.path.is_file() { + return Err(SandboxError::NotFound { + resource: "desktop_recording_file".to_string(), + id: id.to_string(), + }); + } + Ok(entry.path.clone()) + } + + pub async fn delete(&self, id: &str) -> Result<(), SandboxError> { + let mut state = self.inner.lock().await; + self.refresh_locked(&mut state).await?; + if state.current_id.as_deref() == Some(id) { + return Err(SandboxError::Conflict { + message: "stop the active desktop recording before deleting it".to_string(), + }); + } + let entry = state + .recordings + .remove(id) + .ok_or_else(|| SandboxError::NotFound { + resource: "desktop_recording".to_string(), + id: id.to_string(), + })?; + if entry.path.exists() { + fs::remove_file(&entry.path).map_err(|err| SandboxError::StreamError { + message: format!( + "failed to delete desktop recording {}: {err}", + entry.path.display() + ), + })?; + } + Ok(()) + } + + fn ensure_recordings_dir(&self) -> Result<(), SandboxError> { + fs::create_dir_all(&self.recordings_dir).map_err(|err| SandboxError::StreamError { + message: format!( + "failed to create desktop recordings dir {}: {err}", + self.recordings_dir.display() + ), + }) + } + + async fn refresh_locked(&self, state: &mut DesktopRecordingState) -> Result<(), SandboxError> { + let ids: Vec = state.recordings.keys().cloned().collect(); + for id in ids { + let should_clear_current = { + let Some(entry) = state.recordings.get_mut(&id) else { + continue; + }; + let Some(process_id) = entry.info.process_id.clone() else { + Self::refresh_bytes(entry); + continue; + }; + + let snapshot = match self.process_runtime.snapshot(&process_id).await { + Ok(snapshot) => snapshot, + Err(SandboxError::NotFound { .. }) => { + Self::finalize_entry(entry, false); + continue; + } + Err(err) => return Err(err), + }; + + if snapshot.status == ProcessStatus::Running { + Self::refresh_bytes(entry); + false + } else { + Self::finalize_entry(entry, snapshot.exit_code == Some(0)); + true + } + }; + + if should_clear_current && state.current_id.as_deref() == Some(id.as_str()) { + state.current_id = None; + } + } + + Ok(()) + } + + fn refresh_bytes(entry: &mut RecordingEntry) { + entry.info.bytes = file_size(&entry.path); + } + + fn finalize_entry(entry: &mut RecordingEntry, success: bool) { + let bytes = file_size(&entry.path); + entry.info.status = if success || (entry.path.is_file() && bytes > 0) { + DesktopRecordingStatus::Completed + } else { + DesktopRecordingStatus::Failed + }; + entry + .info + .ended_at + .get_or_insert_with(|| chrono::Utc::now().to_rfc3339()); + entry.info.bytes = bytes; + } +} + +fn find_binary(name: &str) -> Option { + let path_env = std::env::var_os("PATH")?; + for path in std::env::split_paths(&path_env) { + let candidate = path.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn file_size(path: &Path) -> u64 { + fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) +} diff --git a/server/packages/sandbox-agent/src/desktop_runtime.rs b/server/packages/sandbox-agent/src/desktop_runtime.rs new file mode 100644 index 0000000..2363dda --- /dev/null +++ b/server/packages/sandbox-agent/src/desktop_runtime.rs @@ -0,0 +1,2215 @@ +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::{Output, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; + +use sandbox_agent_error::SandboxError; + +use crate::desktop_errors::DesktopProblem; +use crate::desktop_install::desktop_platform_support_message; +use crate::desktop_recording::{DesktopRecordingContext, DesktopRecordingManager}; +use crate::desktop_streaming::DesktopStreamingManager; +use crate::desktop_types::{ + DesktopActionResponse, DesktopDisplayInfoResponse, DesktopErrorInfo, DesktopKeyModifiers, + DesktopKeyboardDownRequest, DesktopKeyboardPressRequest, DesktopKeyboardTypeRequest, + DesktopKeyboardUpRequest, DesktopMouseButton, DesktopMouseClickRequest, + DesktopMouseDownRequest, DesktopMouseDragRequest, DesktopMouseMoveRequest, + DesktopMousePositionResponse, DesktopMouseScrollRequest, DesktopMouseUpRequest, + DesktopProcessInfo, DesktopRecordingInfo, DesktopRecordingListResponse, + DesktopRecordingStartRequest, DesktopRegionScreenshotQuery, DesktopResolution, + DesktopScreenshotFormat, DesktopScreenshotQuery, DesktopStartRequest, DesktopState, + DesktopStatusResponse, DesktopStreamStatusResponse, DesktopWindowInfo, + DesktopWindowListResponse, +}; +use crate::process_runtime::{ + ProcessOwner, ProcessRuntime, ProcessStartSpec, ProcessStatus, RestartPolicy, +}; + +const DEFAULT_WIDTH: u32 = 1440; +const DEFAULT_HEIGHT: u32 = 900; +const DEFAULT_DPI: u32 = 96; +const DEFAULT_DISPLAY_NUM: i32 = 99; +const MAX_DISPLAY_PROBE: i32 = 10; +const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(10); +const INPUT_TIMEOUT: Duration = Duration::from_secs(5); +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; +const JPEG_SIGNATURE: &[u8] = b"\xff\xd8\xff"; +const WEBP_RIFF_SIGNATURE: &[u8] = b"RIFF"; +const WEBP_WEBP_SIGNATURE: &[u8] = b"WEBP"; + +#[derive(Debug, Clone)] +pub struct DesktopRuntime { + config: DesktopRuntimeConfig, + process_runtime: Arc, + recording_manager: DesktopRecordingManager, + streaming_manager: DesktopStreamingManager, + inner: Arc>, +} + +#[derive(Debug, Clone)] +pub struct DesktopRuntimeConfig { + state_dir: PathBuf, + display_num: i32, + assume_linux_for_tests: bool, +} + +#[derive(Debug)] +struct DesktopRuntimeStateData { + state: DesktopState, + display_num: i32, + display: Option, + resolution: Option, + started_at: Option, + last_error: Option, + missing_dependencies: Vec, + install_command: Option, + runtime_log_path: PathBuf, + environment: HashMap, + xvfb: Option, + openbox: Option, + dbus_pid: Option, +} + +#[derive(Debug)] +struct ManagedDesktopProcess { + name: &'static str, + process_id: String, + pid: Option, + running: bool, +} + +#[derive(Debug, Clone)] +struct DesktopReadyContext { + display: String, + environment: HashMap, + resolution: DesktopResolution, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DesktopScreenshotData { + pub bytes: Vec, + pub content_type: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct DesktopScreenshotOptions { + format: DesktopScreenshotFormat, + quality: u8, + scale: f32, +} + +impl Default for DesktopScreenshotOptions { + fn default() -> Self { + Self { + format: DesktopScreenshotFormat::Png, + quality: 85, + scale: 1.0, + } + } +} + +impl DesktopScreenshotOptions { + fn content_type(self) -> &'static str { + match self.format { + DesktopScreenshotFormat::Png => "image/png", + DesktopScreenshotFormat::Jpeg => "image/jpeg", + DesktopScreenshotFormat::Webp => "image/webp", + } + } + + fn output_arg(self) -> &'static str { + match self.format { + DesktopScreenshotFormat::Png => "png:-", + DesktopScreenshotFormat::Jpeg => "jpeg:-", + DesktopScreenshotFormat::Webp => "webp:-", + } + } + + fn needs_convert(self) -> bool { + self.format != DesktopScreenshotFormat::Png || (self.scale - 1.0).abs() > f32::EPSILON + } +} + +impl Default for DesktopRuntimeConfig { + fn default() -> Self { + let display_num = std::env::var("SANDBOX_AGENT_DESKTOP_DISPLAY_NUM") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_DISPLAY_NUM); + + let state_dir = std::env::var("SANDBOX_AGENT_DESKTOP_STATE_DIR") + .ok() + .map(PathBuf::from) + .unwrap_or_else(default_state_dir); + + let assume_linux_for_tests = std::env::var("SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX") + .ok() + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + Self { + state_dir, + display_num, + assume_linux_for_tests, + } + } +} + +impl DesktopRuntime { + pub fn new(process_runtime: Arc) -> Self { + Self::with_config(process_runtime, DesktopRuntimeConfig::default()) + } + + pub fn with_config(process_runtime: Arc, config: DesktopRuntimeConfig) -> Self { + let runtime_log_path = config.state_dir.join("desktop-runtime.log"); + let recording_manager = + DesktopRecordingManager::new(process_runtime.clone(), config.state_dir.clone()); + Self { + process_runtime, + recording_manager, + streaming_manager: DesktopStreamingManager::new(), + inner: Arc::new(Mutex::new(DesktopRuntimeStateData { + state: DesktopState::Inactive, + display_num: config.display_num, + display: None, + resolution: None, + started_at: None, + last_error: None, + missing_dependencies: Vec::new(), + install_command: None, + runtime_log_path, + environment: HashMap::new(), + xvfb: None, + openbox: None, + dbus_pid: None, + })), + config, + } + } + + pub async fn status(&self) -> DesktopStatusResponse { + let mut state = self.inner.lock().await; + self.refresh_status_locked(&mut state).await; + self.snapshot_locked(&state) + } + + pub async fn start( + &self, + request: DesktopStartRequest, + ) -> Result { + let mut state = self.inner.lock().await; + + if !self.platform_supported() { + let problem = DesktopProblem::unsupported_platform(desktop_platform_support_message()); + self.record_problem_locked(&mut state, &problem); + state.state = DesktopState::Failed; + return Err(problem); + } + + if matches!(state.state, DesktopState::Starting | DesktopState::Stopping) { + return Err(DesktopProblem::runtime_starting( + "Desktop runtime is busy transitioning state", + )); + } + + self.refresh_status_locked(&mut state).await; + if state.state == DesktopState::Active { + return Ok(self.snapshot_locked(&state)); + } + + if !state.missing_dependencies.is_empty() { + return Err(DesktopProblem::dependencies_missing( + state.missing_dependencies.clone(), + state.install_command.clone(), + self.processes_locked(&state), + )); + } + + self.ensure_state_dir_locked(&state).map_err(|err| { + DesktopProblem::runtime_failed(err, None, self.processes_locked(&state)) + })?; + self.write_runtime_log_locked(&state, "starting desktop runtime"); + + let width = request.width.unwrap_or(DEFAULT_WIDTH); + let height = request.height.unwrap_or(DEFAULT_HEIGHT); + let dpi = request.dpi.unwrap_or(DEFAULT_DPI); + validate_start_request(width, height, dpi)?; + + let display_num = self.choose_display_num()?; + let display = format!(":{display_num}"); + let resolution = DesktopResolution { + width, + height, + dpi: Some(dpi), + }; + let environment = self.base_environment(&display)?; + + state.state = DesktopState::Starting; + state.display_num = display_num; + state.display = Some(display.clone()); + state.resolution = Some(resolution.clone()); + state.started_at = None; + state.last_error = None; + state.environment = environment; + state.install_command = None; + + if let Err(problem) = self.start_dbus_locked(&mut state).await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + if let Err(problem) = self.start_xvfb_locked(&mut state, &resolution).await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + if let Err(problem) = self.wait_for_socket(display_num).await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + if let Err(problem) = self.start_openbox_locked(&mut state).await { + return Err(self.fail_start_locked(&mut state, problem).await); + } + + let ready = DesktopReadyContext { + display, + environment: state.environment.clone(), + resolution, + }; + + let display_info = match self.query_display_info_locked(&state, &ready).await { + Ok(display_info) => display_info, + Err(problem) => return Err(self.fail_start_locked(&mut state, problem).await), + }; + state.resolution = Some(display_info.resolution.clone()); + + let screenshot_options = DesktopScreenshotOptions::default(); + if let Err(problem) = self + .capture_screenshot_locked(&state, None, &screenshot_options) + .await + { + return Err(self.fail_start_locked(&mut state, problem).await); + } + + state.state = DesktopState::Active; + state.started_at = Some(chrono::Utc::now().to_rfc3339()); + state.last_error = None; + self.write_runtime_log_locked( + &state, + &format!( + "desktop runtime active on {} ({}x{}, dpi {})", + display_info.display, + display_info.resolution.width, + display_info.resolution.height, + display_info.resolution.dpi.unwrap_or(DEFAULT_DPI) + ), + ); + + Ok(self.snapshot_locked(&state)) + } + + pub async fn stop(&self) -> Result { + let mut state = self.inner.lock().await; + if matches!(state.state, DesktopState::Starting | DesktopState::Stopping) { + return Err(DesktopProblem::runtime_starting( + "Desktop runtime is busy transitioning state", + )); + } + + state.state = DesktopState::Stopping; + self.write_runtime_log_locked(&state, "stopping desktop runtime"); + let _ = self.recording_manager.stop().await; + let _ = self.streaming_manager.stop().await; + + self.stop_openbox_locked(&mut state).await; + self.stop_xvfb_locked(&mut state).await; + self.stop_dbus_locked(&mut state); + + state.state = DesktopState::Inactive; + state.display = None; + state.resolution = None; + state.started_at = None; + state.last_error = None; + state.missing_dependencies = self.detect_missing_dependencies(); + state.install_command = self.install_command_for(&state.missing_dependencies); + state.environment.clear(); + + Ok(self.snapshot_locked(&state)) + } + + pub async fn shutdown(&self) { + let _ = self.stop().await; + } + + pub async fn screenshot( + &self, + query: DesktopScreenshotQuery, + ) -> Result { + let options = screenshot_options(query.format, query.quality, query.scale)?; + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let bytes = self + .capture_screenshot_locked(&state, Some(&ready), &options) + .await?; + Ok(DesktopScreenshotData { + bytes, + content_type: options.content_type(), + }) + } + + pub async fn screenshot_region( + &self, + query: DesktopRegionScreenshotQuery, + ) -> Result { + validate_region(&query)?; + let options = screenshot_options(query.format, query.quality, query.scale)?; + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let crop = format!("{}x{}+{}+{}", query.width, query.height, query.x, query.y); + let bytes = self + .capture_screenshot_with_crop_locked(&state, &ready, &crop, &options) + .await?; + Ok(DesktopScreenshotData { + bytes, + content_type: options.content_type(), + }) + } + + pub async fn mouse_position(&self) -> Result { + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn move_mouse( + &self, + request: DesktopMouseMoveRequest, + ) -> Result { + validate_coordinates(request.x, request.y)?; + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let args = vec![ + "mousemove".to_string(), + request.x.to_string(), + request.y.to_string(), + ]; + self.run_input_command_locked(&state, &ready, args).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn click_mouse( + &self, + request: DesktopMouseClickRequest, + ) -> Result { + validate_coordinates(request.x, request.y)?; + let click_count = request.click_count.unwrap_or(1); + if click_count == 0 { + return Err(DesktopProblem::invalid_action( + "clickCount must be greater than 0", + )); + } + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let button = mouse_button_code(request.button.unwrap_or(DesktopMouseButton::Left)); + let mut args = vec![ + "mousemove".to_string(), + request.x.to_string(), + request.y.to_string(), + "click".to_string(), + ]; + if click_count > 1 { + args.push("--repeat".to_string()); + args.push(click_count.to_string()); + } + args.push(button.to_string()); + self.run_input_command_locked(&state, &ready, args).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn mouse_down( + &self, + request: DesktopMouseDownRequest, + ) -> Result { + let coordinates = validate_optional_coordinates(request.x, request.y)?; + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let button = mouse_button_code(request.button.unwrap_or(DesktopMouseButton::Left)); + let args = mouse_button_transition_args("mousedown", coordinates, button); + self.run_input_command_locked(&state, &ready, args).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn mouse_up( + &self, + request: DesktopMouseUpRequest, + ) -> Result { + let coordinates = validate_optional_coordinates(request.x, request.y)?; + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let button = mouse_button_code(request.button.unwrap_or(DesktopMouseButton::Left)); + let args = mouse_button_transition_args("mouseup", coordinates, button); + self.run_input_command_locked(&state, &ready, args).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn drag_mouse( + &self, + request: DesktopMouseDragRequest, + ) -> Result { + validate_coordinates(request.start_x, request.start_y)?; + validate_coordinates(request.end_x, request.end_y)?; + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let button = mouse_button_code(request.button.unwrap_or(DesktopMouseButton::Left)); + let args = vec![ + "mousemove".to_string(), + request.start_x.to_string(), + request.start_y.to_string(), + "mousedown".to_string(), + button.to_string(), + "mousemove".to_string(), + request.end_x.to_string(), + request.end_y.to_string(), + "mouseup".to_string(), + button.to_string(), + ]; + self.run_input_command_locked(&state, &ready, args).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn scroll_mouse( + &self, + request: DesktopMouseScrollRequest, + ) -> Result { + validate_coordinates(request.x, request.y)?; + let delta_x = request.delta_x.unwrap_or(0); + let delta_y = request.delta_y.unwrap_or(0); + if delta_x == 0 && delta_y == 0 { + return Err(DesktopProblem::invalid_action( + "deltaX or deltaY must be non-zero", + )); + } + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let mut args = vec![ + "mousemove".to_string(), + request.x.to_string(), + request.y.to_string(), + ]; + + append_scroll_clicks(&mut args, delta_y, 5, 4); + append_scroll_clicks(&mut args, delta_x, 7, 6); + + self.run_input_command_locked(&state, &ready, args).await?; + self.mouse_position_locked(&state, &ready).await + } + + pub async fn type_text( + &self, + request: DesktopKeyboardTypeRequest, + ) -> Result { + if request.text.is_empty() { + return Err(DesktopProblem::invalid_action("text must not be empty")); + } + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let args = type_text_args(request.text, request.delay_ms.unwrap_or(10)); + self.run_input_command_locked(&state, &ready, args).await?; + Ok(DesktopActionResponse { ok: true }) + } + + pub async fn press_key( + &self, + request: DesktopKeyboardPressRequest, + ) -> Result { + if request.key.trim().is_empty() { + return Err(DesktopProblem::invalid_action("key must not be empty")); + } + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let args = press_key_args(request.key, request.modifiers); + self.run_input_command_locked(&state, &ready, args).await?; + Ok(DesktopActionResponse { ok: true }) + } + + pub async fn key_down( + &self, + request: DesktopKeyboardDownRequest, + ) -> Result { + if request.key.trim().is_empty() { + return Err(DesktopProblem::invalid_action("key must not be empty")); + } + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let args = key_transition_args("keydown", request.key); + self.run_input_command_locked(&state, &ready, args).await?; + Ok(DesktopActionResponse { ok: true }) + } + + pub async fn key_up( + &self, + request: DesktopKeyboardUpRequest, + ) -> Result { + if request.key.trim().is_empty() { + return Err(DesktopProblem::invalid_action("key must not be empty")); + } + + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let args = key_transition_args("keyup", request.key); + self.run_input_command_locked(&state, &ready, args).await?; + Ok(DesktopActionResponse { ok: true }) + } + + pub async fn display_info(&self) -> Result { + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + self.query_display_info_locked(&state, &ready).await + } + + pub async fn list_windows(&self) -> Result { + let mut state = self.inner.lock().await; + let ready = self.ensure_ready_locked(&mut state).await?; + let active_window_id = self.active_window_id_locked(&state, &ready).await?; + let window_ids = self.window_ids_locked(&state, &ready).await?; + let mut windows = Vec::with_capacity(window_ids.len()); + for window_id in window_ids { + let title = self.window_title_locked(&state, &ready, &window_id).await?; + let (x, y, width, height) = self + .window_geometry_locked(&state, &ready, &window_id) + .await?; + windows.push(DesktopWindowInfo { + id: window_id.clone(), + title, + x, + y, + width, + height, + is_active: active_window_id + .as_deref() + .map(|active| active == window_id) + .unwrap_or(false), + }); + } + Ok(DesktopWindowListResponse { windows }) + } + + pub async fn start_recording( + &self, + request: DesktopRecordingStartRequest, + ) -> Result { + let context = self.recording_context().await?; + self.recording_manager.start(context, request).await + } + + pub async fn stop_recording(&self) -> Result { + self.recording_manager.stop().await + } + + pub async fn list_recordings(&self) -> Result { + self.recording_manager.list().await + } + + pub async fn get_recording(&self, id: &str) -> Result { + self.recording_manager.get(id).await + } + + pub async fn recording_download_path(&self, id: &str) -> Result { + self.recording_manager.download_path(id).await + } + + pub async fn delete_recording(&self, id: &str) -> Result<(), SandboxError> { + self.recording_manager.delete(id).await + } + + pub async fn start_streaming(&self) -> DesktopStreamStatusResponse { + self.streaming_manager.start().await + } + + pub async fn stop_streaming(&self) -> DesktopStreamStatusResponse { + self.streaming_manager.stop().await + } + + pub async fn ensure_streaming_active(&self) -> Result<(), SandboxError> { + self.streaming_manager.ensure_active().await + } + + async fn recording_context(&self) -> Result { + let mut state = self.inner.lock().await; + let ready = self + .ensure_ready_locked(&mut state) + .await + .map_err(desktop_problem_to_sandbox_error)?; + Ok(DesktopRecordingContext { + display: ready.display, + environment: ready.environment, + resolution: ready.resolution, + }) + } + + async fn ensure_ready_locked( + &self, + state: &mut DesktopRuntimeStateData, + ) -> Result { + self.refresh_status_locked(state).await; + match state.state { + DesktopState::Active => { + let display = state.display.clone().ok_or_else(|| { + DesktopProblem::runtime_failed( + "Desktop runtime has no active display", + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + let resolution = state.resolution.clone().ok_or_else(|| { + DesktopProblem::runtime_failed( + "Desktop runtime has no active resolution", + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + Ok(DesktopReadyContext { + display, + environment: state.environment.clone(), + resolution, + }) + } + DesktopState::InstallRequired => Err(DesktopProblem::dependencies_missing( + state.missing_dependencies.clone(), + state.install_command.clone(), + self.processes_locked(state), + )), + DesktopState::Inactive => Err(DesktopProblem::runtime_inactive( + "Desktop runtime has not been started", + )), + DesktopState::Starting | DesktopState::Stopping => Err( + DesktopProblem::runtime_starting("Desktop runtime is still transitioning"), + ), + DesktopState::Failed => Err(DesktopProblem::runtime_failed( + state + .last_error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| "Desktop runtime is unhealthy".to_string()), + state.install_command.clone(), + self.processes_locked(state), + )), + } + } + + async fn refresh_status_locked(&self, state: &mut DesktopRuntimeStateData) { + let missing_dependencies = if self.platform_supported() { + self.detect_missing_dependencies() + } else { + Vec::new() + }; + state.missing_dependencies = missing_dependencies.clone(); + state.install_command = self.install_command_for(&missing_dependencies); + + if !self.platform_supported() { + state.state = DesktopState::Failed; + state.last_error = Some( + DesktopProblem::unsupported_platform(desktop_platform_support_message()) + .to_error_info(), + ); + return; + } + + if !missing_dependencies.is_empty() { + state.state = DesktopState::InstallRequired; + state.last_error = Some( + DesktopProblem::dependencies_missing( + missing_dependencies, + state.install_command.clone(), + self.processes_locked(state), + ) + .to_error_info(), + ); + return; + } + + if matches!( + state.state, + DesktopState::Inactive | DesktopState::Starting | DesktopState::Stopping + ) { + if state.state == DesktopState::Inactive { + state.last_error = None; + } + return; + } + + if state.state == DesktopState::Failed + && state.display.is_none() + && state.xvfb.is_none() + && state.openbox.is_none() + && state.dbus_pid.is_none() + { + return; + } + + let Some(display) = state.display.clone() else { + state.state = DesktopState::Failed; + state.last_error = Some( + DesktopProblem::runtime_failed( + "Desktop runtime has no display", + None, + self.processes_locked(state), + ) + .to_error_info(), + ); + return; + }; + + if let Err(problem) = self.ensure_process_running_locked(state, "Xvfb").await { + self.record_problem_locked(state, &problem); + state.state = DesktopState::Failed; + return; + } + if let Err(problem) = self.ensure_process_running_locked(state, "openbox").await { + self.record_problem_locked(state, &problem); + state.state = DesktopState::Failed; + return; + } + + if !socket_path(state.display_num).exists() { + let problem = DesktopProblem::runtime_failed( + format!("X socket for display {display} is missing"), + state.install_command.clone(), + self.processes_locked(state), + ); + self.record_problem_locked(state, &problem); + state.state = DesktopState::Failed; + return; + } + + let ready = DesktopReadyContext { + display, + environment: state.environment.clone(), + resolution: state.resolution.clone().unwrap_or(DesktopResolution { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + dpi: Some(DEFAULT_DPI), + }), + }; + + match self.query_display_info_locked(state, &ready).await { + Ok(display_info) => { + state.resolution = Some(display_info.resolution); + } + Err(problem) => { + self.record_problem_locked(state, &problem); + state.state = DesktopState::Failed; + return; + } + } + + let screenshot_options = DesktopScreenshotOptions::default(); + if let Err(problem) = self + .capture_screenshot_locked(state, Some(&ready), &screenshot_options) + .await + { + self.record_problem_locked(state, &problem); + state.state = DesktopState::Failed; + return; + } + + state.state = DesktopState::Active; + state.last_error = None; + } + + async fn ensure_process_running_locked( + &self, + state: &mut DesktopRuntimeStateData, + name: &str, + ) -> Result<(), DesktopProblem> { + let process_id = match name { + "Xvfb" => state + .xvfb + .as_ref() + .map(|process| process.process_id.clone()), + "openbox" => state + .openbox + .as_ref() + .map(|process| process.process_id.clone()), + _ => None, + }; + + let Some(process_id) = process_id else { + return Err(DesktopProblem::runtime_failed( + format!("{name} is not running"), + state.install_command.clone(), + self.processes_locked(state), + )); + }; + + let snapshot = self + .process_runtime + .snapshot(&process_id) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to inspect {name}: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + + if let Some(process) = match name { + "Xvfb" => state.xvfb.as_mut(), + "openbox" => state.openbox.as_mut(), + _ => None, + } { + process.pid = snapshot.pid; + process.running = snapshot.status == ProcessStatus::Running; + } + + if snapshot.status == ProcessStatus::Running { + return Ok(()); + } + + self.write_runtime_log_locked(state, &format!("{name} exited; attempting restart")); + match name { + "Xvfb" => { + let resolution = state.resolution.clone().ok_or_else(|| { + DesktopProblem::runtime_failed( + "desktop resolution missing during Xvfb restart", + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + state.xvfb = None; + self.start_xvfb_locked(state, &resolution).await?; + } + "openbox" => { + state.openbox = None; + self.start_openbox_locked(state).await?; + } + _ => {} + } + + let restarted_snapshot = self + .process_runtime + .snapshot(match name { + "Xvfb" => state + .xvfb + .as_ref() + .map(|process| process.process_id.as_str()) + .unwrap_or_default(), + "openbox" => state + .openbox + .as_ref() + .map(|process| process.process_id.as_str()) + .unwrap_or_default(), + _ => "", + }) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to inspect restarted {name}: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + if restarted_snapshot.status == ProcessStatus::Running { + Ok(()) + } else { + Err(DesktopProblem::runtime_failed( + format!("{name} exited with status {:?}", snapshot.exit_code), + state.install_command.clone(), + self.processes_locked(state), + )) + } + } + + async fn start_dbus_locked( + &self, + state: &mut DesktopRuntimeStateData, + ) -> Result<(), DesktopProblem> { + if find_binary("dbus-launch").is_none() { + self.write_runtime_log_locked( + state, + "dbus-launch not found; continuing without D-Bus session", + ); + return Ok(()); + } + + let output = run_command_output("dbus-launch", &[], &state.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to launch dbus-launch: {err}"), + None, + self.processes_locked(state), + ) + })?; + + if !output.status.success() { + self.write_runtime_log_locked( + state, + &format!( + "dbus-launch failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + ); + return Ok(()); + } + + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((key, value)) = line.split_once('=') { + let cleaned = value.trim().trim_end_matches(';').to_string(); + if key == "DBUS_SESSION_BUS_ADDRESS" { + state.environment.insert(key.to_string(), cleaned); + } else if key == "DBUS_SESSION_BUS_PID" { + state.dbus_pid = cleaned.parse::().ok(); + } + } + } + + Ok(()) + } + + async fn start_xvfb_locked( + &self, + state: &mut DesktopRuntimeStateData, + resolution: &DesktopResolution, + ) -> Result<(), DesktopProblem> { + let Some(display) = state.display.clone() else { + return Err(DesktopProblem::runtime_failed( + "Desktop display was not configured before starting Xvfb", + None, + self.processes_locked(state), + )); + }; + let args = vec![ + display, + "-screen".to_string(), + "0".to_string(), + format!("{}x{}x24", resolution.width, resolution.height), + "-dpi".to_string(), + resolution.dpi.unwrap_or(DEFAULT_DPI).to_string(), + "-nolisten".to_string(), + "tcp".to_string(), + ]; + let snapshot = self + .process_runtime + .start_process(ProcessStartSpec { + command: "Xvfb".to_string(), + args, + cwd: None, + env: state.environment.clone(), + tty: false, + interactive: false, + owner: ProcessOwner::Desktop, + restart_policy: Some(RestartPolicy::Always), + }) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to start Xvfb: {err}"), + None, + self.processes_locked(state), + ) + })?; + state.xvfb = Some(ManagedDesktopProcess { + name: "Xvfb", + process_id: snapshot.id, + pid: snapshot.pid, + running: snapshot.status == ProcessStatus::Running, + }); + Ok(()) + } + + async fn start_openbox_locked( + &self, + state: &mut DesktopRuntimeStateData, + ) -> Result<(), DesktopProblem> { + let snapshot = self + .process_runtime + .start_process(ProcessStartSpec { + command: "openbox".to_string(), + args: Vec::new(), + cwd: None, + env: state.environment.clone(), + tty: false, + interactive: false, + owner: ProcessOwner::Desktop, + restart_policy: Some(RestartPolicy::Always), + }) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to start openbox: {err}"), + None, + self.processes_locked(state), + ) + })?; + state.openbox = Some(ManagedDesktopProcess { + name: "openbox", + process_id: snapshot.id, + pid: snapshot.pid, + running: snapshot.status == ProcessStatus::Running, + }); + Ok(()) + } + + async fn stop_xvfb_locked(&self, state: &mut DesktopRuntimeStateData) { + if let Some(process) = state.xvfb.take() { + self.write_runtime_log_locked(state, "stopping Xvfb"); + let _ = self + .process_runtime + .stop_process(&process.process_id, Some(2_000)) + .await; + if self + .process_runtime + .snapshot(&process.process_id) + .await + .ok() + .is_some_and(|snapshot| snapshot.status == ProcessStatus::Running) + { + let _ = self + .process_runtime + .kill_process(&process.process_id, Some(1_000)) + .await; + } + } + } + + async fn stop_openbox_locked(&self, state: &mut DesktopRuntimeStateData) { + if let Some(process) = state.openbox.take() { + self.write_runtime_log_locked(state, "stopping openbox"); + let _ = self + .process_runtime + .stop_process(&process.process_id, Some(2_000)) + .await; + if self + .process_runtime + .snapshot(&process.process_id) + .await + .ok() + .is_some_and(|snapshot| snapshot.status == ProcessStatus::Running) + { + let _ = self + .process_runtime + .kill_process(&process.process_id, Some(1_000)) + .await; + } + } + } + + fn stop_dbus_locked(&self, state: &mut DesktopRuntimeStateData) { + if let Some(pid) = state.dbus_pid.take() { + #[cfg(unix)] + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + } + + async fn fail_start_locked( + &self, + state: &mut DesktopRuntimeStateData, + problem: DesktopProblem, + ) -> DesktopProblem { + self.record_problem_locked(state, &problem); + self.write_runtime_log_locked(state, "desktop runtime startup failed; cleaning up"); + self.stop_openbox_locked(state).await; + self.stop_xvfb_locked(state).await; + self.stop_dbus_locked(state); + state.state = DesktopState::Failed; + state.display = None; + state.resolution = None; + state.started_at = None; + state.environment.clear(); + problem + } + + async fn capture_screenshot_locked( + &self, + state: &DesktopRuntimeStateData, + ready: Option<&DesktopReadyContext>, + options: &DesktopScreenshotOptions, + ) -> Result, DesktopProblem> { + match ready { + Some(ready) => { + self.capture_screenshot_with_crop_locked(state, ready, "", options) + .await + } + None => { + let ready = DesktopReadyContext { + display: state + .display + .clone() + .unwrap_or_else(|| format!(":{}", state.display_num)), + environment: state.environment.clone(), + resolution: state.resolution.clone().unwrap_or(DesktopResolution { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + dpi: Some(DEFAULT_DPI), + }), + }; + self.capture_screenshot_with_crop_locked(state, &ready, "", options) + .await + } + } + } + + async fn capture_screenshot_with_crop_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + crop: &str, + options: &DesktopScreenshotOptions, + ) -> Result, DesktopProblem> { + let mut args = vec!["-window".to_string(), "root".to_string()]; + if !crop.is_empty() { + args.push("-crop".to_string()); + args.push(crop.to_string()); + } + args.push("png:-".to_string()); + + let output = run_command_output("import", &args, &ready.environment, SCREENSHOT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::screenshot_failed( + format!("failed to capture desktop screenshot: {err}"), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + return Err(DesktopProblem::screenshot_failed( + format!( + "desktop screenshot command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + self.processes_locked(state), + )); + } + let bytes = maybe_convert_screenshot(output.stdout, options, &ready.environment) + .await + .map_err(|message| { + DesktopProblem::screenshot_failed(message, self.processes_locked(state)) + })?; + validate_image_bytes(&bytes, options.format).map_err(|message| { + DesktopProblem::screenshot_failed(message, self.processes_locked(state)) + })?; + Ok(bytes) + } + + async fn active_window_id_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + ) -> Result, DesktopProblem> { + let args = vec!["getactivewindow".to_string()]; + let output = run_command_output("xdotool", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to query active window: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + if output.status.code() == Some(1) && output.stdout.is_empty() { + return Ok(None); + } + return Err(DesktopProblem::runtime_failed( + format!( + "active window query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + state.install_command.clone(), + self.processes_locked(state), + )); + } + let window_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if window_id.is_empty() { + Ok(None) + } else { + Ok(Some(window_id)) + } + } + + async fn window_ids_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + ) -> Result, DesktopProblem> { + let args = vec![ + "search".to_string(), + "--onlyvisible".to_string(), + "--name".to_string(), + "".to_string(), + ]; + let output = run_command_output("xdotool", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to list desktop windows: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + if output.status.code() == Some(1) && output.stdout.is_empty() { + return Ok(Vec::new()); + } + return Err(DesktopProblem::runtime_failed( + format!( + "desktop window listing failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + state.install_command.clone(), + self.processes_locked(state), + )); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) + } + + async fn window_title_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + window_id: &str, + ) -> Result { + let args = vec!["getwindowname".to_string(), window_id.to_string()]; + let output = run_command_output("xdotool", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to query window title: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + return Err(DesktopProblem::runtime_failed( + format!( + "window title query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + state.install_command.clone(), + self.processes_locked(state), + )); + } + Ok(String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string()) + } + + async fn window_geometry_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + window_id: &str, + ) -> Result<(i32, i32, u32, u32), DesktopProblem> { + let args = vec!["getwindowgeometry".to_string(), window_id.to_string()]; + let output = run_command_output("xdotool", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to query window geometry: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + return Err(DesktopProblem::runtime_failed( + format!( + "window geometry query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + state.install_command.clone(), + self.processes_locked(state), + )); + } + parse_window_geometry(&output.stdout).map_err(|message| { + DesktopProblem::runtime_failed( + message, + state.install_command.clone(), + self.processes_locked(state), + ) + }) + } + + async fn mouse_position_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + ) -> Result { + let args = vec!["getmouselocation".to_string(), "--shell".to_string()]; + let output = run_command_output("xdotool", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::input_failed( + format!("failed to query mouse position: {err}"), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + return Err(DesktopProblem::input_failed( + format!( + "mouse position command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + self.processes_locked(state), + )); + } + parse_mouse_position(&output.stdout) + .map_err(|message| DesktopProblem::input_failed(message, self.processes_locked(state))) + } + + async fn run_input_command_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + args: Vec, + ) -> Result<(), DesktopProblem> { + let output = run_command_output("xdotool", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::input_failed( + format!("failed to execute desktop input command: {err}"), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + return Err(DesktopProblem::input_failed( + format!( + "desktop input command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + self.processes_locked(state), + )); + } + Ok(()) + } + + async fn query_display_info_locked( + &self, + state: &DesktopRuntimeStateData, + ready: &DesktopReadyContext, + ) -> Result { + let args = vec!["--current".to_string()]; + let output = run_command_output("xrandr", &args, &ready.environment, INPUT_TIMEOUT) + .await + .map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to query display info: {err}"), + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + if !output.status.success() { + return Err(DesktopProblem::runtime_failed( + format!( + "display query failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + state.install_command.clone(), + self.processes_locked(state), + )); + } + let resolution = parse_xrandr_resolution(&output.stdout).map_err(|message| { + DesktopProblem::runtime_failed( + message, + state.install_command.clone(), + self.processes_locked(state), + ) + })?; + Ok(DesktopDisplayInfoResponse { + display: ready.display.clone(), + resolution: DesktopResolution { + dpi: ready.resolution.dpi, + ..resolution + }, + }) + } + + fn detect_missing_dependencies(&self) -> Vec { + let mut missing = Vec::new(); + for (name, binary) in [ + ("Xvfb", "Xvfb"), + ("openbox", "openbox"), + ("xdotool", "xdotool"), + ("import", "import"), + ("xrandr", "xrandr"), + ] { + if find_binary(binary).is_none() { + missing.push(name.to_string()); + } + } + missing + } + + fn install_command_for(&self, missing_dependencies: &[String]) -> Option { + if !self.platform_supported() || missing_dependencies.is_empty() { + None + } else { + Some("sandbox-agent install desktop --yes".to_string()) + } + } + + fn platform_supported(&self) -> bool { + cfg!(target_os = "linux") || self.config.assume_linux_for_tests + } + + fn choose_display_num(&self) -> Result { + for offset in 0..MAX_DISPLAY_PROBE { + let candidate = self.config.display_num + offset; + if !socket_path(candidate).exists() { + return Ok(candidate); + } + } + Err(DesktopProblem::runtime_failed( + "unable to find an available X display starting at :99", + None, + Vec::new(), + )) + } + + fn base_environment(&self, display: &str) -> Result, DesktopProblem> { + let mut environment = HashMap::new(); + environment.insert("DISPLAY".to_string(), display.to_string()); + environment.insert( + "HOME".to_string(), + self.config + .state_dir + .join("home") + .to_string_lossy() + .to_string(), + ); + environment.insert( + "USER".to_string(), + std::env::var("USER").unwrap_or_else(|_| "sandbox-agent".to_string()), + ); + environment.insert( + "PATH".to_string(), + std::env::var("PATH").unwrap_or_default(), + ); + fs::create_dir_all(self.config.state_dir.join("home")).map_err(|err| { + DesktopProblem::runtime_failed( + format!("failed to create desktop home: {err}"), + None, + Vec::new(), + ) + })?; + Ok(environment) + } + + async fn wait_for_socket(&self, display_num: i32) -> Result<(), DesktopProblem> { + let socket = socket_path(display_num); + let parent = socket + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("/tmp/.X11-unix")); + let _ = fs::create_dir_all(parent); + + let start = tokio::time::Instant::now(); + while start.elapsed() < STARTUP_TIMEOUT { + if socket.exists() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Err(DesktopProblem::runtime_failed( + format!("timed out waiting for X socket {}", socket.display()), + None, + Vec::new(), + )) + } + + fn snapshot_locked(&self, state: &DesktopRuntimeStateData) -> DesktopStatusResponse { + DesktopStatusResponse { + state: state.state, + display: state.display.clone(), + resolution: state.resolution.clone(), + started_at: state.started_at.clone(), + last_error: state.last_error.clone(), + missing_dependencies: state.missing_dependencies.clone(), + install_command: state.install_command.clone(), + processes: self.processes_locked(state), + runtime_log_path: Some(state.runtime_log_path.to_string_lossy().to_string()), + } + } + + fn processes_locked(&self, state: &DesktopRuntimeStateData) -> Vec { + let mut processes = Vec::new(); + if let Some(process) = state.xvfb.as_ref() { + processes.push(DesktopProcessInfo { + name: process.name.to_string(), + pid: process.pid, + running: process.running, + log_path: None, + }); + } + if let Some(process) = state.openbox.as_ref() { + processes.push(DesktopProcessInfo { + name: process.name.to_string(), + pid: process.pid, + running: process.running, + log_path: None, + }); + } + if let Some(pid) = state.dbus_pid { + processes.push(DesktopProcessInfo { + name: "dbus".to_string(), + pid: Some(pid), + running: process_exists(pid), + log_path: None, + }); + } + processes + } + + fn record_problem_locked(&self, state: &mut DesktopRuntimeStateData, problem: &DesktopProblem) { + state.last_error = Some(problem.to_error_info()); + self.write_runtime_log_locked( + state, + &format!("{}: {}", problem.code(), problem.to_error_info().message), + ); + } + + fn ensure_state_dir_locked(&self, state: &DesktopRuntimeStateData) -> Result<(), String> { + fs::create_dir_all(&self.config.state_dir).map_err(|err| { + format!( + "failed to create desktop state dir {}: {err}", + self.config.state_dir.display() + ) + })?; + if let Some(parent) = state.runtime_log_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create runtime log dir {}: {err}", + parent.display() + ) + })?; + } + Ok(()) + } + + fn write_runtime_log_locked(&self, state: &DesktopRuntimeStateData, message: &str) { + if let Some(parent) = state.runtime_log_path.parent() { + let _ = fs::create_dir_all(parent); + } + let line = format!("{} {}\n", chrono::Utc::now().to_rfc3339(), message); + let _ = OpenOptions::new() + .create(true) + .append(true) + .open(&state.runtime_log_path) + .and_then(|mut file| std::io::Write::write_all(&mut file, line.as_bytes())); + } +} + +fn desktop_problem_to_sandbox_error(problem: DesktopProblem) -> SandboxError { + SandboxError::Conflict { + message: problem.to_error_info().message, + } +} + +fn default_state_dir() -> PathBuf { + if let Ok(value) = std::env::var("XDG_STATE_HOME") { + return PathBuf::from(value).join("sandbox-agent").join("desktop"); + } + if let Some(home) = dirs::home_dir() { + return home + .join(".local") + .join("state") + .join("sandbox-agent") + .join("desktop"); + } + std::env::temp_dir().join("sandbox-agent-desktop") +} + +fn socket_path(display_num: i32) -> PathBuf { + PathBuf::from(format!("/tmp/.X11-unix/X{display_num}")) +} + +fn find_binary(name: &str) -> Option { + let path_env = std::env::var_os("PATH")?; + for path in std::env::split_paths(&path_env) { + let candidate = path.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +async fn run_command_output( + command: &str, + args: &[String], + environment: &HashMap, + timeout: Duration, +) -> Result { + run_command_output_with_optional_stdin(command, args, environment, timeout, None).await +} + +async fn run_command_output_with_stdin( + command: &str, + args: &[String], + environment: &HashMap, + timeout: Duration, + stdin_bytes: Vec, +) -> Result { + run_command_output_with_optional_stdin(command, args, environment, timeout, Some(stdin_bytes)) + .await +} + +async fn run_command_output_with_optional_stdin( + command: &str, + args: &[String], + environment: &HashMap, + timeout: Duration, + stdin_bytes: Option>, +) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut child = Command::new(command); + child.args(args); + child.envs(environment); + child.stdin(if stdin_bytes.is_some() { + Stdio::piped() + } else { + Stdio::null() + }); + child.stdout(Stdio::piped()); + child.stderr(Stdio::piped()); + + let mut child = child.spawn().map_err(|err| err.to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to capture child stdout".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "failed to capture child stderr".to_string())?; + + let stdin_task = if let Some(bytes) = stdin_bytes { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to capture child stdin".to_string())?; + Some(tokio::spawn(async move { + stdin.write_all(&bytes).await?; + stdin.shutdown().await + })) + } else { + None + }; + + let stdout_task = tokio::spawn(async move { + let mut stdout = stdout; + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).await.map(|_| bytes) + }); + let stderr_task = tokio::spawn(async move { + let mut stderr = stderr; + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).await.map(|_| bytes) + }); + + let status = match tokio::time::timeout(timeout, child.wait()).await { + Ok(result) => result.map_err(|err| err.to_string())?, + Err(_) => { + terminate_child(&mut child).await?; + if let Some(stdin_task) = stdin_task { + let _ = stdin_task.await; + } + let _ = stdout_task.await; + let _ = stderr_task.await; + return Err(format!("command timed out after {}s", timeout.as_secs())); + } + }; + + if let Some(stdin_task) = stdin_task { + stdin_task + .await + .map_err(|err| err.to_string())? + .map_err(|err| err.to_string())?; + } + + let stdout = stdout_task + .await + .map_err(|err| err.to_string())? + .map_err(|err| err.to_string())?; + let stderr = stderr_task + .await + .map_err(|err| err.to_string())? + .map_err(|err| err.to_string())?; + + Ok(Output { + status, + stdout, + stderr, + }) +} + +async fn terminate_child(child: &mut Child) -> Result<(), String> { + if let Ok(Some(_)) = child.try_wait() { + return Ok(()); + } + child.start_kill().map_err(|err| err.to_string())?; + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; + Ok(()) +} + +fn process_exists(pid: u32) -> bool { + #[cfg(unix)] + unsafe { + return libc::kill(pid as i32, 0) == 0 + || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH); + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +fn parse_xrandr_resolution(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + for line in text.lines() { + if let Some(index) = line.find(" current ") { + let tail = &line[index + " current ".len()..]; + let mut parts = tail.split(','); + if let Some(current) = parts.next() { + let dims: Vec<&str> = current.split_whitespace().collect(); + if dims.len() >= 3 { + let width = dims[0] + .parse::() + .map_err(|_| "failed to parse xrandr width".to_string())?; + let height = dims[2] + .parse::() + .map_err(|_| "failed to parse xrandr height".to_string())?; + return Ok(DesktopResolution { + width, + height, + dpi: None, + }); + } + } + } + } + Err("unable to parse xrandr current resolution".to_string()) +} + +fn parse_mouse_position(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut x = None; + let mut y = None; + let mut screen = None; + let mut window = None; + for line in text.lines() { + if let Some((key, value)) = line.split_once('=') { + match key { + "X" => x = value.parse::().ok(), + "Y" => y = value.parse::().ok(), + "SCREEN" => screen = value.parse::().ok(), + "WINDOW" => window = Some(value.to_string()), + _ => {} + } + } + } + match (x, y) { + (Some(x), Some(y)) => Ok(DesktopMousePositionResponse { + x, + y, + screen, + window, + }), + _ => Err("unable to parse xdotool mouse position".to_string()), + } +} + +fn type_text_args(text: String, delay_ms: u32) -> Vec { + vec![ + "type".to_string(), + "--delay".to_string(), + delay_ms.to_string(), + "--".to_string(), + text, + ] +} + +fn press_key_args(key: String, modifiers: Option) -> Vec { + vec![ + "key".to_string(), + "--".to_string(), + key_with_modifiers(key, modifiers), + ] +} + +fn key_transition_args(command: &str, key: String) -> Vec { + vec![command.to_string(), "--".to_string(), key] +} + +fn key_with_modifiers(key: String, modifiers: Option) -> String { + let Some(modifiers) = modifiers else { + return key; + }; + + let mut parts = Vec::new(); + if modifiers.ctrl == Some(true) { + parts.push("ctrl"); + } + if modifiers.shift == Some(true) { + parts.push("shift"); + } + if modifiers.alt == Some(true) { + parts.push("alt"); + } + if modifiers.cmd == Some(true) { + parts.push("super"); + } + parts.push(key.as_str()); + parts.join("+") +} + +fn mouse_button_transition_args( + command: &str, + coordinates: Option<(i32, i32)>, + button: u8, +) -> Vec { + let mut args = Vec::new(); + if let Some((x, y)) = coordinates { + args.push("mousemove".to_string()); + args.push(x.to_string()); + args.push(y.to_string()); + } + args.push(command.to_string()); + args.push(button.to_string()); + args +} + +fn screenshot_options( + format: Option, + quality: Option, + scale: Option, +) -> Result { + let quality = quality.unwrap_or(85); + if !(1..=100).contains(&quality) { + return Err(DesktopProblem::invalid_action( + "quality must be between 1 and 100", + )); + } + + let scale = scale.unwrap_or(1.0); + if !(0.1..=1.0).contains(&scale) { + return Err(DesktopProblem::invalid_action( + "scale must be between 0.1 and 1.0", + )); + } + + Ok(DesktopScreenshotOptions { + format: format.unwrap_or(DesktopScreenshotFormat::Png), + quality, + scale, + }) +} + +async fn maybe_convert_screenshot( + bytes: Vec, + options: &DesktopScreenshotOptions, + environment: &HashMap, +) -> Result, String> { + if !options.needs_convert() { + return Ok(bytes); + } + + let mut args = vec!["png:-".to_string()]; + if (options.scale - 1.0).abs() > f32::EPSILON { + args.push("-resize".to_string()); + args.push(format!("{:.2}%", options.scale * 100.0)); + } + if options.format != DesktopScreenshotFormat::Png { + args.push("-quality".to_string()); + args.push(options.quality.to_string()); + } + args.push(options.output_arg().to_string()); + + let output = + run_command_output_with_stdin("convert", &args, environment, SCREENSHOT_TIMEOUT, bytes) + .await?; + if !output.status.success() { + return Err(format!( + "desktop screenshot conversion failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(output.stdout) +} + +fn validate_image_bytes(bytes: &[u8], format: DesktopScreenshotFormat) -> Result<(), String> { + match format { + DesktopScreenshotFormat::Png => { + if bytes.len() < PNG_SIGNATURE.len() || &bytes[..PNG_SIGNATURE.len()] != PNG_SIGNATURE { + return Err("desktop screenshot did not return PNG bytes".to_string()); + } + } + DesktopScreenshotFormat::Jpeg => { + if bytes.len() < JPEG_SIGNATURE.len() + || &bytes[..JPEG_SIGNATURE.len()] != JPEG_SIGNATURE + { + return Err("desktop screenshot did not return JPEG bytes".to_string()); + } + } + DesktopScreenshotFormat::Webp => { + if bytes.len() < 12 + || &bytes[..WEBP_RIFF_SIGNATURE.len()] != WEBP_RIFF_SIGNATURE + || &bytes[8..12] != WEBP_WEBP_SIGNATURE + { + return Err("desktop screenshot did not return WebP bytes".to_string()); + } + } + } + Ok(()) +} + +fn validate_start_request(width: u32, height: u32, dpi: u32) -> Result<(), DesktopProblem> { + if width == 0 || height == 0 { + return Err(DesktopProblem::invalid_action( + "Desktop width and height must be greater than 0", + )); + } + if dpi == 0 { + return Err(DesktopProblem::invalid_action( + "Desktop dpi must be greater than 0", + )); + } + Ok(()) +} + +fn validate_region(query: &DesktopRegionScreenshotQuery) -> Result<(), DesktopProblem> { + validate_coordinates(query.x, query.y)?; + if query.width == 0 || query.height == 0 { + return Err(DesktopProblem::invalid_action( + "Screenshot region width and height must be greater than 0", + )); + } + Ok(()) +} + +fn validate_optional_coordinates( + x: Option, + y: Option, +) -> Result, DesktopProblem> { + match (x, y) { + (Some(x), Some(y)) => { + validate_coordinates(x, y)?; + Ok(Some((x, y))) + } + (None, None) => Ok(None), + _ => Err(DesktopProblem::invalid_action( + "x and y must both be provided when setting coordinates", + )), + } +} + +fn validate_coordinates(x: i32, y: i32) -> Result<(), DesktopProblem> { + if x < 0 || y < 0 { + return Err(DesktopProblem::invalid_action( + "Desktop coordinates must be non-negative", + )); + } + Ok(()) +} + +fn mouse_button_code(button: DesktopMouseButton) -> u8 { + match button { + DesktopMouseButton::Left => 1, + DesktopMouseButton::Middle => 2, + DesktopMouseButton::Right => 3, + } +} + +fn append_scroll_clicks( + args: &mut Vec, + delta: i32, + positive_button: u8, + negative_button: u8, +) { + if delta == 0 { + return; + } + let button = if delta > 0 { + positive_button + } else { + negative_button + }; + let repeat = delta.unsigned_abs(); + args.push("click".to_string()); + if repeat > 1 { + args.push("--repeat".to_string()); + args.push(repeat.to_string()); + } + args.push(button.to_string()); +} + +fn parse_window_geometry(bytes: &[u8]) -> Result<(i32, i32, u32, u32), String> { + let text = String::from_utf8_lossy(bytes); + let mut position = None; + let mut geometry = None; + for line in text.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("Position:") { + let coordinate_text = value + .trim() + .split_whitespace() + .next() + .ok_or_else(|| "unable to parse window position".to_string())?; + let (x, y) = coordinate_text + .split_once(',') + .ok_or_else(|| "unable to parse window position".to_string())?; + let x = x + .parse::() + .map_err(|_| "failed to parse window x coordinate".to_string())?; + let y = y + .parse::() + .map_err(|_| "failed to parse window y coordinate".to_string())?; + position = Some((x, y)); + } + if let Some(value) = trimmed.strip_prefix("Geometry:") { + let (width, height) = value + .trim() + .split_once('x') + .ok_or_else(|| "unable to parse window geometry".to_string())?; + let width = width + .parse::() + .map_err(|_| "failed to parse window width".to_string())?; + let height = height + .parse::() + .map_err(|_| "failed to parse window height".to_string())?; + geometry = Some((width, height)); + } + } + + match (position, geometry) { + (Some((x, y)), Some((width, height))) => Ok((x, y, width, height)), + _ => Err("unable to parse xdotool window geometry".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_xrandr_resolution_reads_current_geometry() { + let bytes = b"Screen 0: minimum 1 x 1, current 1440 x 900, maximum 32767 x 32767\n"; + let parsed = parse_xrandr_resolution(bytes).expect("parse resolution"); + assert_eq!(parsed.width, 1440); + assert_eq!(parsed.height, 900); + } + + #[test] + fn parse_mouse_position_reads_shell_output() { + let bytes = b"X=123\nY=456\nSCREEN=0\nWINDOW=0\n"; + let parsed = parse_mouse_position(bytes).expect("parse mouse position"); + assert_eq!(parsed.x, 123); + assert_eq!(parsed.y, 456); + assert_eq!(parsed.screen, Some(0)); + assert_eq!(parsed.window.as_deref(), Some("0")); + } + + #[test] + fn png_validation_rejects_non_png_bytes() { + let error = validate_image_bytes(b"not png", DesktopScreenshotFormat::Png) + .expect_err("validation should fail"); + assert!(error.contains("PNG")); + } + + #[test] + fn type_text_args_insert_double_dash_before_user_text() { + let args = type_text_args("--help".to_string(), 5); + assert_eq!(args, vec!["type", "--delay", "5", "--", "--help"]); + } + + #[test] + fn press_key_args_insert_double_dash_before_user_key() { + let args = press_key_args("--help".to_string(), None); + assert_eq!(args, vec!["key", "--", "--help"]); + } + + #[test] + fn press_key_args_builds_key_sequence_from_modifiers() { + let args = press_key_args( + "a".to_string(), + Some(DesktopKeyModifiers { + ctrl: Some(true), + shift: Some(true), + alt: Some(false), + cmd: None, + }), + ); + assert_eq!(args, vec!["key", "--", "ctrl+shift+a"]); + } + + #[test] + fn append_scroll_clicks_uses_positive_direction_buttons() { + let mut args = Vec::new(); + append_scroll_clicks(&mut args, 2, 5, 4); + append_scroll_clicks(&mut args, -3, 7, 6); + assert_eq!( + args, + vec!["click", "--repeat", "2", "5", "click", "--repeat", "3", "6"] + ); + } + + #[test] + fn parse_window_geometry_reads_xdotool_output() { + let bytes = b"Window 123\n Position: 400,300 (screen: 0)\n Geometry: 1440x900\n"; + let parsed = parse_window_geometry(bytes).expect("parse geometry"); + assert_eq!(parsed, (400, 300, 1440, 900)); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_command_output_kills_child_on_timeout() { + let pid_file = std::env::temp_dir().join(format!( + "sandbox-agent-desktop-runtime-timeout-{}.pid", + std::process::id() + )); + let _ = std::fs::remove_file(&pid_file); + let command = format!("echo $$ > {}; exec sleep 30", pid_file.display()); + let args = vec!["-c".to_string(), command]; + + let error = run_command_output("sh", &args, &HashMap::new(), Duration::from_millis(200)) + .await + .expect_err("command should time out"); + assert!(error.contains("timed out")); + + let pid = std::fs::read_to_string(&pid_file) + .expect("pid file should exist") + .trim() + .parse::() + .expect("pid should parse"); + + for _ in 0..20 { + if !process_exists(pid) { + let _ = std::fs::remove_file(&pid_file); + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let _ = std::fs::remove_file(&pid_file); + panic!("timed out child process {pid} still exists after timeout cleanup"); + } +} diff --git a/server/packages/sandbox-agent/src/desktop_streaming.rs b/server/packages/sandbox-agent/src/desktop_streaming.rs new file mode 100644 index 0000000..86fb611 --- /dev/null +++ b/server/packages/sandbox-agent/src/desktop_streaming.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +use sandbox_agent_error::SandboxError; + +use crate::desktop_types::DesktopStreamStatusResponse; + +#[derive(Debug, Clone)] +pub struct DesktopStreamingManager { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct DesktopStreamingState { + active: bool, +} + +impl DesktopStreamingManager { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(DesktopStreamingState::default())), + } + } + + pub async fn start(&self) -> DesktopStreamStatusResponse { + let mut state = self.inner.lock().await; + state.active = true; + DesktopStreamStatusResponse { active: true } + } + + pub async fn stop(&self) -> DesktopStreamStatusResponse { + let mut state = self.inner.lock().await; + state.active = false; + DesktopStreamStatusResponse { active: false } + } + + pub async fn ensure_active(&self) -> Result<(), SandboxError> { + if self.inner.lock().await.active { + Ok(()) + } else { + Err(SandboxError::Conflict { + message: "desktop streaming is not active".to_string(), + }) + } + } +} diff --git a/server/packages/sandbox-agent/src/desktop_types.rs b/server/packages/sandbox-agent/src/desktop_types.rs new file mode 100644 index 0000000..7f813da --- /dev/null +++ b/server/packages/sandbox-agent/src/desktop_types.rs @@ -0,0 +1,302 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DesktopState { + Inactive, + InstallRequired, + Starting, + Active, + Stopping, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopResolution { + pub width: u32, + pub height: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dpi: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopErrorInfo { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopProcessInfo { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, + pub running: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopStatusResponse { + pub state: DesktopState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, + #[serde(default)] + pub missing_dependencies: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install_command: Option, + #[serde(default)] + pub processes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_log_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, IntoParams, Default)] +#[serde(rename_all = "camelCase")] +pub struct DesktopStartRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dpi: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, IntoParams, Default)] +#[serde(rename_all = "camelCase")] +pub struct DesktopScreenshotQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scale: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DesktopScreenshotFormat { + Png, + Jpeg, + Webp, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct DesktopRegionScreenshotQuery { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scale: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMousePositionResponse { + pub x: i32, + pub y: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub screen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DesktopMouseButton { + Left, + Middle, + Right, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMouseMoveRequest { + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMouseClickRequest { + pub x: i32, + pub y: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub button: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub click_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMouseDownRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub button: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMouseUpRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub button: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMouseDragRequest { + pub start_x: i32, + pub start_y: i32, + pub end_x: i32, + pub end_y: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub button: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopMouseScrollRequest { + pub x: i32, + pub y: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delta_x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delta_y: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopKeyboardTypeRequest { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delay_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopKeyboardPressRequest { + pub key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modifiers: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct DesktopKeyModifiers { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ctrl: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shift: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cmd: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopKeyboardDownRequest { + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DesktopKeyboardUpRequest { + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopActionResponse { + pub ok: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopDisplayInfoResponse { + pub display: String, + pub resolution: DesktopResolution, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopWindowInfo { + pub id: String, + pub title: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub is_active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopWindowListResponse { + pub windows: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct DesktopRecordingStartRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fps: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DesktopRecordingStatus { + Recording, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopRecordingInfo { + pub id: String, + pub status: DesktopRecordingStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_id: Option, + pub file_name: String, + pub bytes: u64, + pub started_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ended_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopRecordingListResponse { + pub recordings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesktopStreamStatusResponse { + pub active: bool, +} diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index e84b10b..d7b92d6 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -3,6 +3,12 @@ mod acp_proxy_runtime; pub mod cli; pub mod daemon; +mod desktop_errors; +mod desktop_install; +mod desktop_recording; +mod desktop_runtime; +mod desktop_streaming; +pub mod desktop_types; mod process_runtime; pub mod router; pub mod server_logs; diff --git a/server/packages/sandbox-agent/src/process_runtime.rs b/server/packages/sandbox-agent/src/process_runtime.rs index cd1bedd..3f2ce8d 100644 --- a/server/packages/sandbox-agent/src/process_runtime.rs +++ b/server/packages/sandbox-agent/src/process_runtime.rs @@ -1,5 +1,5 @@ use std::collections::{HashMap, VecDeque}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Instant; @@ -27,6 +27,22 @@ pub enum ProcessStream { Pty, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProcessOwner { + User, + Desktop, + System, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RestartPolicy { + Never, + Always, + OnFailure, +} + #[derive(Debug, Clone)] pub struct ProcessStartSpec { pub command: String, @@ -35,6 +51,8 @@ pub struct ProcessStartSpec { pub env: HashMap, pub tty: bool, pub interactive: bool, + pub owner: ProcessOwner, + pub restart_policy: Option, } #[derive(Debug, Clone)] @@ -78,6 +96,7 @@ pub struct ProcessSnapshot { pub cwd: Option, pub tty: bool, pub interactive: bool, + pub owner: ProcessOwner, pub status: ProcessStatus, pub pid: Option, pub exit_code: Option, @@ -129,17 +148,27 @@ struct ManagedProcess { cwd: Option, tty: bool, interactive: bool, + owner: ProcessOwner, + #[allow(dead_code)] + restart_policy: RestartPolicy, + spec: ProcessStartSpec, created_at_ms: i64, - pid: Option, max_log_bytes: usize, - stdin: Mutex>, - #[cfg(unix)] - pty_resize_fd: Mutex>, + runtime: Mutex, status: RwLock, sequence: AtomicU64, logs: Mutex>, total_log_bytes: Mutex, log_tx: broadcast::Sender, + stop_requested: AtomicBool, +} + +#[derive(Debug)] +struct ManagedRuntime { + pid: Option, + stdin: Option, + #[cfg(unix)] + pty_resize_fd: Option, } #[derive(Debug)] @@ -162,17 +191,17 @@ struct ManagedStatus { } struct SpawnedPipeProcess { - process: Arc, child: Child, stdout: tokio::process::ChildStdout, stderr: tokio::process::ChildStderr, + runtime: ManagedRuntime, } #[cfg(unix)] struct SpawnedTtyProcess { - process: Arc, child: Child, reader: tokio::fs::File, + runtime: ManagedRuntime, } impl ProcessRuntime { @@ -224,21 +253,14 @@ impl ProcessRuntime { &self, spec: ProcessStartSpec, ) -> Result { - let config = self.get_config().await; - - let process_refs = { - let processes = self.inner.processes.read().await; - processes.values().cloned().collect::>() - }; - - let mut running_count = 0usize; - for process in process_refs { - if process.status.read().await.status == ProcessStatus::Running { - running_count += 1; - } + if spec.command.trim().is_empty() { + return Err(SandboxError::InvalidRequest { + message: "command must not be empty".to_string(), + }); } - if running_count >= config.max_concurrent_processes { + let config = self.get_config().await; + if self.running_process_count().await >= config.max_concurrent_processes { return Err(SandboxError::Conflict { message: format!( "max concurrent process limit reached ({})", @@ -247,73 +269,44 @@ impl ProcessRuntime { }); } - if spec.command.trim().is_empty() { - return Err(SandboxError::InvalidRequest { - message: "command must not be empty".to_string(), - }); - } - let id_num = self.inner.next_id.fetch_add(1, Ordering::Relaxed); let id = format!("proc_{id_num}"); + let process = Arc::new(ManagedProcess { + id: id.clone(), + command: spec.command.clone(), + args: spec.args.clone(), + cwd: spec.cwd.clone(), + tty: spec.tty, + interactive: spec.interactive, + owner: spec.owner, + restart_policy: spec.restart_policy.unwrap_or(RestartPolicy::Never), + spec, + created_at_ms: now_ms(), + max_log_bytes: config.max_log_bytes_per_process, + runtime: Mutex::new(ManagedRuntime { + pid: None, + stdin: None, + #[cfg(unix)] + pty_resize_fd: None, + }), + status: RwLock::new(ManagedStatus { + status: ProcessStatus::Running, + exit_code: None, + exited_at_ms: None, + }), + sequence: AtomicU64::new(1), + logs: Mutex::new(VecDeque::new()), + total_log_bytes: Mutex::new(0), + log_tx: broadcast::channel(512).0, + stop_requested: AtomicBool::new(false), + }); - if spec.tty { - #[cfg(unix)] - { - let spawned = self - .spawn_tty_process(id.clone(), spec, config.max_log_bytes_per_process) - .await?; - let process = spawned.process.clone(); - self.inner - .processes - .write() - .await - .insert(id, process.clone()); - - let p = process.clone(); - tokio::spawn(async move { - pump_output(p, spawned.reader, ProcessStream::Pty).await; - }); - - let p = process.clone(); - tokio::spawn(async move { - watch_exit(p, spawned.child).await; - }); - - return Ok(process.snapshot().await); - } - #[cfg(not(unix))] - { - return Err(SandboxError::StreamError { - message: "tty process mode is not supported on this platform".to_string(), - }); - } - } - - let spawned = self - .spawn_pipe_process(id.clone(), spec, config.max_log_bytes_per_process) - .await?; - let process = spawned.process.clone(); + self.spawn_existing_process(process.clone()).await?; self.inner .processes .write() .await .insert(id, process.clone()); - - let p = process.clone(); - tokio::spawn(async move { - pump_output(p, spawned.stdout, ProcessStream::Stdout).await; - }); - - let p = process.clone(); - tokio::spawn(async move { - pump_output(p, spawned.stderr, ProcessStream::Stderr).await; - }); - - let p = process.clone(); - tokio::spawn(async move { - watch_exit(p, spawned.child).await; - }); - Ok(process.snapshot().await) } @@ -412,11 +405,13 @@ impl ProcessRuntime { }) } - pub async fn list_processes(&self) -> Vec { + pub async fn list_processes(&self, owner: Option) -> Vec { let processes = self.inner.processes.read().await; let mut items = Vec::with_capacity(processes.len()); for process in processes.values() { - items.push(process.snapshot().await); + if owner.is_none_or(|expected| process.owner == expected) { + items.push(process.snapshot().await); + } } items.sort_by(|a, b| a.id.cmp(&b.id)); items @@ -453,6 +448,7 @@ impl ProcessRuntime { wait_ms: Option, ) -> Result { let process = self.lookup_process(id).await?; + process.stop_requested.store(true, Ordering::SeqCst); process.send_signal(SIGTERM).await?; maybe_wait_for_exit(process.clone(), wait_ms.unwrap_or(2_000)).await; Ok(process.snapshot().await) @@ -464,6 +460,7 @@ impl ProcessRuntime { wait_ms: Option, ) -> Result { let process = self.lookup_process(id).await?; + process.stop_requested.store(true, Ordering::SeqCst); process.send_signal(SIGKILL).await?; maybe_wait_for_exit(process.clone(), wait_ms.unwrap_or(1_000)).await; Ok(process.snapshot().await) @@ -506,6 +503,17 @@ impl ProcessRuntime { Ok(process.log_tx.subscribe()) } + async fn running_process_count(&self) -> usize { + let processes = self.inner.processes.read().await; + let mut running = 0usize; + for process in processes.values() { + if process.status.read().await.status == ProcessStatus::Running { + running += 1; + } + } + running + } + async fn lookup_process(&self, id: &str) -> Result, SandboxError> { let process = self.inner.processes.read().await.get(id).cloned(); process.ok_or_else(|| SandboxError::NotFound { @@ -514,11 +522,83 @@ impl ProcessRuntime { }) } - async fn spawn_pipe_process( + async fn spawn_existing_process( &self, - id: String, - spec: ProcessStartSpec, - max_log_bytes: usize, + process: Arc, + ) -> Result<(), SandboxError> { + process.stop_requested.store(false, Ordering::SeqCst); + let mut runtime_guard = process.runtime.lock().await; + let mut status_guard = process.status.write().await; + + if process.tty { + #[cfg(unix)] + { + let SpawnedTtyProcess { + child, + reader, + runtime, + } = self.spawn_tty_process(&process.spec)?; + *runtime_guard = runtime; + status_guard.status = ProcessStatus::Running; + status_guard.exit_code = None; + status_guard.exited_at_ms = None; + drop(status_guard); + drop(runtime_guard); + + let process_for_output = process.clone(); + tokio::spawn(async move { + pump_output(process_for_output, reader, ProcessStream::Pty).await; + }); + + let runtime = self.clone(); + tokio::spawn(async move { + watch_exit(runtime, process, child).await; + }); + + return Ok(()); + } + #[cfg(not(unix))] + { + return Err(SandboxError::StreamError { + message: "tty process mode is not supported on this platform".to_string(), + }); + } + } + + let SpawnedPipeProcess { + child, + stdout, + stderr, + runtime, + } = self.spawn_pipe_process(&process.spec)?; + *runtime_guard = runtime; + status_guard.status = ProcessStatus::Running; + status_guard.exit_code = None; + status_guard.exited_at_ms = None; + drop(status_guard); + drop(runtime_guard); + + let process_for_stdout = process.clone(); + tokio::spawn(async move { + pump_output(process_for_stdout, stdout, ProcessStream::Stdout).await; + }); + + let process_for_stderr = process.clone(); + tokio::spawn(async move { + pump_output(process_for_stderr, stderr, ProcessStream::Stderr).await; + }); + + let runtime = self.clone(); + tokio::spawn(async move { + watch_exit(runtime, process, child).await; + }); + + Ok(()) + } + + fn spawn_pipe_process( + &self, + spec: &ProcessStartSpec, ) -> Result { let mut cmd = Command::new(&spec.command); cmd.args(&spec.args) @@ -551,35 +631,14 @@ impl ProcessRuntime { .ok_or_else(|| SandboxError::StreamError { message: "failed to capture stderr".to_string(), })?; - let pid = child.id(); - - let (tx, _rx) = broadcast::channel(512); - let process = Arc::new(ManagedProcess { - id, - command: spec.command, - args: spec.args, - cwd: spec.cwd, - tty: false, - interactive: spec.interactive, - created_at_ms: now_ms(), - pid, - max_log_bytes, - stdin: Mutex::new(stdin.map(ProcessStdin::Pipe)), - #[cfg(unix)] - pty_resize_fd: Mutex::new(None), - status: RwLock::new(ManagedStatus { - status: ProcessStatus::Running, - exit_code: None, - exited_at_ms: None, - }), - sequence: AtomicU64::new(1), - logs: Mutex::new(VecDeque::new()), - total_log_bytes: Mutex::new(0), - log_tx: tx, - }); Ok(SpawnedPipeProcess { - process, + runtime: ManagedRuntime { + pid: child.id(), + stdin: stdin.map(ProcessStdin::Pipe), + #[cfg(unix)] + pty_resize_fd: None, + }, child, stdout, stderr, @@ -587,11 +646,9 @@ impl ProcessRuntime { } #[cfg(unix)] - async fn spawn_tty_process( + fn spawn_tty_process( &self, - id: String, - spec: ProcessStartSpec, - max_log_bytes: usize, + spec: &ProcessStartSpec, ) -> Result { use std::os::fd::AsRawFd; use std::process::Stdio; @@ -632,8 +689,8 @@ impl ProcessRuntime { let child = cmd.spawn().map_err(|err| SandboxError::StreamError { message: format!("failed to spawn tty process: {err}"), })?; - let pid = child.id(); + drop(slave_fd); let master_raw = master_fd.as_raw_fd(); @@ -644,32 +701,12 @@ impl ProcessRuntime { let writer_file = tokio::fs::File::from_std(std::fs::File::from(writer_fd)); let resize_file = std::fs::File::from(resize_fd); - let (tx, _rx) = broadcast::channel(512); - let process = Arc::new(ManagedProcess { - id, - command: spec.command, - args: spec.args, - cwd: spec.cwd, - tty: true, - interactive: spec.interactive, - created_at_ms: now_ms(), - pid, - max_log_bytes, - stdin: Mutex::new(Some(ProcessStdin::Pty(writer_file))), - pty_resize_fd: Mutex::new(Some(resize_file)), - status: RwLock::new(ManagedStatus { - status: ProcessStatus::Running, - exit_code: None, - exited_at_ms: None, - }), - sequence: AtomicU64::new(1), - logs: Mutex::new(VecDeque::new()), - total_log_bytes: Mutex::new(0), - log_tx: tx, - }); - Ok(SpawnedTtyProcess { - process, + runtime: ManagedRuntime { + pid, + stdin: Some(ProcessStdin::Pty(writer_file)), + pty_resize_fd: Some(resize_file), + }, child, reader: reader_file, }) @@ -694,6 +731,7 @@ pub struct ProcessLogFilter { impl ManagedProcess { async fn snapshot(&self) -> ProcessSnapshot { let status = self.status.read().await.clone(); + let pid = self.runtime.lock().await.pid; ProcessSnapshot { id: self.id.clone(), command: self.command.clone(), @@ -701,8 +739,9 @@ impl ManagedProcess { cwd: self.cwd.clone(), tty: self.tty, interactive: self.interactive, + owner: self.owner, status: status.status, - pid: self.pid, + pid, exit_code: status.exit_code, created_at_ms: self.created_at_ms, exited_at_ms: status.exited_at_ms, @@ -752,10 +791,13 @@ impl ManagedProcess { }); } - let mut guard = self.stdin.lock().await; - let stdin = guard.as_mut().ok_or_else(|| SandboxError::Conflict { - message: "process does not accept stdin".to_string(), - })?; + let mut runtime = self.runtime.lock().await; + let stdin = runtime + .stdin + .as_mut() + .ok_or_else(|| SandboxError::Conflict { + message: "process does not accept stdin".to_string(), + })?; match stdin { ProcessStdin::Pipe(pipe) => { @@ -825,7 +867,7 @@ impl ManagedProcess { if self.status.read().await.status != ProcessStatus::Running { return Ok(()); } - let Some(pid) = self.pid else { + let Some(pid) = self.runtime.lock().await.pid else { return Ok(()); }; @@ -840,8 +882,9 @@ impl ManagedProcess { #[cfg(unix)] { use std::os::fd::AsRawFd; - let guard = self.pty_resize_fd.lock().await; - let Some(fd) = guard.as_ref() else { + + let runtime = self.runtime.lock().await; + let Some(fd) = runtime.pty_resize_fd.as_ref() else { return Err(SandboxError::Conflict { message: "PTY resize handle unavailable".to_string(), }); @@ -857,6 +900,32 @@ impl ManagedProcess { Ok(()) } + + #[allow(dead_code)] + fn should_restart(&self, exit_code: Option) -> bool { + match self.restart_policy { + RestartPolicy::Never => false, + RestartPolicy::Always => true, + RestartPolicy::OnFailure => exit_code.unwrap_or(1) != 0, + } + } + + async fn mark_exited(&self, exit_code: Option, exited_at_ms: Option) { + { + let mut status = self.status.write().await; + status.status = ProcessStatus::Exited; + status.exit_code = exit_code; + status.exited_at_ms = exited_at_ms; + } + + let mut runtime = self.runtime.lock().await; + runtime.pid = None; + let _ = runtime.stdin.take(); + #[cfg(unix)] + { + let _ = runtime.pty_resize_fd.take(); + } + } } fn stream_matches(stream: ProcessStream, filter: ProcessLogFilterStream) -> bool { @@ -909,21 +978,16 @@ where } } -async fn watch_exit(process: Arc, mut child: Child) { +async fn watch_exit(runtime: ProcessRuntime, process: Arc, mut child: Child) { + let _ = runtime; let wait = child.wait().await; let (exit_code, exited_at_ms) = match wait { Ok(status) => (status.code(), Some(now_ms())), Err(_) => (None, Some(now_ms())), }; - { - let mut state = process.status.write().await; - state.status = ProcessStatus::Exited; - state.exit_code = exit_code; - state.exited_at_ms = exited_at_ms; - } - - let _ = process.stdin.lock().await.take(); + let _ = process.stop_requested.swap(false, Ordering::SeqCst); + process.mark_exited(exit_code, exited_at_ms).await; } async fn capture_output(mut reader: R, max_bytes: usize) -> std::io::Result<(Vec, bool)> diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 110c325..70d3f45 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -34,12 +34,16 @@ use tar::Archive; use tokio_stream::wrappers::BroadcastStream; use tower_http::trace::TraceLayer; use tracing::Span; -use utoipa::{Modify, OpenApi, ToSchema}; +use utoipa::{IntoParams, Modify, OpenApi, ToSchema}; use crate::acp_proxy_runtime::{AcpProxyRuntime, ProxyPostOutcome}; +use crate::desktop_errors::DesktopProblem; +use crate::desktop_runtime::DesktopRuntime; +use crate::desktop_types::*; use crate::process_runtime::{ - decode_input_bytes, ProcessLogFilter, ProcessLogFilterStream, ProcessRuntime, - ProcessRuntimeConfig, ProcessSnapshot, ProcessStartSpec, ProcessStatus, ProcessStream, RunSpec, + decode_input_bytes, ProcessLogFilter, ProcessLogFilterStream, + ProcessOwner as RuntimeProcessOwner, ProcessRuntime, ProcessRuntimeConfig, ProcessSnapshot, + ProcessStartSpec, ProcessStatus, ProcessStream, RunSpec, }; use crate::ui; @@ -87,6 +91,7 @@ pub struct AppState { acp_proxy: Arc, opencode_server_manager: Arc, process_runtime: Arc, + desktop_runtime: Arc, pub(crate) branding: BrandingMode, version_cache: Mutex>, } @@ -111,12 +116,14 @@ impl AppState { }, )); let process_runtime = Arc::new(ProcessRuntime::new()); + let desktop_runtime = Arc::new(DesktopRuntime::new(process_runtime.clone())); Self { auth, agent_manager, acp_proxy, opencode_server_manager, process_runtime, + desktop_runtime, branding, version_cache: Mutex::new(HashMap::new()), } @@ -138,6 +145,10 @@ impl AppState { self.process_runtime.clone() } + pub(crate) fn desktop_runtime(&self) -> Arc { + self.desktop_runtime.clone() + } + pub(crate) fn purge_version_cache(&self, agent: AgentId) { self.version_cache.lock().unwrap().remove(&agent); } @@ -172,6 +183,59 @@ pub fn build_router(state: AppState) -> Router { pub fn build_router_with_state(shared: Arc) -> (Router, Arc) { let mut v1_router = Router::new() .route("/health", get(get_v1_health)) + .route("/desktop/status", get(get_v1_desktop_status)) + .route("/desktop/start", post(post_v1_desktop_start)) + .route("/desktop/stop", post(post_v1_desktop_stop)) + .route("/desktop/screenshot", get(get_v1_desktop_screenshot)) + .route( + "/desktop/screenshot/region", + get(get_v1_desktop_screenshot_region), + ) + .route( + "/desktop/mouse/position", + get(get_v1_desktop_mouse_position), + ) + .route("/desktop/mouse/move", post(post_v1_desktop_mouse_move)) + .route("/desktop/mouse/click", post(post_v1_desktop_mouse_click)) + .route("/desktop/mouse/down", post(post_v1_desktop_mouse_down)) + .route("/desktop/mouse/up", post(post_v1_desktop_mouse_up)) + .route("/desktop/mouse/drag", post(post_v1_desktop_mouse_drag)) + .route("/desktop/mouse/scroll", post(post_v1_desktop_mouse_scroll)) + .route( + "/desktop/keyboard/type", + post(post_v1_desktop_keyboard_type), + ) + .route( + "/desktop/keyboard/press", + post(post_v1_desktop_keyboard_press), + ) + .route( + "/desktop/keyboard/down", + post(post_v1_desktop_keyboard_down), + ) + .route("/desktop/keyboard/up", post(post_v1_desktop_keyboard_up)) + .route("/desktop/display/info", get(get_v1_desktop_display_info)) + .route("/desktop/windows", get(get_v1_desktop_windows)) + .route( + "/desktop/recording/start", + post(post_v1_desktop_recording_start), + ) + .route( + "/desktop/recording/stop", + post(post_v1_desktop_recording_stop), + ) + .route("/desktop/recordings", get(get_v1_desktop_recordings)) + .route( + "/desktop/recordings/:id", + get(get_v1_desktop_recording).delete(delete_v1_desktop_recording), + ) + .route( + "/desktop/recordings/:id/download", + get(get_v1_desktop_recording_download), + ) + .route("/desktop/stream/start", post(post_v1_desktop_stream_start)) + .route("/desktop/stream/stop", post(post_v1_desktop_stream_stop)) + .route("/desktop/stream/ws", get(get_v1_desktop_stream_ws)) .route("/agents", get(get_v1_agents)) .route("/agents/:agent", get(get_v1_agent)) .route("/agents/:agent/install", post(post_v1_agent_install)) @@ -316,12 +380,40 @@ async fn opencode_unavailable() -> Response { pub async fn shutdown_servers(state: &Arc) { state.acp_proxy().shutdown_all().await; state.opencode_server_manager().shutdown().await; + state.desktop_runtime().shutdown().await; } #[derive(OpenApi)] #[openapi( paths( get_v1_health, + get_v1_desktop_status, + post_v1_desktop_start, + post_v1_desktop_stop, + get_v1_desktop_screenshot, + get_v1_desktop_screenshot_region, + get_v1_desktop_mouse_position, + post_v1_desktop_mouse_move, + post_v1_desktop_mouse_click, + post_v1_desktop_mouse_down, + post_v1_desktop_mouse_up, + post_v1_desktop_mouse_drag, + post_v1_desktop_mouse_scroll, + post_v1_desktop_keyboard_type, + post_v1_desktop_keyboard_press, + post_v1_desktop_keyboard_down, + post_v1_desktop_keyboard_up, + get_v1_desktop_display_info, + get_v1_desktop_windows, + post_v1_desktop_recording_start, + post_v1_desktop_recording_stop, + get_v1_desktop_recordings, + get_v1_desktop_recording, + get_v1_desktop_recording_download, + delete_v1_desktop_recording, + post_v1_desktop_stream_start, + post_v1_desktop_stream_stop, + get_v1_desktop_stream_ws, get_v1_agents, get_v1_agent, post_v1_agent_install, @@ -360,6 +452,37 @@ pub async fn shutdown_servers(state: &Arc) { components( schemas( HealthResponse, + DesktopState, + DesktopResolution, + DesktopErrorInfo, + DesktopProcessInfo, + DesktopStatusResponse, + DesktopStartRequest, + DesktopScreenshotQuery, + DesktopScreenshotFormat, + DesktopRegionScreenshotQuery, + DesktopMousePositionResponse, + DesktopMouseButton, + DesktopMouseMoveRequest, + DesktopMouseClickRequest, + DesktopMouseDownRequest, + DesktopMouseUpRequest, + DesktopMouseDragRequest, + DesktopMouseScrollRequest, + DesktopKeyboardTypeRequest, + DesktopKeyboardPressRequest, + DesktopKeyModifiers, + DesktopKeyboardDownRequest, + DesktopKeyboardUpRequest, + DesktopActionResponse, + DesktopDisplayInfoResponse, + DesktopWindowInfo, + DesktopWindowListResponse, + DesktopRecordingStartRequest, + DesktopRecordingStatus, + DesktopRecordingInfo, + DesktopRecordingListResponse, + DesktopStreamStatusResponse, ServerStatus, ServerStatusInfo, AgentCapabilities, @@ -381,12 +504,14 @@ pub async fn shutdown_servers(state: &Arc) { FsActionResponse, FsUploadBatchResponse, ProcessConfig, + ProcessOwner, ProcessCreateRequest, ProcessRunRequest, ProcessRunResponse, ProcessState, ProcessInfo, ProcessListResponse, + ProcessListQuery, ProcessLogsStream, ProcessLogsQuery, ProcessLogEntry, @@ -438,6 +563,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(value: DesktopProblem) -> Self { + Self::Problem(value.to_problem_details()) + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { let problem = match &self { @@ -476,6 +607,628 @@ async fn get_v1_health() -> Json { }) } +/// Get desktop runtime status. +/// +/// Returns the current desktop runtime state, dependency status, active +/// display metadata, and supervised process information. +#[utoipa::path( + get, + path = "/v1/desktop/status", + tag = "v1", + responses( + (status = 200, description = "Desktop runtime status", body = DesktopStatusResponse), + (status = 401, description = "Authentication required", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_status( + State(state): State>, +) -> Result, ApiError> { + Ok(Json(state.desktop_runtime().status().await)) +} + +/// Start the private desktop runtime. +/// +/// Lazily launches the managed Xvfb/openbox stack, validates display health, +/// and returns the resulting desktop status snapshot. +#[utoipa::path( + post, + path = "/v1/desktop/start", + tag = "v1", + request_body = DesktopStartRequest, + responses( + (status = 200, description = "Desktop runtime status after start", body = DesktopStatusResponse), + (status = 400, description = "Invalid desktop start request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is already transitioning", body = ProblemDetails), + (status = 501, description = "Desktop API unsupported on this platform", body = ProblemDetails), + (status = 503, description = "Desktop runtime could not be started", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_start( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let status = state.desktop_runtime().start(body).await?; + Ok(Json(status)) +} + +/// Stop the private desktop runtime. +/// +/// Terminates the managed openbox/Xvfb/dbus processes owned by the desktop +/// runtime and returns the resulting status snapshot. +#[utoipa::path( + post, + path = "/v1/desktop/stop", + tag = "v1", + responses( + (status = 200, description = "Desktop runtime status after stop", body = DesktopStatusResponse), + (status = 409, description = "Desktop runtime is already transitioning", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_stop( + State(state): State>, +) -> Result, ApiError> { + let status = state.desktop_runtime().stop().await?; + Ok(Json(status)) +} + +/// Capture a full desktop screenshot. +/// +/// Performs a health-gated full-frame screenshot of the managed desktop and +/// returns the requested image bytes. +#[utoipa::path( + get, + path = "/v1/desktop/screenshot", + tag = "v1", + params(DesktopScreenshotQuery), + responses( + (status = 200, description = "Desktop screenshot as image bytes"), + (status = 400, description = "Invalid screenshot query", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or screenshot capture failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_screenshot( + State(state): State>, + Query(query): Query, +) -> Result { + let screenshot = state.desktop_runtime().screenshot(query).await?; + Ok(( + [(header::CONTENT_TYPE, screenshot.content_type)], + Bytes::from(screenshot.bytes), + ) + .into_response()) +} + +/// Capture a desktop screenshot region. +/// +/// Performs a health-gated screenshot crop against the managed desktop and +/// returns the requested region image bytes. +#[utoipa::path( + get, + path = "/v1/desktop/screenshot/region", + tag = "v1", + params(DesktopRegionScreenshotQuery), + responses( + (status = 200, description = "Desktop screenshot region as image bytes"), + (status = 400, description = "Invalid screenshot region", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or screenshot capture failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_screenshot_region( + State(state): State>, + Query(query): Query, +) -> Result { + let screenshot = state.desktop_runtime().screenshot_region(query).await?; + Ok(( + [(header::CONTENT_TYPE, screenshot.content_type)], + Bytes::from(screenshot.bytes), + ) + .into_response()) +} + +/// Get the current desktop mouse position. +/// +/// Performs a health-gated mouse position query against the managed desktop. +#[utoipa::path( + get, + path = "/v1/desktop/mouse/position", + tag = "v1", + responses( + (status = 200, description = "Desktop mouse position", body = DesktopMousePositionResponse), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input check failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_mouse_position( + State(state): State>, +) -> Result, ApiError> { + let position = state.desktop_runtime().mouse_position().await?; + Ok(Json(position)) +} + +/// Move the desktop mouse. +/// +/// Performs a health-gated absolute pointer move on the managed desktop and +/// returns the resulting mouse position. +#[utoipa::path( + post, + path = "/v1/desktop/mouse/move", + tag = "v1", + request_body = DesktopMouseMoveRequest, + responses( + (status = 200, description = "Desktop mouse position after move", body = DesktopMousePositionResponse), + (status = 400, description = "Invalid mouse move request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_mouse_move( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let position = state.desktop_runtime().move_mouse(body).await?; + Ok(Json(position)) +} + +/// Click on the desktop. +/// +/// Performs a health-gated pointer move and click against the managed desktop +/// and returns the resulting mouse position. +#[utoipa::path( + post, + path = "/v1/desktop/mouse/click", + tag = "v1", + request_body = DesktopMouseClickRequest, + responses( + (status = 200, description = "Desktop mouse position after click", body = DesktopMousePositionResponse), + (status = 400, description = "Invalid mouse click request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_mouse_click( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let position = state.desktop_runtime().click_mouse(body).await?; + Ok(Json(position)) +} + +/// Press and hold a desktop mouse button. +/// +/// Performs a health-gated optional pointer move followed by `xdotool mousedown` +/// and returns the resulting mouse position. +#[utoipa::path( + post, + path = "/v1/desktop/mouse/down", + tag = "v1", + request_body = DesktopMouseDownRequest, + responses( + (status = 200, description = "Desktop mouse position after button press", body = DesktopMousePositionResponse), + (status = 400, description = "Invalid mouse down request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_mouse_down( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let position = state.desktop_runtime().mouse_down(body).await?; + Ok(Json(position)) +} + +/// Release a desktop mouse button. +/// +/// Performs a health-gated optional pointer move followed by `xdotool mouseup` +/// and returns the resulting mouse position. +#[utoipa::path( + post, + path = "/v1/desktop/mouse/up", + tag = "v1", + request_body = DesktopMouseUpRequest, + responses( + (status = 200, description = "Desktop mouse position after button release", body = DesktopMousePositionResponse), + (status = 400, description = "Invalid mouse up request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_mouse_up( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let position = state.desktop_runtime().mouse_up(body).await?; + Ok(Json(position)) +} + +/// Drag the desktop mouse. +/// +/// Performs a health-gated drag gesture against the managed desktop and +/// returns the resulting mouse position. +#[utoipa::path( + post, + path = "/v1/desktop/mouse/drag", + tag = "v1", + request_body = DesktopMouseDragRequest, + responses( + (status = 200, description = "Desktop mouse position after drag", body = DesktopMousePositionResponse), + (status = 400, description = "Invalid mouse drag request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_mouse_drag( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let position = state.desktop_runtime().drag_mouse(body).await?; + Ok(Json(position)) +} + +/// Scroll the desktop mouse wheel. +/// +/// Performs a health-gated scroll gesture at the requested coordinates and +/// returns the resulting mouse position. +#[utoipa::path( + post, + path = "/v1/desktop/mouse/scroll", + tag = "v1", + request_body = DesktopMouseScrollRequest, + responses( + (status = 200, description = "Desktop mouse position after scroll", body = DesktopMousePositionResponse), + (status = 400, description = "Invalid mouse scroll request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_mouse_scroll( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let position = state.desktop_runtime().scroll_mouse(body).await?; + Ok(Json(position)) +} + +/// Type desktop keyboard text. +/// +/// Performs a health-gated `xdotool type` operation against the managed +/// desktop. +#[utoipa::path( + post, + path = "/v1/desktop/keyboard/type", + tag = "v1", + request_body = DesktopKeyboardTypeRequest, + responses( + (status = 200, description = "Desktop keyboard action result", body = DesktopActionResponse), + (status = 400, description = "Invalid keyboard type request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_keyboard_type( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let response = state.desktop_runtime().type_text(body).await?; + Ok(Json(response)) +} + +/// Press a desktop keyboard shortcut. +/// +/// Performs a health-gated `xdotool key` operation against the managed +/// desktop. +#[utoipa::path( + post, + path = "/v1/desktop/keyboard/press", + tag = "v1", + request_body = DesktopKeyboardPressRequest, + responses( + (status = 200, description = "Desktop keyboard action result", body = DesktopActionResponse), + (status = 400, description = "Invalid keyboard press request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_keyboard_press( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let response = state.desktop_runtime().press_key(body).await?; + Ok(Json(response)) +} + +/// Press and hold a desktop keyboard key. +/// +/// Performs a health-gated `xdotool keydown` operation against the managed +/// desktop. +#[utoipa::path( + post, + path = "/v1/desktop/keyboard/down", + tag = "v1", + request_body = DesktopKeyboardDownRequest, + responses( + (status = 200, description = "Desktop keyboard action result", body = DesktopActionResponse), + (status = 400, description = "Invalid keyboard down request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_keyboard_down( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let response = state.desktop_runtime().key_down(body).await?; + Ok(Json(response)) +} + +/// Release a desktop keyboard key. +/// +/// Performs a health-gated `xdotool keyup` operation against the managed +/// desktop. +#[utoipa::path( + post, + path = "/v1/desktop/keyboard/up", + tag = "v1", + request_body = DesktopKeyboardUpRequest, + responses( + (status = 200, description = "Desktop keyboard action result", body = DesktopActionResponse), + (status = 400, description = "Invalid keyboard up request", body = ProblemDetails), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 502, description = "Desktop runtime health or input failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_keyboard_up( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let response = state.desktop_runtime().key_up(body).await?; + Ok(Json(response)) +} + +/// Get desktop display information. +/// +/// Performs a health-gated display query against the managed desktop and +/// returns the current display identifier and resolution. +#[utoipa::path( + get, + path = "/v1/desktop/display/info", + tag = "v1", + responses( + (status = 200, description = "Desktop display information", body = DesktopDisplayInfoResponse), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 503, description = "Desktop runtime health or display query failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_display_info( + State(state): State>, +) -> Result, ApiError> { + let info = state.desktop_runtime().display_info().await?; + Ok(Json(info)) +} + +/// List visible desktop windows. +/// +/// Performs a health-gated visible-window enumeration against the managed +/// desktop and returns the current window metadata. +#[utoipa::path( + get, + path = "/v1/desktop/windows", + tag = "v1", + responses( + (status = 200, description = "Visible desktop windows", body = DesktopWindowListResponse), + (status = 409, description = "Desktop runtime is not ready", body = ProblemDetails), + (status = 503, description = "Desktop runtime health or window query failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_windows( + State(state): State>, +) -> Result, ApiError> { + let windows = state.desktop_runtime().list_windows().await?; + Ok(Json(windows)) +} + +/// Start desktop recording. +/// +/// Starts an ffmpeg x11grab recording against the managed desktop and returns +/// the created recording metadata. +#[utoipa::path( + post, + path = "/v1/desktop/recording/start", + tag = "v1", + request_body = DesktopRecordingStartRequest, + responses( + (status = 200, description = "Desktop recording started", body = DesktopRecordingInfo), + (status = 409, description = "Desktop runtime is not ready or a recording is already active", body = ProblemDetails), + (status = 502, description = "Desktop recording failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_recording_start( + State(state): State>, + Json(body): Json, +) -> Result, ApiError> { + let recording = state.desktop_runtime().start_recording(body).await?; + Ok(Json(recording)) +} + +/// Stop desktop recording. +/// +/// Stops the active desktop recording and returns the finalized recording +/// metadata. +#[utoipa::path( + post, + path = "/v1/desktop/recording/stop", + tag = "v1", + responses( + (status = 200, description = "Desktop recording stopped", body = DesktopRecordingInfo), + (status = 409, description = "No active desktop recording", body = ProblemDetails), + (status = 502, description = "Desktop recording stop failed", body = ProblemDetails) + ) +)] +async fn post_v1_desktop_recording_stop( + State(state): State>, +) -> Result, ApiError> { + let recording = state.desktop_runtime().stop_recording().await?; + Ok(Json(recording)) +} + +/// List desktop recordings. +/// +/// Returns the current desktop recording catalog. +#[utoipa::path( + get, + path = "/v1/desktop/recordings", + tag = "v1", + responses( + (status = 200, description = "Desktop recordings", body = DesktopRecordingListResponse), + (status = 502, description = "Desktop recordings query failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_recordings( + State(state): State>, +) -> Result, ApiError> { + let recordings = state.desktop_runtime().list_recordings().await?; + Ok(Json(recordings)) +} + +/// Get desktop recording metadata. +/// +/// Returns metadata for a single desktop recording. +#[utoipa::path( + get, + path = "/v1/desktop/recordings/{id}", + tag = "v1", + params( + ("id" = String, Path, description = "Desktop recording ID") + ), + responses( + (status = 200, description = "Desktop recording metadata", body = DesktopRecordingInfo), + (status = 404, description = "Unknown desktop recording", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_recording( + State(state): State>, + Path(id): Path, +) -> Result, ApiError> { + let recording = state.desktop_runtime().get_recording(&id).await?; + Ok(Json(recording)) +} + +/// Download a desktop recording. +/// +/// Serves the recorded MP4 bytes for a completed desktop recording. +#[utoipa::path( + get, + path = "/v1/desktop/recordings/{id}/download", + tag = "v1", + params( + ("id" = String, Path, description = "Desktop recording ID") + ), + responses( + (status = 200, description = "Desktop recording as MP4 bytes"), + (status = 404, description = "Unknown desktop recording", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_recording_download( + State(state): State>, + Path(id): Path, +) -> Result { + let path = state.desktop_runtime().recording_download_path(&id).await?; + let bytes = tokio::fs::read(&path) + .await + .map_err(|err| SandboxError::StreamError { + message: format!("failed to read desktop recording {}: {err}", path.display()), + })?; + Ok(([(header::CONTENT_TYPE, "video/mp4")], Bytes::from(bytes)).into_response()) +} + +/// Delete a desktop recording. +/// +/// Removes a completed desktop recording and its file from disk. +#[utoipa::path( + delete, + path = "/v1/desktop/recordings/{id}", + tag = "v1", + params( + ("id" = String, Path, description = "Desktop recording ID") + ), + responses( + (status = 204, description = "Desktop recording deleted"), + (status = 404, description = "Unknown desktop recording", body = ProblemDetails), + (status = 409, description = "Desktop recording is still active", body = ProblemDetails) + ) +)] +async fn delete_v1_desktop_recording( + State(state): State>, + Path(id): Path, +) -> Result { + state.desktop_runtime().delete_recording(&id).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Start desktop streaming. +/// +/// Enables desktop websocket streaming for the managed desktop. +#[utoipa::path( + post, + path = "/v1/desktop/stream/start", + tag = "v1", + responses( + (status = 200, description = "Desktop streaming started", body = DesktopStreamStatusResponse) + ) +)] +async fn post_v1_desktop_stream_start( + State(state): State>, +) -> Result, ApiError> { + Ok(Json(state.desktop_runtime().start_streaming().await)) +} + +/// Stop desktop streaming. +/// +/// Disables desktop websocket streaming for the managed desktop. +#[utoipa::path( + post, + path = "/v1/desktop/stream/stop", + tag = "v1", + responses( + (status = 200, description = "Desktop streaming stopped", body = DesktopStreamStatusResponse) + ) +)] +async fn post_v1_desktop_stream_stop( + State(state): State>, +) -> Result, ApiError> { + Ok(Json(state.desktop_runtime().stop_streaming().await)) +} + +/// Open a desktop websocket streaming session. +/// +/// Upgrades the connection to a websocket that streams JPEG desktop frames and +/// accepts mouse and keyboard control frames. +#[utoipa::path( + get, + path = "/v1/desktop/stream/ws", + tag = "v1", + params( + ("access_token" = Option, Query, description = "Bearer token alternative for WS auth") + ), + responses( + (status = 101, description = "WebSocket upgraded"), + (status = 409, description = "Desktop runtime or streaming session is not ready", body = ProblemDetails), + (status = 502, description = "Desktop stream failed", body = ProblemDetails) + ) +)] +async fn get_v1_desktop_stream_ws( + State(state): State>, + Query(_query): Query, + ws: WebSocketUpgrade, +) -> Result { + state.desktop_runtime().ensure_streaming_active().await?; + Ok(ws + .on_upgrade(move |socket| desktop_stream_ws_session(socket, state.desktop_runtime())) + .into_response()) +} + #[utoipa::path( get, path = "/v1/agents", @@ -1238,6 +1991,8 @@ async fn post_v1_processes( env: body.env.into_iter().collect(), tty: body.tty, interactive: body.interactive, + owner: RuntimeProcessOwner::User, + restart_policy: None, }) .await?; @@ -1298,6 +2053,7 @@ async fn post_v1_processes_run( get, path = "/v1/processes", tag = "v1", + params(ProcessListQuery), responses( (status = 200, description = "List processes", body = ProcessListResponse), (status = 501, description = "Process API unsupported on this platform", body = ProblemDetails) @@ -1305,12 +2061,16 @@ async fn post_v1_processes_run( )] async fn get_v1_processes( State(state): State>, + Query(query): Query, ) -> Result, ApiError> { if !process_api_supported() { return Err(process_api_not_supported().into()); } - let snapshots = state.process_runtime().list_processes().await; + let snapshots = state + .process_runtime() + .list_processes(query.owner.map(into_runtime_process_owner)) + .await; Ok(Json(ProcessListResponse { processes: snapshots.into_iter().map(map_process_snapshot).collect(), })) @@ -1691,6 +2451,46 @@ enum TerminalClientFrame { Close, } +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum DesktopStreamClientFrame { + MoveMouse { + x: i32, + y: i32, + }, + MouseDown { + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, + #[serde(default)] + button: Option, + }, + MouseUp { + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, + #[serde(default)] + button: Option, + }, + Scroll { + x: i32, + y: i32, + #[serde(default)] + delta_x: Option, + #[serde(default)] + delta_y: Option, + }, + KeyDown { + key: String, + }, + KeyUp { + key: String, + }, + Close, +} + async fn process_terminal_ws_session( mut socket: WebSocket, runtime: Arc, @@ -1803,6 +2603,133 @@ async fn process_terminal_ws_session( } } +async fn desktop_stream_ws_session(mut socket: WebSocket, desktop_runtime: Arc) { + let display_info = match desktop_runtime.display_info().await { + Ok(info) => info, + Err(err) => { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + let _ = socket.close().await; + return; + } + }; + + if send_ws_json( + &mut socket, + json!({ + "type": "ready", + "width": display_info.resolution.width, + "height": display_info.resolution.height, + }), + ) + .await + .is_err() + { + return; + } + + let mut frame_tick = tokio::time::interval(Duration::from_millis(100)); + + loop { + tokio::select! { + ws_in = socket.recv() => { + match ws_in { + Some(Ok(Message::Text(text))) => { + match serde_json::from_str::(&text) { + Ok(DesktopStreamClientFrame::MoveMouse { x, y }) => { + if let Err(err) = desktop_runtime + .move_mouse(DesktopMouseMoveRequest { x, y }) + .await + { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + } + } + Ok(DesktopStreamClientFrame::MouseDown { x, y, button }) => { + if let Err(err) = desktop_runtime + .mouse_down(DesktopMouseDownRequest { x, y, button }) + .await + { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + } + } + Ok(DesktopStreamClientFrame::MouseUp { x, y, button }) => { + if let Err(err) = desktop_runtime + .mouse_up(DesktopMouseUpRequest { x, y, button }) + .await + { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + } + } + Ok(DesktopStreamClientFrame::Scroll { x, y, delta_x, delta_y }) => { + if let Err(err) = desktop_runtime + .scroll_mouse(DesktopMouseScrollRequest { + x, + y, + delta_x, + delta_y, + }) + .await + { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + } + } + Ok(DesktopStreamClientFrame::KeyDown { key }) => { + if let Err(err) = desktop_runtime + .key_down(DesktopKeyboardDownRequest { key }) + .await + { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + } + } + Ok(DesktopStreamClientFrame::KeyUp { key }) => { + if let Err(err) = desktop_runtime + .key_up(DesktopKeyboardUpRequest { key }) + .await + { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + } + } + Ok(DesktopStreamClientFrame::Close) => { + let _ = socket.close().await; + break; + } + Err(err) => { + let _ = send_ws_error(&mut socket, &format!("invalid desktop stream frame: {err}")).await; + } + } + } + Some(Ok(Message::Ping(payload))) => { + let _ = socket.send(Message::Pong(payload)).await; + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Binary(_))) | Some(Ok(Message::Pong(_))) => {} + Some(Err(_)) => break, + } + } + _ = frame_tick.tick() => { + let frame = desktop_runtime + .screenshot(DesktopScreenshotQuery { + format: Some(DesktopScreenshotFormat::Jpeg), + quality: Some(60), + scale: Some(1.0), + }) + .await; + match frame { + Ok(frame) => { + if socket.send(Message::Binary(frame.bytes.into())).await.is_err() { + break; + } + } + Err(err) => { + let _ = send_ws_error(&mut socket, &err.to_error_info().message).await; + let _ = socket.close().await; + break; + } + } + } + } + } +} + async fn send_ws_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> { socket .send(Message::Text( @@ -2171,6 +3098,14 @@ fn into_runtime_process_config(config: ProcessConfig) -> ProcessRuntimeConfig { } } +fn into_runtime_process_owner(owner: ProcessOwner) -> RuntimeProcessOwner { + match owner { + ProcessOwner::User => RuntimeProcessOwner::User, + ProcessOwner::Desktop => RuntimeProcessOwner::Desktop, + ProcessOwner::System => RuntimeProcessOwner::System, + } +} + fn map_process_snapshot(snapshot: ProcessSnapshot) -> ProcessInfo { ProcessInfo { id: snapshot.id, @@ -2179,6 +3114,11 @@ fn map_process_snapshot(snapshot: ProcessSnapshot) -> ProcessInfo { cwd: snapshot.cwd, tty: snapshot.tty, interactive: snapshot.interactive, + owner: match snapshot.owner { + RuntimeProcessOwner::User => ProcessOwner::User, + RuntimeProcessOwner::Desktop => ProcessOwner::Desktop, + RuntimeProcessOwner::System => ProcessOwner::System, + }, status: match snapshot.status { ProcessStatus::Running => ProcessState::Running, ProcessStatus::Exited => ProcessState::Exited, diff --git a/server/packages/sandbox-agent/src/router/support.rs b/server/packages/sandbox-agent/src/router/support.rs index 0e7a7b1..6bcc103 100644 --- a/server/packages/sandbox-agent/src/router/support.rs +++ b/server/packages/sandbox-agent/src/router/support.rs @@ -33,7 +33,8 @@ pub(super) async fn require_token( .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")); - let allow_query_token = request.uri().path().ends_with("/terminal/ws"); + let allow_query_token = request.uri().path().ends_with("/terminal/ws") + || request.uri().path().ends_with("/stream/ws"); let query_token = if allow_query_token { request .uri() diff --git a/server/packages/sandbox-agent/src/router/types.rs b/server/packages/sandbox-agent/src/router/types.rs index 6d40e2a..218ad77 100644 --- a/server/packages/sandbox-agent/src/router/types.rs +++ b/server/packages/sandbox-agent/src/router/types.rs @@ -425,6 +425,14 @@ pub enum ProcessState { Exited, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProcessOwner { + User, + Desktop, + System, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProcessInfo { @@ -435,6 +443,7 @@ pub struct ProcessInfo { pub cwd: Option, pub tty: bool, pub interactive: bool, + pub owner: ProcessOwner, pub status: ProcessState, #[serde(default, skip_serializing_if = "Option::is_none")] pub pid: Option, @@ -451,6 +460,13 @@ pub struct ProcessListResponse { pub processes: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct ProcessListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ProcessLogsStream { diff --git a/server/packages/sandbox-agent/tests/support/docker.rs b/server/packages/sandbox-agent/tests/support/docker.rs new file mode 100644 index 0000000..9305d95 --- /dev/null +++ b/server/packages/sandbox-agent/tests/support/docker.rs @@ -0,0 +1,593 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use sandbox_agent::router::AuthConfig; +use serial_test::serial; +use tempfile::TempDir; + +const CONTAINER_PORT: u16 = 3000; +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const DEFAULT_IMAGE_TAG: &str = "sandbox-agent-test:dev"; +const STANDARD_PATHS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", +]; + +static IMAGE_TAG: OnceLock = OnceLock::new(); +static DOCKER_BIN: OnceLock = OnceLock::new(); +static CONTAINER_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone)] +pub struct DockerApp { + base_url: String, +} + +impl DockerApp { + pub fn http_url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + pub fn ws_url(&self, path: &str) -> String { + let suffix = self + .base_url + .strip_prefix("http://") + .unwrap_or(&self.base_url); + format!("ws://{suffix}{path}") + } +} + +pub struct TestApp { + pub app: DockerApp, + install_dir: PathBuf, + _root: TempDir, + container_id: String, +} + +#[derive(Default)] +pub struct TestAppOptions { + pub env: BTreeMap, + pub extra_paths: Vec, + pub replace_path: bool, +} + +impl TestApp { + pub fn new(auth: AuthConfig) -> Self { + Self::with_setup(auth, |_| {}) + } + + pub fn with_setup(auth: AuthConfig, setup: F) -> Self + where + F: FnOnce(&Path), + { + Self::with_options(auth, TestAppOptions::default(), setup) + } + + pub fn with_options(auth: AuthConfig, options: TestAppOptions, setup: F) -> Self + where + F: FnOnce(&Path), + { + let root = tempfile::tempdir().expect("create docker test root"); + let layout = TestLayout::new(root.path()); + layout.create(); + setup(&layout.install_dir); + + let container_id = unique_container_id(); + let image = ensure_test_image(); + let env = build_env(&layout, &auth, &options); + let mounts = build_mounts(root.path(), &env); + let base_url = run_container(&container_id, &image, &mounts, &env, &auth); + + Self { + app: DockerApp { base_url }, + install_dir: layout.install_dir, + _root: root, + container_id, + } + } + + pub fn install_path(&self) -> &Path { + &self.install_dir + } + + pub fn root_path(&self) -> &Path { + self._root.path() + } +} + +impl Drop for TestApp { + fn drop(&mut self) { + let _ = Command::new(docker_bin()) + .args(["rm", "-f", &self.container_id]) + .output(); + } +} + +pub struct LiveServer { + base_url: String, +} + +impl LiveServer { + pub async fn spawn(app: DockerApp) -> Self { + Self { + base_url: app.base_url, + } + } + + pub fn http_url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + pub fn ws_url(&self, path: &str) -> String { + let suffix = self + .base_url + .strip_prefix("http://") + .unwrap_or(&self.base_url); + format!("ws://{suffix}{path}") + } + + pub async fn shutdown(self) {} +} + +struct TestLayout { + home: PathBuf, + xdg_data_home: PathBuf, + xdg_state_home: PathBuf, + appdata: PathBuf, + local_appdata: PathBuf, + install_dir: PathBuf, +} + +impl TestLayout { + fn new(root: &Path) -> Self { + let home = root.join("home"); + let xdg_data_home = root.join("xdg-data"); + let xdg_state_home = root.join("xdg-state"); + let appdata = root.join("appdata").join("Roaming"); + let local_appdata = root.join("appdata").join("Local"); + let install_dir = xdg_data_home.join("sandbox-agent").join("bin"); + Self { + home, + xdg_data_home, + xdg_state_home, + appdata, + local_appdata, + install_dir, + } + } + + fn create(&self) { + for dir in [ + &self.home, + &self.xdg_data_home, + &self.xdg_state_home, + &self.appdata, + &self.local_appdata, + &self.install_dir, + ] { + fs::create_dir_all(dir).expect("create docker test dir"); + } + } +} + +fn ensure_test_image() -> String { + IMAGE_TAG + .get_or_init(|| { + let repo_root = repo_root(); + let image_tag = std::env::var("SANDBOX_AGENT_TEST_IMAGE") + .unwrap_or_else(|_| DEFAULT_IMAGE_TAG.to_string()); + let output = Command::new(docker_bin()) + .args(["build", "--tag", &image_tag, "--file"]) + .arg( + repo_root + .join("docker") + .join("test-agent") + .join("Dockerfile"), + ) + .arg(&repo_root) + .output() + .expect("build sandbox-agent test image"); + if !output.status.success() { + panic!( + "failed to build sandbox-agent test image: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + image_tag + }) + .clone() +} + +fn build_env( + layout: &TestLayout, + auth: &AuthConfig, + options: &TestAppOptions, +) -> BTreeMap { + let mut env = BTreeMap::new(); + env.insert( + "HOME".to_string(), + layout.home.to_string_lossy().to_string(), + ); + env.insert( + "USERPROFILE".to_string(), + layout.home.to_string_lossy().to_string(), + ); + env.insert( + "XDG_DATA_HOME".to_string(), + layout.xdg_data_home.to_string_lossy().to_string(), + ); + env.insert( + "XDG_STATE_HOME".to_string(), + layout.xdg_state_home.to_string_lossy().to_string(), + ); + env.insert( + "APPDATA".to_string(), + layout.appdata.to_string_lossy().to_string(), + ); + env.insert( + "LOCALAPPDATA".to_string(), + layout.local_appdata.to_string_lossy().to_string(), + ); + + for (key, value) in std::env::vars() { + if key == "PATH" { + continue; + } + if key == "XDG_STATE_HOME" || key == "HOME" || key == "USERPROFILE" { + continue; + } + if key.starts_with("SANDBOX_AGENT_") || key.starts_with("OPENCODE_COMPAT_") { + env.insert(key.clone(), rewrite_localhost_url(&key, &value)); + } + } + + if let Some(token) = auth.token.as_ref() { + env.insert("SANDBOX_AGENT_TEST_AUTH_TOKEN".to_string(), token.clone()); + } + + if options.replace_path { + env.insert( + "PATH".to_string(), + options.env.get("PATH").cloned().unwrap_or_default(), + ); + } else { + let mut custom_path_entries = + custom_path_entries(layout.install_dir.parent().expect("install base")); + custom_path_entries.extend(explicit_path_entries()); + custom_path_entries.extend( + options + .extra_paths + .iter() + .filter(|path| path.is_absolute() && path.exists()) + .cloned(), + ); + custom_path_entries.sort(); + custom_path_entries.dedup(); + + if custom_path_entries.is_empty() { + env.insert("PATH".to_string(), DEFAULT_PATH.to_string()); + } else { + let joined = custom_path_entries + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>() + .join(":"); + env.insert("PATH".to_string(), format!("{joined}:{DEFAULT_PATH}")); + } + } + + for (key, value) in &options.env { + if key == "PATH" { + continue; + } + env.insert(key.clone(), rewrite_localhost_url(key, value)); + } + + env +} + +fn build_mounts(root: &Path, env: &BTreeMap) -> Vec { + let mut mounts = BTreeSet::new(); + mounts.insert(root.to_path_buf()); + + for key in [ + "HOME", + "USERPROFILE", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "APPDATA", + "LOCALAPPDATA", + "SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR", + ] { + if let Some(value) = env.get(key) { + let path = PathBuf::from(value); + if path.is_absolute() { + mounts.insert(path); + } + } + } + + if let Some(path_value) = env.get("PATH") { + for entry in path_value.split(':') { + if entry.is_empty() || STANDARD_PATHS.contains(&entry) { + continue; + } + let path = PathBuf::from(entry); + if path.is_absolute() && path.exists() { + mounts.insert(path); + } + } + } + + mounts.into_iter().collect() +} + +fn run_container( + container_id: &str, + image: &str, + mounts: &[PathBuf], + env: &BTreeMap, + auth: &AuthConfig, +) -> String { + let mut args = vec![ + "run".to_string(), + "-d".to_string(), + "--rm".to_string(), + "--name".to_string(), + container_id.to_string(), + "-p".to_string(), + format!("127.0.0.1::{CONTAINER_PORT}"), + ]; + + #[cfg(unix)] + { + args.push("--user".to_string()); + args.push(format!("{}:{}", unsafe { libc::geteuid() }, unsafe { + libc::getegid() + })); + } + + if cfg!(target_os = "linux") { + args.push("--add-host".to_string()); + args.push("host.docker.internal:host-gateway".to_string()); + } + + for mount in mounts { + args.push("-v".to_string()); + args.push(format!("{}:{}", mount.display(), mount.display())); + } + + for (key, value) in env { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + + args.push(image.to_string()); + args.push("server".to_string()); + args.push("--host".to_string()); + args.push("0.0.0.0".to_string()); + args.push("--port".to_string()); + args.push(CONTAINER_PORT.to_string()); + match auth.token.as_ref() { + Some(token) => { + args.push("--token".to_string()); + args.push(token.clone()); + } + None => args.push("--no-token".to_string()), + } + + let output = Command::new(docker_bin()) + .args(&args) + .output() + .expect("start docker test container"); + if !output.status.success() { + panic!( + "failed to start docker test container: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let port_output = Command::new(docker_bin()) + .args(["port", container_id, &format!("{CONTAINER_PORT}/tcp")]) + .output() + .expect("resolve mapped docker port"); + if !port_output.status.success() { + panic!( + "failed to resolve docker test port: {}", + String::from_utf8_lossy(&port_output.stderr) + ); + } + + let mapping = String::from_utf8(port_output.stdout) + .expect("docker port utf8") + .trim() + .to_string(); + let host_port = mapping.rsplit(':').next().expect("mapped host port").trim(); + let base_url = format!("http://127.0.0.1:{host_port}"); + wait_for_health(&base_url, auth.token.as_deref()); + base_url +} + +fn wait_for_health(base_url: &str, token: Option<&str>) { + let started = SystemTime::now(); + loop { + if probe_health(base_url, token) { + return; + } + + if started + .elapsed() + .unwrap_or_else(|_| Duration::from_secs(0)) + .gt(&Duration::from_secs(30)) + { + panic!("timed out waiting for sandbox-agent docker test server"); + } + thread::sleep(Duration::from_millis(200)); + } +} + +fn probe_health(base_url: &str, token: Option<&str>) -> bool { + let address = base_url.strip_prefix("http://").unwrap_or(base_url); + let mut stream = match TcpStream::connect(address) { + Ok(stream) => stream, + Err(_) => return false, + }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); + + let mut request = + format!("GET /v1/health HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n"); + if let Some(token) = token { + request.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + request.push_str("\r\n"); + + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + + let mut response = String::new(); + if stream.read_to_string(&mut response).is_err() { + return false; + } + + response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") +} + +fn custom_path_entries(root: &Path) -> Vec { + let mut entries = Vec::new(); + if let Some(value) = std::env::var_os("PATH") { + for entry in std::env::split_paths(&value) { + if !entry.exists() { + continue; + } + if entry.starts_with(root) || entry.starts_with(std::env::temp_dir()) { + entries.push(entry); + } + } + } + entries.sort(); + entries.dedup(); + entries +} + +fn explicit_path_entries() -> Vec { + let mut entries = Vec::new(); + if let Some(value) = std::env::var_os("SANDBOX_AGENT_TEST_EXTRA_PATHS") { + for entry in std::env::split_paths(&value) { + if entry.is_absolute() && entry.exists() { + entries.push(entry); + } + } + } + entries +} + +fn rewrite_localhost_url(key: &str, value: &str) -> String { + if key.ends_with("_URL") || key.ends_with("_URI") { + return value + .replace("http://127.0.0.1", "http://host.docker.internal") + .replace("http://localhost", "http://host.docker.internal"); + } + value.to_string() +} + +fn unique_container_id() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or(0); + let counter = CONTAINER_COUNTER.fetch_add(1, Ordering::Relaxed); + format!( + "sandbox-agent-test-{}-{millis}-{counter}", + std::process::id() + ) +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize() + .expect("repo root") +} + +fn docker_bin() -> &'static Path { + DOCKER_BIN + .get_or_init(|| { + if let Some(value) = std::env::var_os("SANDBOX_AGENT_TEST_DOCKER_BIN") { + let path = PathBuf::from(value); + if path.exists() { + return path; + } + } + + for candidate in [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/usr/bin/docker", + ] { + let path = PathBuf::from(candidate); + if path.exists() { + return path; + } + } + + PathBuf::from("docker") + }) + .as_path() +} + +#[cfg(test)] +mod tests { + use super::*; + + struct EnvVarGuard { + key: &'static str, + old: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let old = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, old } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.old.as_ref() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + #[test] + #[serial] + fn build_env_keeps_test_local_xdg_state_home() { + let root = tempfile::tempdir().expect("create docker support tempdir"); + let host_state = tempfile::tempdir().expect("create host xdg state tempdir"); + let _guard = EnvVarGuard::set("XDG_STATE_HOME", host_state.path()); + + let layout = TestLayout::new(root.path()); + layout.create(); + + let env = build_env(&layout, &AuthConfig::disabled(), &TestAppOptions::default()); + assert_eq!( + env.get("XDG_STATE_HOME"), + Some(&layout.xdg_state_home.to_string_lossy().to_string()) + ); + } +} diff --git a/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs b/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs index 029ca25..fc88c4c 100644 --- a/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs +++ b/server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs @@ -1,37 +1,14 @@ use std::fs; use std::path::Path; -use axum::body::Body; -use axum::http::{Method, Request, StatusCode}; use futures::StreamExt; -use http_body_util::BodyExt; -use sandbox_agent::router::{build_router, AppState, AuthConfig}; -use sandbox_agent_agent_management::agents::AgentManager; +use reqwest::{Method, StatusCode}; +use sandbox_agent::router::AuthConfig; use serde_json::{json, Value}; -use tempfile::TempDir; -use tower::util::ServiceExt; -struct TestApp { - app: axum::Router, - _install_dir: TempDir, -} - -impl TestApp { - fn with_setup(setup: F) -> Self - where - F: FnOnce(&Path), - { - let install_dir = tempfile::tempdir().expect("create temp install dir"); - setup(install_dir.path()); - let manager = AgentManager::new(install_dir.path()).expect("create agent manager"); - let state = AppState::new(AuthConfig::disabled(), manager); - let app = build_router(state); - Self { - app, - _install_dir: install_dir, - } - } -} +#[path = "support/docker.rs"] +mod docker_support; +use docker_support::TestApp; fn write_executable(path: &Path, script: &str) { fs::write(path, script).expect("write executable"); @@ -101,28 +78,29 @@ fn setup_stub_agent_process_only(install_dir: &Path, agent: &str) { } async fn send_request( - app: &axum::Router, + app: &docker_support::DockerApp, method: Method, uri: &str, body: Option, ) -> (StatusCode, Vec) { - let mut builder = Request::builder().method(method).uri(uri); - let request_body = if let Some(body) = body { - builder = builder.header("content-type", "application/json"); - Body::from(body.to_string()) + let client = reqwest::Client::new(); + let response = if let Some(body) = body { + client + .request(method, app.http_url(uri)) + .header("content-type", "application/json") + .body(body.to_string()) + .send() + .await + .expect("request handled") } else { - Body::empty() + client + .request(method, app.http_url(uri)) + .send() + .await + .expect("request handled") }; - - let request = builder.body(request_body).expect("build request"); - let response = app.clone().oneshot(request).await.expect("request handled"); let status = response.status(); - let bytes = response - .into_body() - .collect() - .await - .expect("collect body") - .to_bytes(); + let bytes = response.bytes().await.expect("collect body"); (status, bytes.to_vec()) } @@ -145,7 +123,7 @@ async fn agent_process_matrix_smoke_and_jsonrpc_conformance() { .chain(agent_process_only_agents.iter()) .copied() .collect(); - let test_app = TestApp::with_setup(|install_dir| { + let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| { for agent in native_agents { setup_stub_artifacts(install_dir, agent); } @@ -201,21 +179,15 @@ async fn agent_process_matrix_smoke_and_jsonrpc_conformance() { assert_eq!(new_json["id"], 2, "{agent}: session/new id"); assert_eq!(new_json["result"]["echoedMethod"], "session/new"); - let request = Request::builder() - .method(Method::GET) - .uri(format!("/v1/acp/{agent}-server")) - .body(Body::empty()) - .expect("build sse request"); - - let response = test_app - .app - .clone() - .oneshot(request) + let response = reqwest::Client::new() + .get(test_app.app.http_url(&format!("/v1/acp/{agent}-server"))) + .header("accept", "text/event-stream") + .send() .await .expect("sse response"); assert_eq!(response.status(), StatusCode::OK); - let mut stream = response.into_body().into_data_stream(); + let mut stream = response.bytes_stream(); let chunk = tokio::time::timeout(std::time::Duration::from_secs(5), async move { while let Some(item) = stream.next().await { let bytes = item.expect("sse chunk"); diff --git a/server/packages/sandbox-agent/tests/v1_api.rs b/server/packages/sandbox-agent/tests/v1_api.rs index fa572e6..02558a7 100644 --- a/server/packages/sandbox-agent/tests/v1_api.rs +++ b/server/packages/sandbox-agent/tests/v1_api.rs @@ -1,128 +1,19 @@ use std::fs; use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::time::Duration; -use axum::body::Body; -use axum::http::{header, HeaderMap, Method, Request, StatusCode}; -use axum::Router; use futures::StreamExt; -use http_body_util::BodyExt; -use sandbox_agent::router::{build_router, AppState, AuthConfig}; -use sandbox_agent_agent_management::agents::AgentManager; +use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue}; +use reqwest::{Method, StatusCode}; +use sandbox_agent::router::AuthConfig; use serde_json::{json, Value}; use serial_test::serial; -use tempfile::TempDir; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tower::util::ServiceExt; -struct TestApp { - app: Router, - install_dir: TempDir, -} - -impl TestApp { - fn new(auth: AuthConfig) -> Self { - Self::with_setup(auth, |_| {}) - } - - fn with_setup(auth: AuthConfig, setup: F) -> Self - where - F: FnOnce(&Path), - { - let install_dir = tempfile::tempdir().expect("create temp install dir"); - setup(install_dir.path()); - let manager = AgentManager::new(install_dir.path()).expect("create agent manager"); - let state = AppState::new(auth, manager); - let app = build_router(state); - Self { app, install_dir } - } - - fn install_path(&self) -> &Path { - self.install_dir.path() - } -} - -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -struct LiveServer { - address: SocketAddr, - shutdown_tx: Option>, - task: JoinHandle<()>, -} - -impl LiveServer { - async fn spawn(app: Router) -> Self { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind live server"); - let address = listener.local_addr().expect("live server address"); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - - let task = tokio::spawn(async move { - let server = - axum::serve(listener, app.into_make_service()).with_graceful_shutdown(async { - let _ = shutdown_rx.await; - }); - - let _ = server.await; - }); - - Self { - address, - shutdown_tx: Some(shutdown_tx), - task, - } - } - - fn http_url(&self, path: &str) -> String { - format!("http://{}{}", self.address, path) - } - - fn ws_url(&self, path: &str) -> String { - format!("ws://{}{}", self.address, path) - } - - async fn shutdown(mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - - let _ = tokio::time::timeout(Duration::from_secs(3), async { - let _ = self.task.await; - }) - .await; - } -} - -impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - - fn set_os(key: &'static str, value: &std::ffi::OsStr) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(previous) = self.previous.as_ref() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } -} +#[path = "support/docker.rs"] +mod docker_support; +use docker_support::{LiveServer, TestApp}; fn write_executable(path: &Path, script: &str) { fs::write(path, script).expect("write executable"); @@ -168,17 +59,18 @@ exit 0 } fn serve_registry_once(document: Value) -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server"); - let address = listener.local_addr().expect("registry address"); + let listener = TcpListener::bind("0.0.0.0:0").expect("bind registry server"); + let port = listener.local_addr().expect("registry address").port(); let body = document.to_string(); - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - respond_json(&mut stream, &body); + std::thread::spawn(move || loop { + match listener.accept() { + Ok((mut stream, _)) => respond_json(&mut stream, &body), + Err(_) => break, } }); - format!("http://{address}/registry.json") + format!("http://127.0.0.1:{port}/registry.json") } fn respond_json(stream: &mut TcpStream, body: &str) { @@ -196,74 +88,96 @@ fn respond_json(stream: &mut TcpStream, body: &str) { } async fn send_request( - app: &Router, + app: &docker_support::DockerApp, method: Method, uri: &str, body: Option, headers: &[(&str, &str)], ) -> (StatusCode, HeaderMap, Vec) { - let mut builder = Request::builder().method(method).uri(uri); + let client = reqwest::Client::new(); + let mut builder = client.request(method, app.http_url(uri)); for (name, value) in headers { - builder = builder.header(*name, *value); + let header_name = HeaderName::from_bytes(name.as_bytes()).expect("header name"); + let header_value = HeaderValue::from_str(value).expect("header value"); + builder = builder.header(header_name, header_value); } - let request_body = if let Some(body) = body { - builder = builder.header(header::CONTENT_TYPE, "application/json"); - Body::from(body.to_string()) + let response = if let Some(body) = body { + builder + .header(header::CONTENT_TYPE, "application/json") + .body(body.to_string()) + .send() + .await + .expect("request handled") } else { - Body::empty() + builder.send().await.expect("request handled") }; - - let request = builder.body(request_body).expect("build request"); - let response = app.clone().oneshot(request).await.expect("request handled"); let status = response.status(); let headers = response.headers().clone(); - let bytes = response - .into_body() - .collect() - .await - .expect("collect body") - .to_bytes(); + let bytes = response.bytes().await.expect("collect body"); (status, headers, bytes.to_vec()) } async fn send_request_raw( - app: &Router, + app: &docker_support::DockerApp, method: Method, uri: &str, body: Option>, headers: &[(&str, &str)], content_type: Option<&str>, ) -> (StatusCode, HeaderMap, Vec) { - let mut builder = Request::builder().method(method).uri(uri); + let client = reqwest::Client::new(); + let mut builder = client.request(method, app.http_url(uri)); for (name, value) in headers { - builder = builder.header(*name, *value); + let header_name = HeaderName::from_bytes(name.as_bytes()).expect("header name"); + let header_value = HeaderValue::from_str(value).expect("header value"); + builder = builder.header(header_name, header_value); } - let request_body = if let Some(body) = body { + let response = if let Some(body) = body { if let Some(content_type) = content_type { builder = builder.header(header::CONTENT_TYPE, content_type); } - Body::from(body) + builder.body(body).send().await.expect("request handled") } else { - Body::empty() + builder.send().await.expect("request handled") }; - - let request = builder.body(request_body).expect("build request"); - let response = app.clone().oneshot(request).await.expect("request handled"); let status = response.status(); let headers = response.headers().clone(); - let bytes = response - .into_body() - .collect() - .await - .expect("collect body") - .to_bytes(); + let bytes = response.bytes().await.expect("collect body"); (status, headers, bytes.to_vec()) } +async fn launch_desktop_focus_window(app: &docker_support::DockerApp, display: &str) { + let command = r#"nohup xterm -geometry 80x24+40+40 -title 'Sandbox Desktop Test' -e sh -lc 'sleep 60' >/tmp/sandbox-agent-xterm.log 2>&1 < /dev/null & for _ in $(seq 1 50); do wid="$(xdotool search --onlyvisible --name 'Sandbox Desktop Test' 2>/dev/null | head -n 1 || true)"; if [ -n "$wid" ]; then xdotool windowactivate "$wid"; exit 0; fi; sleep 0.1; done; exit 1"#; + let (status, _, body) = send_request( + app, + Method::POST, + "/v1/processes/run", + Some(json!({ + "command": "sh", + "args": ["-lc", command], + "env": { + "DISPLAY": display, + }, + "timeoutMs": 10_000 + })), + &[], + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected desktop focus window launch response: {}", + String::from_utf8_lossy(&body) + ); + let parsed = parse_json(&body); + assert_eq!(parsed["exitCode"], 0); +} + fn parse_json(bytes: &[u8]) -> Value { if bytes.is_empty() { Value::Null @@ -284,7 +198,7 @@ fn initialize_payload() -> Value { }) } -async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) { +async fn bootstrap_server(app: &docker_support::DockerApp, server_id: &str, agent: &str) { let initialize = initialize_payload(); let (status, _, _body) = send_request( app, @@ -297,17 +211,17 @@ async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) { assert_eq!(status, StatusCode::OK); } -async fn read_first_sse_data(app: &Router, server_id: &str) -> String { - let request = Request::builder() - .method(Method::GET) - .uri(format!("/v1/acp/{server_id}")) - .body(Body::empty()) - .expect("build request"); - - let response = app.clone().oneshot(request).await.expect("sse response"); +async fn read_first_sse_data(app: &docker_support::DockerApp, server_id: &str) -> String { + let client = reqwest::Client::new(); + let response = client + .get(app.http_url(&format!("/v1/acp/{server_id}"))) + .header("accept", "text/event-stream") + .send() + .await + .expect("sse response"); assert_eq!(response.status(), StatusCode::OK); - let mut stream = response.into_body().into_data_stream(); + let mut stream = response.bytes_stream(); tokio::time::timeout(Duration::from_secs(5), async move { while let Some(chunk) = stream.next().await { let bytes = chunk.expect("stream chunk"); @@ -323,21 +237,21 @@ async fn read_first_sse_data(app: &Router, server_id: &str) -> String { } async fn read_first_sse_data_with_last_id( - app: &Router, + app: &docker_support::DockerApp, server_id: &str, last_event_id: u64, ) -> String { - let request = Request::builder() - .method(Method::GET) - .uri(format!("/v1/acp/{server_id}")) + let client = reqwest::Client::new(); + let response = client + .get(app.http_url(&format!("/v1/acp/{server_id}"))) + .header("accept", "text/event-stream") .header("last-event-id", last_event_id.to_string()) - .body(Body::empty()) - .expect("build request"); - - let response = app.clone().oneshot(request).await.expect("sse response"); + .send() + .await + .expect("sse response"); assert_eq!(response.status(), StatusCode::OK); - let mut stream = response.into_body().into_data_stream(); + let mut stream = response.bytes_stream(); tokio::time::timeout(Duration::from_secs(5), async move { while let Some(chunk) = stream.next().await { let bytes = chunk.expect("stream chunk"); @@ -375,5 +289,7 @@ mod acp_transport; mod config_endpoints; #[path = "v1_api/control_plane.rs"] mod control_plane; +#[path = "v1_api/desktop.rs"] +mod desktop; #[path = "v1_api/processes.rs"] mod processes; diff --git a/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs b/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs index 3aec8ca..e212c86 100644 --- a/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs +++ b/server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs @@ -22,8 +22,9 @@ async fn mcp_config_requires_directory_and_name() { #[tokio::test] async fn mcp_config_crud_round_trip() { let test_app = TestApp::new(AuthConfig::disabled()); - let project = tempfile::tempdir().expect("tempdir"); - let directory = project.path().to_string_lossy().to_string(); + let project = test_app.root_path().join("mcp-config-project"); + fs::create_dir_all(&project).expect("create project dir"); + let directory = project.to_string_lossy().to_string(); let entry = json!({ "type": "local", @@ -99,8 +100,9 @@ async fn skills_config_requires_directory_and_name() { #[tokio::test] async fn skills_config_crud_round_trip() { let test_app = TestApp::new(AuthConfig::disabled()); - let project = tempfile::tempdir().expect("tempdir"); - let directory = project.path().to_string_lossy().to_string(); + let project = test_app.root_path().join("skills-config-project"); + fs::create_dir_all(&project).expect("create project dir"); + let directory = project.to_string_lossy().to_string(); let entry = json!({ "sources": [ diff --git a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs index dc352ca..fdd4131 100644 --- a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs +++ b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs @@ -1,4 +1,5 @@ use super::*; +use std::collections::BTreeMap; #[tokio::test] async fn v1_health_removed_legacy_and_opencode_unmounted() { @@ -137,10 +138,19 @@ async fn v1_filesystem_endpoints_round_trip() { #[tokio::test] #[serial] async fn require_preinstall_blocks_missing_agent() { - let test_app = { - let _preinstall = EnvVarGuard::set("SANDBOX_AGENT_REQUIRE_PREINSTALL", "true"); - TestApp::new(AuthConfig::disabled()) - }; + let mut env = BTreeMap::new(); + env.insert( + "SANDBOX_AGENT_REQUIRE_PREINSTALL".to_string(), + "true".to_string(), + ); + let test_app = TestApp::with_options( + AuthConfig::disabled(), + docker_support::TestAppOptions { + env, + ..Default::default() + }, + |_| {}, + ); let (status, _, body) = send_request( &test_app.app, @@ -176,20 +186,26 @@ async fn lazy_install_runs_on_first_bootstrap() { ] })); - let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", ®istry_url); - let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| { - fs::create_dir_all(install_path.join("agent_processes")) - .expect("create agent processes dir"); - write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n"); - fs::create_dir_all(install_path.join("bin")).expect("create bin dir"); - write_fake_npm(&install_path.join("bin").join("npm")); - }); + let helper_bin_root = tempfile::tempdir().expect("helper bin tempdir"); + let helper_bin = helper_bin_root.path().join("bin"); + fs::create_dir_all(&helper_bin).expect("create helper bin dir"); + write_fake_npm(&helper_bin.join("npm")); - let original_path = std::env::var_os("PATH").unwrap_or_default(); - let mut paths = vec![test_app.install_path().join("bin")]; - paths.extend(std::env::split_paths(&original_path)); - let merged_path = std::env::join_paths(paths).expect("join PATH"); - let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str()); + let mut env = BTreeMap::new(); + env.insert("SANDBOX_AGENT_ACP_REGISTRY_URL".to_string(), registry_url); + let test_app = TestApp::with_options( + AuthConfig::disabled(), + docker_support::TestAppOptions { + env, + extra_paths: vec![helper_bin.clone()], + ..Default::default() + }, + |install_path| { + fs::create_dir_all(install_path.join("agent_processes")) + .expect("create agent processes dir"); + write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n"); + }, + ); let (status, _, _) = send_request( &test_app.app, diff --git a/server/packages/sandbox-agent/tests/v1_api/desktop.rs b/server/packages/sandbox-agent/tests/v1_api/desktop.rs new file mode 100644 index 0000000..76d9389 --- /dev/null +++ b/server/packages/sandbox-agent/tests/v1_api/desktop.rs @@ -0,0 +1,494 @@ +use super::*; +use futures::{SinkExt, StreamExt}; +use serial_test::serial; +use std::collections::BTreeMap; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; + +fn png_dimensions(bytes: &[u8]) -> (u32, u32) { + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); + let width = u32::from_be_bytes(bytes[16..20].try_into().expect("png width bytes")); + let height = u32::from_be_bytes(bytes[20..24].try_into().expect("png height bytes")); + (width, height) +} + +async fn recv_ws_message( + ws: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> Message { + tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("timed out waiting for websocket frame") + .expect("websocket stream ended") + .expect("websocket frame") +} + +#[tokio::test] +#[serial] +async fn v1_desktop_status_reports_install_required_when_dependencies_are_missing() { + let temp = tempfile::tempdir().expect("create empty path tempdir"); + let mut env = BTreeMap::new(); + env.insert( + "PATH".to_string(), + temp.path().to_string_lossy().to_string(), + ); + + let test_app = TestApp::with_options( + AuthConfig::disabled(), + docker_support::TestAppOptions { + env, + replace_path: true, + ..Default::default() + }, + |_| {}, + ); + + let (status, _, body) = + send_request(&test_app.app, Method::GET, "/v1/desktop/status", None, &[]).await; + + assert_eq!(status, StatusCode::OK); + let parsed = parse_json(&body); + assert_eq!(parsed["state"], "install_required"); + assert!(parsed["missingDependencies"] + .as_array() + .expect("missingDependencies array") + .iter() + .any(|value| value == "Xvfb")); + assert_eq!( + parsed["installCommand"], + "sandbox-agent install desktop --yes" + ); +} + +#[tokio::test] +#[serial] +async fn v1_desktop_lifecycle_and_actions_work_with_real_runtime() { + let test_app = TestApp::new(AuthConfig::disabled()); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/start", + Some(json!({ + "width": 1440, + "height": 900, + "dpi": 96 + })), + &[], + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "unexpected start response: {}", + String::from_utf8_lossy(&body) + ); + let parsed = parse_json(&body); + assert_eq!(parsed["state"], "active"); + let display = parsed["display"] + .as_str() + .expect("desktop display") + .to_string(); + assert!(display.starts_with(':')); + assert_eq!(parsed["resolution"]["width"], 1440); + assert_eq!(parsed["resolution"]["height"], 900); + + let (status, headers, body) = send_request_raw( + &test_app.app, + Method::GET, + "/v1/desktop/screenshot", + None, + &[], + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("image/png") + ); + assert!(body.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(png_dimensions(&body), (1440, 900)); + + let (status, headers, body) = send_request_raw( + &test_app.app, + Method::GET, + "/v1/desktop/screenshot?format=jpeg&quality=50", + None, + &[], + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("image/jpeg") + ); + assert!(body.starts_with(&[0xff, 0xd8, 0xff])); + + let (status, headers, body) = send_request_raw( + &test_app.app, + Method::GET, + "/v1/desktop/screenshot?scale=0.5", + None, + &[], + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("image/png") + ); + assert_eq!(png_dimensions(&body), (720, 450)); + + let (status, _, body) = send_request_raw( + &test_app.app, + Method::GET, + "/v1/desktop/screenshot/region?x=10&y=20&width=30&height=40", + None, + &[], + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body.starts_with(b"\x89PNG\r\n\x1a\n")); + + let (status, _, body) = send_request( + &test_app.app, + Method::GET, + "/v1/desktop/display/info", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let display_info = parse_json(&body); + assert_eq!(display_info["display"], display); + assert_eq!(display_info["resolution"]["width"], 1440); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/move", + Some(json!({ "x": 400, "y": 300 })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let mouse = parse_json(&body); + assert_eq!(mouse["x"], 400); + assert_eq!(mouse["y"], 300); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/drag", + Some(json!({ + "startX": 100, + "startY": 110, + "endX": 220, + "endY": 230, + "button": "left" + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let dragged = parse_json(&body); + assert_eq!(dragged["x"], 220); + assert_eq!(dragged["y"], 230); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/click", + Some(json!({ + "x": 220, + "y": 230, + "button": "left", + "clickCount": 1 + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let clicked = parse_json(&body); + assert_eq!(clicked["x"], 220); + assert_eq!(clicked["y"], 230); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/down", + Some(json!({ + "x": 220, + "y": 230, + "button": "left" + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let mouse_down = parse_json(&body); + assert_eq!(mouse_down["x"], 220); + assert_eq!(mouse_down["y"], 230); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/move", + Some(json!({ "x": 260, "y": 280 })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let moved_while_down = parse_json(&body); + assert_eq!(moved_while_down["x"], 260); + assert_eq!(moved_while_down["y"], 280); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/up", + Some(json!({ "button": "left" })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let mouse_up = parse_json(&body); + assert_eq!(mouse_up["x"], 260); + assert_eq!(mouse_up["y"], 280); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/mouse/scroll", + Some(json!({ + "x": 220, + "y": 230, + "deltaY": -3 + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let scrolled = parse_json(&body); + assert_eq!(scrolled["x"], 220); + assert_eq!(scrolled["y"], 230); + + let (status, _, body) = + send_request(&test_app.app, Method::GET, "/v1/desktop/windows", None, &[]).await; + assert_eq!(status, StatusCode::OK); + assert!(parse_json(&body)["windows"].is_array()); + + let (status, _, body) = send_request( + &test_app.app, + Method::GET, + "/v1/desktop/mouse/position", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let position = parse_json(&body); + assert_eq!(position["x"], 220); + assert_eq!(position["y"], 230); + + launch_desktop_focus_window(&test_app.app, &display).await; + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/keyboard/type", + Some(json!({ "text": "hello world", "delayMs": 5 })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["ok"], true); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/keyboard/press", + Some(json!({ "key": "ctrl+l" })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["ok"], true); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/keyboard/press", + Some(json!({ + "key": "l", + "modifiers": { + "ctrl": true + } + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["ok"], true); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/keyboard/down", + Some(json!({ "key": "shift" })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["ok"], true); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/keyboard/up", + Some(json!({ "key": "shift" })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["ok"], true); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/recording/start", + Some(json!({ "fps": 8 })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let recording = parse_json(&body); + let recording_id = recording["id"].as_str().expect("recording id").to_string(); + assert_eq!(recording["status"], "recording"); + + tokio::time::sleep(Duration::from_secs(2)).await; + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/recording/stop", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let stopped_recording = parse_json(&body); + assert_eq!(stopped_recording["id"], recording_id); + assert_eq!(stopped_recording["status"], "completed"); + + let (status, _, body) = send_request( + &test_app.app, + Method::GET, + "/v1/desktop/recordings", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(parse_json(&body)["recordings"].is_array()); + + let (status, headers, body) = send_request_raw( + &test_app.app, + Method::GET, + &format!("/v1/desktop/recordings/{recording_id}/download"), + None, + &[], + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("video/mp4") + ); + assert!(body.windows(4).any(|window| window == b"ftyp")); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/stream/start", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["active"], true); + + let (mut ws, _) = connect_async(test_app.app.ws_url("/v1/desktop/stream/ws")) + .await + .expect("connect desktop stream websocket"); + + let ready = recv_ws_message(&mut ws).await; + match ready { + Message::Text(text) => { + let value: Value = serde_json::from_str(&text).expect("desktop stream ready frame"); + assert_eq!(value["type"], "ready"); + assert_eq!(value["width"], 1440); + assert_eq!(value["height"], 900); + } + other => panic!("expected text ready frame, got {other:?}"), + } + + let frame = recv_ws_message(&mut ws).await; + match frame { + Message::Binary(bytes) => assert!(bytes.starts_with(&[0xff, 0xd8, 0xff])), + other => panic!("expected binary jpeg frame, got {other:?}"), + } + + ws.send(Message::Text( + json!({ + "type": "moveMouse", + "x": 320, + "y": 330 + }) + .to_string() + .into(), + )) + .await + .expect("send desktop stream mouse move"); + let _ = ws.close(None).await; + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/stream/stop", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["active"], false); + + let (status, _, _) = send_request( + &test_app.app, + Method::DELETE, + &format!("/v1/desktop/recordings/{recording_id}"), + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let (status, _, body) = + send_request(&test_app.app, Method::POST, "/v1/desktop/stop", None, &[]).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["state"], "inactive"); +} diff --git a/server/packages/sandbox-agent/tests/v1_api/processes.rs b/server/packages/sandbox-agent/tests/v1_api/processes.rs index 3c02029..136a51c 100644 --- a/server/packages/sandbox-agent/tests/v1_api/processes.rs +++ b/server/packages/sandbox-agent/tests/v1_api/processes.rs @@ -2,6 +2,7 @@ use super::*; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use futures::{SinkExt, StreamExt}; +use serial_test::serial; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; @@ -277,6 +278,98 @@ async fn v1_process_tty_input_and_logs() { assert_eq!(status, StatusCode::NO_CONTENT); } +#[tokio::test] +#[serial] +async fn v1_processes_owner_filter_separates_user_and_desktop_processes() { + let test_app = TestApp::new(AuthConfig::disabled()); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/processes", + Some(json!({ + "command": "sh", + "args": ["-lc", "sleep 30"], + "tty": false, + "interactive": false + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let user_process_id = parse_json(&body)["id"] + .as_str() + .expect("process id") + .to_string(); + + let (status, _, body) = send_request( + &test_app.app, + Method::POST, + "/v1/desktop/start", + Some(json!({ + "width": 1024, + "height": 768 + })), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["state"], "active"); + + let (status, _, body) = send_request( + &test_app.app, + Method::GET, + "/v1/processes?owner=user", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let user_processes = parse_json(&body)["processes"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!(user_processes + .iter() + .any(|process| process["id"] == user_process_id)); + assert!(user_processes + .iter() + .all(|process| process["owner"] == "user")); + + let (status, _, body) = send_request( + &test_app.app, + Method::GET, + "/v1/processes?owner=desktop", + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let desktop_processes = parse_json(&body)["processes"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!(desktop_processes.len() >= 2); + assert!(desktop_processes + .iter() + .all(|process| process["owner"] == "desktop")); + + let (status, _, _) = send_request( + &test_app.app, + Method::POST, + &format!("/v1/processes/{user_process_id}/kill"), + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, _, body) = + send_request(&test_app.app, Method::POST, "/v1/desktop/stop", None, &[]).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(parse_json(&body)["state"], "inactive"); +} + #[tokio::test] async fn v1_process_not_found_returns_404() { let test_app = TestApp::new(AuthConfig::disabled()); @@ -413,22 +506,17 @@ async fn v1_process_logs_follow_sse_streams_entries() { .expect("process id") .to_string(); - let request = Request::builder() - .method(Method::GET) - .uri(format!( + let response = reqwest::Client::new() + .get(test_app.app.http_url(&format!( "/v1/processes/{process_id}/logs?stream=stdout&follow=true" - )) - .body(Body::empty()) - .expect("build request"); - let response = test_app - .app - .clone() - .oneshot(request) + ))) + .header("accept", "text/event-stream") + .send() .await .expect("sse response"); assert_eq!(response.status(), StatusCode::OK); - let mut stream = response.into_body().into_data_stream(); + let mut stream = response.bytes_stream(); let chunk = tokio::time::timeout(Duration::from_secs(5), async move { while let Some(chunk) = stream.next().await { let bytes = chunk.expect("stream chunk"); From 4252c705dfc093213cc8d232f7183e055c25d9a4 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 16 Mar 2026 17:56:50 -0700 Subject: [PATCH 10/29] chore: remove .context/ from git and add to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CleanShot 2026-03-08 at 18.53.28@2x.png | Bin 112229 -> 0 bytes .context/attachments/PR instructions.md | 19 -- .context/attachments/Review request-v1.md | 101 -------- .context/attachments/Review request-v2.md | 101 -------- .context/attachments/Review request-v3.md | 101 -------- .context/attachments/Review request.md | 101 -------- .context/attachments/plan.md | 215 ------------------ .context/docker-test-image.stamp | 0 .context/docker-test-zgvGyf/bin/Xvfb | 15 -- .context/docker-test-zgvGyf/bin/dbus-launch | 4 - .context/docker-test-zgvGyf/bin/import | 3 - .context/docker-test-zgvGyf/bin/openbox | 6 - .context/docker-test-zgvGyf/bin/xdotool | 57 ----- .context/docker-test-zgvGyf/bin/xrandr | 5 - .../bin/agent_processes/mock-acp | 111 --------- .../bin/agent_processes/mock-acp | 111 --------- .../xdg-data/sandbox-agent/logs/log-03-08-26 | 4 - .../xdg-data/sandbox-agent/telemetry_id | 1 - .context/notes.md | 0 .../desktop-computer-use-api-enhancements.md | 215 ------------------ .context/proposal-revert-actions-to-queues.md | 202 ---------------- .../proposal-rivetkit-sandbox-resilience.md | 94 -------- .context/proposal-task-owner-git-auth.md | 200 ---------------- .context/todos.md | 0 .gitignore | 1 + 25 files changed, 1 insertion(+), 1666 deletions(-) delete mode 100644 .context/attachments/CleanShot 2026-03-08 at 18.53.28@2x.png delete mode 100644 .context/attachments/PR instructions.md delete mode 100644 .context/attachments/Review request-v1.md delete mode 100644 .context/attachments/Review request-v2.md delete mode 100644 .context/attachments/Review request-v3.md delete mode 100644 .context/attachments/Review request.md delete mode 100644 .context/attachments/plan.md delete mode 100644 .context/docker-test-image.stamp delete mode 100755 .context/docker-test-zgvGyf/bin/Xvfb delete mode 100755 .context/docker-test-zgvGyf/bin/dbus-launch delete mode 100755 .context/docker-test-zgvGyf/bin/import delete mode 100755 .context/docker-test-zgvGyf/bin/openbox delete mode 100755 .context/docker-test-zgvGyf/bin/xdotool delete mode 100755 .context/docker-test-zgvGyf/bin/xrandr delete mode 100755 .context/docker-test-zgvGyf/xdg-data/Library/Application Support/sandbox-agent/bin/agent_processes/mock-acp delete mode 100755 .context/docker-test-zgvGyf/xdg-data/sandbox-agent/bin/agent_processes/mock-acp delete mode 100644 .context/docker-test-zgvGyf/xdg-data/sandbox-agent/logs/log-03-08-26 delete mode 100644 .context/docker-test-zgvGyf/xdg-data/sandbox-agent/telemetry_id delete mode 100644 .context/notes.md delete mode 100644 .context/plans/desktop-computer-use-api-enhancements.md delete mode 100644 .context/proposal-revert-actions-to-queues.md delete mode 100644 .context/proposal-rivetkit-sandbox-resilience.md delete mode 100644 .context/proposal-task-owner-git-auth.md delete mode 100644 .context/todos.md diff --git a/.context/attachments/CleanShot 2026-03-08 at 18.53.28@2x.png b/.context/attachments/CleanShot 2026-03-08 at 18.53.28@2x.png deleted file mode 100644 index 955a813aedbc9474109f9d654f2184ce9a58a8cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 112229 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 - ### **#1 Empty input causes crash** - - If the input field is empty when page loads, the app will crash. - - File: src/ui/Input.tsx - - ### **#2 Dead code** - - The getUserData function is now unused. It should be deleted. - - File: src/core/UserData.ts - - -Use this list when evaluating issues in Steps 5 and 6 (these are false positives, do NOT flag): - -- Pre-existing issues -- Something that appears to be a bug but is actually correct -- Pedantic nitpicks that a senior engineer would not flag -- Issues that a linter will catch (do not run the linter to verify) -- General code quality concerns (e.g., lack of test coverage, general security issues) unless explicitly required in CLAUDE.md or AGENTS.md -- Issues mentioned in CLAUDE.md or AGENTS.md but explicitly silenced in the code (e.g., via a lint ignore comment) - -Notes: - -- All subagents should be explicitly instructed not to post comments themselves. Only you, the main agent, should post comments. -- Do not use the AskUserQuestion tool. Your goal should be to complete the entire review without user intervention. -- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch. -- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md or AGENTS.md rule, include a link to it). - -## Fallback: if you don't have access to subagents - -If you don't have subagents, perform all the steps above yourself sequentially instead of launching agents. Do each review axis (CLAUDE.md compliance, bug scan, introduced problems) yourself, and validate each issue yourself. - -## Fallback: if you don't have access to the workspace diff tool - -If you don't have access to the mcp__conductor__GetWorkspaceDiff tool, use the following git commands to get the diff: - -```bash -# Get the merge base between this branch and the target -MERGE_BASE=$(git merge-base origin/main HEAD) - -# Get the committed diff against the merge base -git diff $MERGE_BASE HEAD - -# Get any uncommitted changes (staged and unstaged) -git diff HEAD -``` - -Review the combination of both outputs: the first shows all committed changes on this branch relative to the target, and the second shows any uncommitted work in progress. - -No need to mention in your report whether or not you used one of the fallback strategies; it's usually irrelevant. - diff --git a/.context/attachments/Review request-v2.md b/.context/attachments/Review request-v2.md deleted file mode 100644 index 0a800c7..0000000 --- a/.context/attachments/Review request-v2.md +++ /dev/null @@ -1,101 +0,0 @@ -## Code Review Instructions - -1. Launch a haiku agent to return a list of file paths (not their contents) for all relevant CLAUDE.md files including: - - - The root CLAUDE.md file, if it exists - - Any CLAUDE.md files in directories containing files modified by the workspace diff (use mcp__conductor__GetWorkspaceDiff with stat option) - -2. If this workspace has an associated PR, read the title and description (but not the changes). This will be helpful context. - -3. In parallel with step 2, launch a sonnet agent to view the changes, using mcp__conductor__GetWorkspaceDiff, and return a summary of the changes - -4. Launch 4 agents in parallel to independently review the changes using mcp__conductor__GetWorkspaceDiff. Each agent should return the list of issues, where each issue includes a description and the reason it was flagged (e.g. "CLAUDE.md adherence", "bug"). The agents should do the following: - - Agents 1 + 2: CLAUDE.md or AGENTS.md compliance sonnet agents - Audit changes for CLAUDE.md or AGENTS.md compliance in parallel. Note: When evaluating CLAUDE.md or AGENTS.md compliance for a file, you should only consider CLAUDE.md or AGENTS.md files that share a file path with the file or parents. - - Agent 3: Opus bug agent - Scan for obvious bugs. Focus only on the diff itself without reading extra context. Flag only significant bugs; ignore nitpicks and likely false positives. Do not flag issues that you cannot validate without looking at context outside of the git diff. - - Agent 4: Opus bug agent - Look for problems that exist in the introduced code. This could be security issues, incorrect logic, etc. Only look for issues that fall within the changed code. - - **CRITICAL: We only want HIGH SIGNAL issues.** This means: - - - Objective bugs that will cause incorrect behavior at runtime - - Clear, unambiguous CLAUDE.md violations where you can quote the exact rule being broken - - We do NOT want: - - - Subjective concerns or "suggestions" - - Style preferences not explicitly required by CLAUDE.md - - Potential issues that "might" be problems - - Anything requiring interpretation or judgment calls - - If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time. - - In addition to the above, each subagent should be told the PR title and description. This will help provide context regarding the author's intent. - -5. For each issue found in the previous step, launch parallel subagents to validate the issue. These subagents should get the PR title and description along with a description of the issue. The agent's job is to review the issue to validate that the stated issue is truly an issue with high confidence. For example, if an issue such as "variable is not defined" was flagged, the subagent's job would be to validate that is actually true in the code. Another example would be CLAUDE.md issues. The agent should validate that the CLAUDE.md rule that was violated is scoped for this file and is actually violated. Use Opus subagents for bugs and logic issues, and sonnet agents for CLAUDE.md violations. - -6. Filter out any issues that were not validated in step 5. This step will give us our list of high signal issues for our review. - -7. Post inline comments for each issue using mcp__conductor__DiffComment: - - **IMPORTANT: Only post ONE comment per unique issue.** - -8. Write out a list of issues found, along with the location of the comment. For example: - - - ### **#1 Empty input causes crash** - - If the input field is empty when page loads, the app will crash. - - File: src/ui/Input.tsx - - ### **#2 Dead code** - - The getUserData function is now unused. It should be deleted. - - File: src/core/UserData.ts - - -Use this list when evaluating issues in Steps 5 and 6 (these are false positives, do NOT flag): - -- Pre-existing issues -- Something that appears to be a bug but is actually correct -- Pedantic nitpicks that a senior engineer would not flag -- Issues that a linter will catch (do not run the linter to verify) -- General code quality concerns (e.g., lack of test coverage, general security issues) unless explicitly required in CLAUDE.md or AGENTS.md -- Issues mentioned in CLAUDE.md or AGENTS.md but explicitly silenced in the code (e.g., via a lint ignore comment) - -Notes: - -- All subagents should be explicitly instructed not to post comments themselves. Only you, the main agent, should post comments. -- Do not use the AskUserQuestion tool. Your goal should be to complete the entire review without user intervention. -- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch. -- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md or AGENTS.md rule, include a link to it). - -## Fallback: if you don't have access to subagents - -If you don't have subagents, perform all the steps above yourself sequentially instead of launching agents. Do each review axis (CLAUDE.md compliance, bug scan, introduced problems) yourself, and validate each issue yourself. - -## Fallback: if you don't have access to the workspace diff tool - -If you don't have access to the mcp__conductor__GetWorkspaceDiff tool, use the following git commands to get the diff: - -```bash -# Get the merge base between this branch and the target -MERGE_BASE=$(git merge-base origin/main HEAD) - -# Get the committed diff against the merge base -git diff $MERGE_BASE HEAD - -# Get any uncommitted changes (staged and unstaged) -git diff HEAD -``` - -Review the combination of both outputs: the first shows all committed changes on this branch relative to the target, and the second shows any uncommitted work in progress. - -No need to mention in your report whether or not you used one of the fallback strategies; it's usually irrelevant. - diff --git a/.context/attachments/Review request-v3.md b/.context/attachments/Review request-v3.md deleted file mode 100644 index 0a800c7..0000000 --- a/.context/attachments/Review request-v3.md +++ /dev/null @@ -1,101 +0,0 @@ -## Code Review Instructions - -1. Launch a haiku agent to return a list of file paths (not their contents) for all relevant CLAUDE.md files including: - - - The root CLAUDE.md file, if it exists - - Any CLAUDE.md files in directories containing files modified by the workspace diff (use mcp__conductor__GetWorkspaceDiff with stat option) - -2. If this workspace has an associated PR, read the title and description (but not the changes). This will be helpful context. - -3. In parallel with step 2, launch a sonnet agent to view the changes, using mcp__conductor__GetWorkspaceDiff, and return a summary of the changes - -4. Launch 4 agents in parallel to independently review the changes using mcp__conductor__GetWorkspaceDiff. Each agent should return the list of issues, where each issue includes a description and the reason it was flagged (e.g. "CLAUDE.md adherence", "bug"). The agents should do the following: - - Agents 1 + 2: CLAUDE.md or AGENTS.md compliance sonnet agents - Audit changes for CLAUDE.md or AGENTS.md compliance in parallel. Note: When evaluating CLAUDE.md or AGENTS.md compliance for a file, you should only consider CLAUDE.md or AGENTS.md files that share a file path with the file or parents. - - Agent 3: Opus bug agent - Scan for obvious bugs. Focus only on the diff itself without reading extra context. Flag only significant bugs; ignore nitpicks and likely false positives. Do not flag issues that you cannot validate without looking at context outside of the git diff. - - Agent 4: Opus bug agent - Look for problems that exist in the introduced code. This could be security issues, incorrect logic, etc. Only look for issues that fall within the changed code. - - **CRITICAL: We only want HIGH SIGNAL issues.** This means: - - - Objective bugs that will cause incorrect behavior at runtime - - Clear, unambiguous CLAUDE.md violations where you can quote the exact rule being broken - - We do NOT want: - - - Subjective concerns or "suggestions" - - Style preferences not explicitly required by CLAUDE.md - - Potential issues that "might" be problems - - Anything requiring interpretation or judgment calls - - If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time. - - In addition to the above, each subagent should be told the PR title and description. This will help provide context regarding the author's intent. - -5. For each issue found in the previous step, launch parallel subagents to validate the issue. These subagents should get the PR title and description along with a description of the issue. The agent's job is to review the issue to validate that the stated issue is truly an issue with high confidence. For example, if an issue such as "variable is not defined" was flagged, the subagent's job would be to validate that is actually true in the code. Another example would be CLAUDE.md issues. The agent should validate that the CLAUDE.md rule that was violated is scoped for this file and is actually violated. Use Opus subagents for bugs and logic issues, and sonnet agents for CLAUDE.md violations. - -6. Filter out any issues that were not validated in step 5. This step will give us our list of high signal issues for our review. - -7. Post inline comments for each issue using mcp__conductor__DiffComment: - - **IMPORTANT: Only post ONE comment per unique issue.** - -8. Write out a list of issues found, along with the location of the comment. For example: - - - ### **#1 Empty input causes crash** - - If the input field is empty when page loads, the app will crash. - - File: src/ui/Input.tsx - - ### **#2 Dead code** - - The getUserData function is now unused. It should be deleted. - - File: src/core/UserData.ts - - -Use this list when evaluating issues in Steps 5 and 6 (these are false positives, do NOT flag): - -- Pre-existing issues -- Something that appears to be a bug but is actually correct -- Pedantic nitpicks that a senior engineer would not flag -- Issues that a linter will catch (do not run the linter to verify) -- General code quality concerns (e.g., lack of test coverage, general security issues) unless explicitly required in CLAUDE.md or AGENTS.md -- Issues mentioned in CLAUDE.md or AGENTS.md but explicitly silenced in the code (e.g., via a lint ignore comment) - -Notes: - -- All subagents should be explicitly instructed not to post comments themselves. Only you, the main agent, should post comments. -- Do not use the AskUserQuestion tool. Your goal should be to complete the entire review without user intervention. -- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch. -- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md or AGENTS.md rule, include a link to it). - -## Fallback: if you don't have access to subagents - -If you don't have subagents, perform all the steps above yourself sequentially instead of launching agents. Do each review axis (CLAUDE.md compliance, bug scan, introduced problems) yourself, and validate each issue yourself. - -## Fallback: if you don't have access to the workspace diff tool - -If you don't have access to the mcp__conductor__GetWorkspaceDiff tool, use the following git commands to get the diff: - -```bash -# Get the merge base between this branch and the target -MERGE_BASE=$(git merge-base origin/main HEAD) - -# Get the committed diff against the merge base -git diff $MERGE_BASE HEAD - -# Get any uncommitted changes (staged and unstaged) -git diff HEAD -``` - -Review the combination of both outputs: the first shows all committed changes on this branch relative to the target, and the second shows any uncommitted work in progress. - -No need to mention in your report whether or not you used one of the fallback strategies; it's usually irrelevant. - diff --git a/.context/attachments/Review request.md b/.context/attachments/Review request.md deleted file mode 100644 index 0a800c7..0000000 --- a/.context/attachments/Review request.md +++ /dev/null @@ -1,101 +0,0 @@ -## Code Review Instructions - -1. Launch a haiku agent to return a list of file paths (not their contents) for all relevant CLAUDE.md files including: - - - The root CLAUDE.md file, if it exists - - Any CLAUDE.md files in directories containing files modified by the workspace diff (use mcp__conductor__GetWorkspaceDiff with stat option) - -2. If this workspace has an associated PR, read the title and description (but not the changes). This will be helpful context. - -3. In parallel with step 2, launch a sonnet agent to view the changes, using mcp__conductor__GetWorkspaceDiff, and return a summary of the changes - -4. Launch 4 agents in parallel to independently review the changes using mcp__conductor__GetWorkspaceDiff. Each agent should return the list of issues, where each issue includes a description and the reason it was flagged (e.g. "CLAUDE.md adherence", "bug"). The agents should do the following: - - Agents 1 + 2: CLAUDE.md or AGENTS.md compliance sonnet agents - Audit changes for CLAUDE.md or AGENTS.md compliance in parallel. Note: When evaluating CLAUDE.md or AGENTS.md compliance for a file, you should only consider CLAUDE.md or AGENTS.md files that share a file path with the file or parents. - - Agent 3: Opus bug agent - Scan for obvious bugs. Focus only on the diff itself without reading extra context. Flag only significant bugs; ignore nitpicks and likely false positives. Do not flag issues that you cannot validate without looking at context outside of the git diff. - - Agent 4: Opus bug agent - Look for problems that exist in the introduced code. This could be security issues, incorrect logic, etc. Only look for issues that fall within the changed code. - - **CRITICAL: We only want HIGH SIGNAL issues.** This means: - - - Objective bugs that will cause incorrect behavior at runtime - - Clear, unambiguous CLAUDE.md violations where you can quote the exact rule being broken - - We do NOT want: - - - Subjective concerns or "suggestions" - - Style preferences not explicitly required by CLAUDE.md - - Potential issues that "might" be problems - - Anything requiring interpretation or judgment calls - - If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time. - - In addition to the above, each subagent should be told the PR title and description. This will help provide context regarding the author's intent. - -5. For each issue found in the previous step, launch parallel subagents to validate the issue. These subagents should get the PR title and description along with a description of the issue. The agent's job is to review the issue to validate that the stated issue is truly an issue with high confidence. For example, if an issue such as "variable is not defined" was flagged, the subagent's job would be to validate that is actually true in the code. Another example would be CLAUDE.md issues. The agent should validate that the CLAUDE.md rule that was violated is scoped for this file and is actually violated. Use Opus subagents for bugs and logic issues, and sonnet agents for CLAUDE.md violations. - -6. Filter out any issues that were not validated in step 5. This step will give us our list of high signal issues for our review. - -7. Post inline comments for each issue using mcp__conductor__DiffComment: - - **IMPORTANT: Only post ONE comment per unique issue.** - -8. Write out a list of issues found, along with the location of the comment. For example: - - - ### **#1 Empty input causes crash** - - If the input field is empty when page loads, the app will crash. - - File: src/ui/Input.tsx - - ### **#2 Dead code** - - The getUserData function is now unused. It should be deleted. - - File: src/core/UserData.ts - - -Use this list when evaluating issues in Steps 5 and 6 (these are false positives, do NOT flag): - -- Pre-existing issues -- Something that appears to be a bug but is actually correct -- Pedantic nitpicks that a senior engineer would not flag -- Issues that a linter will catch (do not run the linter to verify) -- General code quality concerns (e.g., lack of test coverage, general security issues) unless explicitly required in CLAUDE.md or AGENTS.md -- Issues mentioned in CLAUDE.md or AGENTS.md but explicitly silenced in the code (e.g., via a lint ignore comment) - -Notes: - -- All subagents should be explicitly instructed not to post comments themselves. Only you, the main agent, should post comments. -- Do not use the AskUserQuestion tool. Your goal should be to complete the entire review without user intervention. -- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch. -- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md or AGENTS.md rule, include a link to it). - -## Fallback: if you don't have access to subagents - -If you don't have subagents, perform all the steps above yourself sequentially instead of launching agents. Do each review axis (CLAUDE.md compliance, bug scan, introduced problems) yourself, and validate each issue yourself. - -## Fallback: if you don't have access to the workspace diff tool - -If you don't have access to the mcp__conductor__GetWorkspaceDiff tool, use the following git commands to get the diff: - -```bash -# Get the merge base between this branch and the target -MERGE_BASE=$(git merge-base origin/main HEAD) - -# Get the committed diff against the merge base -git diff $MERGE_BASE HEAD - -# Get any uncommitted changes (staged and unstaged) -git diff HEAD -``` - -Review the combination of both outputs: the first shows all committed changes on this branch relative to the target, and the second shows any uncommitted work in progress. - -No need to mention in your report whether or not you used one of the fallback strategies; it's usually irrelevant. - diff --git a/.context/attachments/plan.md b/.context/attachments/plan.md deleted file mode 100644 index 2749e27..0000000 --- a/.context/attachments/plan.md +++ /dev/null @@ -1,215 +0,0 @@ -# Desktop Computer Use API Enhancements - -## Context - -Competitive analysis of Daytona, Cloudflare Sandbox SDK, and CUA revealed significant gaps in our desktop computer use API. Both Daytona and Cloudflare have or are building screenshot compression, hotkey combos, mouseDown/mouseUp, keyDown/keyUp, per-component process health, and live desktop streaming. CUA additionally has window management and accessibility trees. We have none of these. This plan closes the most impactful gaps across 7 tasks. - -## Execution Order - -``` -Sprint 1 (parallel, no dependencies): Tasks 1, 2, 3, 4 -Sprint 2 (foundational refactor): Task 5 -Sprint 3 (parallel, depend on #5): Tasks 6, 7 -``` - ---- - -## Task 1: Unify keyboard press with object modifiers - -**What**: Change `DesktopKeyboardPressRequest` to accept a `modifiers` object instead of requiring DSL strings like `"ctrl+c"`. - -**Files**: -- `server/packages/sandbox-agent/src/desktop_types.rs` — Add `DesktopKeyModifiers { ctrl, shift, alt, cmd }` struct (all `Option`). Add `modifiers: Option` to `DesktopKeyboardPressRequest`. -- `server/packages/sandbox-agent/src/desktop_runtime.rs` — Modify `press_key_args()` (~line 1349) to build xdotool key string from modifiers object. If modifiers present, construct `"ctrl+shift+a"` style string. `cmd` maps to `super`. -- `server/packages/sandbox-agent/src/router.rs` — Add `DesktopKeyModifiers` to OpenAPI schemas list. -- `docs/openapi.json` — Regenerate. - -**Backward compatible**: Old `{"key": "ctrl+a"}` still works. New form: `{"key": "a", "modifiers": {"ctrl": true}}`. - -**Test**: Unit test that `press_key_args("a", Some({ctrl: true, shift: true}))` produces `["key", "--", "ctrl+shift+a"]`. Integration test with both old and new request shapes. - ---- - -## Task 2: Add mouseDown/mouseUp and keyDown/keyUp endpoints - -**What**: 4 new endpoints for low-level press/release control. - -**Endpoints**: -- `POST /v1/desktop/mouse/down` — `xdotool mousedown BUTTON` (optional x,y moves first) -- `POST /v1/desktop/mouse/up` — `xdotool mouseup BUTTON` -- `POST /v1/desktop/keyboard/down` — `xdotool keydown KEY` -- `POST /v1/desktop/keyboard/up` — `xdotool keyup KEY` - -**Files**: -- `server/packages/sandbox-agent/src/desktop_types.rs` — Add `DesktopMouseDownRequest`, `DesktopMouseUpRequest` (x/y optional, button optional), `DesktopKeyboardDownRequest`, `DesktopKeyboardUpRequest` (key: String). -- `server/packages/sandbox-agent/src/desktop_runtime.rs` — Add 4 public methods following existing `click_mouse()` / `press_key()` patterns. -- `server/packages/sandbox-agent/src/router.rs` — Add 4 routes, 4 handlers with utoipa annotations. -- `sdks/typescript/src/client.ts` — Add `mouseDownDesktop()`, `mouseUpDesktop()`, `keyDownDesktop()`, `keyUpDesktop()`. -- `docs/openapi.json` — Regenerate. - -**Test**: Integration test: mouseDown → mousemove → mouseUp sequence. keyDown → keyUp sequence. - ---- - -## Task 3: Screenshot compression - -**What**: Add format, quality, and scale query params to screenshot endpoints. - -**Params**: `format` (png|jpeg|webp, default png), `quality` (1-100, default 85), `scale` (0.1-1.0, default 1.0). - -**Files**: -- `server/packages/sandbox-agent/src/desktop_types.rs` — Add `DesktopScreenshotFormat` enum. Add `format`, `quality`, `scale` fields to `DesktopScreenshotQuery` and `DesktopRegionScreenshotQuery`. -- `server/packages/sandbox-agent/src/desktop_runtime.rs` — After capturing PNG via `import`, pipe through ImageMagick `convert` if format != png or scale != 1.0: `convert png:- -resize {scale*100}% -quality {quality} {format}:-`. Add a `run_command_with_stdin()` helper (or modify existing `run_command_output`) to pipe bytes into a command's stdin. -- `server/packages/sandbox-agent/src/router.rs` — Modify screenshot handlers to pass format/quality/scale, return dynamic `Content-Type` header. -- `sdks/typescript/src/client.ts` — Update `takeDesktopScreenshot()` to accept format/quality/scale. -- `docs/openapi.json` — Regenerate. - -**Dependencies**: ImageMagick `convert` already installed in Docker. Verify WebP delegate availability. - -**Test**: Integration tests: request `?format=jpeg&quality=50`, verify `Content-Type: image/jpeg` and JPEG magic bytes. Verify default still returns PNG. Verify `?scale=0.5` returns a smaller image. - ---- - -## Task 4: Window listing API - -**What**: New endpoint to list open windows. - -**Endpoint**: `GET /v1/desktop/windows` - -**Files**: -- `server/packages/sandbox-agent/src/desktop_types.rs` — Add `DesktopWindowInfo { id, title, x, y, width, height, is_active }` and `DesktopWindowListResponse`. -- `server/packages/sandbox-agent/src/desktop_runtime.rs` — Add `list_windows()` method using xdotool (already installed): - 1. `xdotool search --onlyvisible --name ""` → window IDs - 2. `xdotool getwindowname {id}` + `xdotool getwindowgeometry {id}` per window - 3. `xdotool getactivewindow` → is_active flag - 4. Add `parse_window_geometry()` helper. -- `server/packages/sandbox-agent/src/router.rs` — Add route, handler, OpenAPI annotations. -- `sdks/typescript/src/client.ts` — Add `listDesktopWindows()`. -- `docs/openapi.json` — Regenerate. - -**No new Docker dependencies** — xdotool already installed. - -**Test**: Integration test: start desktop, verify `GET /v1/desktop/windows` returns 200 with a list (may be empty if no GUI apps open, which is fine). - ---- - -## Task 5: Unify desktop processes into process runtime with owner flag - -**What**: Desktop processes (Xvfb, openbox, dbus) get registered in the general process runtime with an `owner` field, gaining log streaming, SSE, and unified lifecycle for free. - -**Files**: - -- `server/packages/sandbox-agent/src/process_runtime.rs`: - - Add `ProcessOwner` enum: `User`, `Desktop`, `System`. - - Add `RestartPolicy` enum: `Never`, `Always`, `OnFailure`. - - Add `owner: ProcessOwner` and `restart_policy: Option` to `ProcessStartSpec`, `ManagedProcess`, and `ProcessSnapshot`. - - Modify `list_processes()` to accept optional owner filter. - - Add auto-restart logic in `watch_exit()`: if restart_policy is Always (or OnFailure and exit code != 0), re-spawn the process using stored spec. Need to store the original `ProcessStartSpec` on `ManagedProcess`. - -- `server/packages/sandbox-agent/src/router/types.rs`: - - Add `owner` to `ProcessInfo` response. - - Add `ProcessListQuery { owner: Option }`. - -- `server/packages/sandbox-agent/src/router.rs`: - - Modify `get_v1_processes` to accept `Query` and filter. - - Pass `ProcessRuntime` into `DesktopRuntime::new()`. - - Add `ProcessOwner`, `RestartPolicy` to OpenAPI schemas. - -- `server/packages/sandbox-agent/src/desktop_runtime.rs` — **Major refactor**: - - Remove `ManagedDesktopChild` struct. - - `DesktopRuntime` takes `ProcessRuntime` as constructor param. - - `start_xvfb_locked()` and `start_openbox_locked()` call `process_runtime.start_process(ProcessStartSpec { owner: Desktop, restart_policy: Some(Always), ... })` instead of spawning directly. - - Store returned process IDs in state instead of `Child` handles. - - `stop` calls `process_runtime.stop_process()` / `kill_process()`. - - `processes_locked()` queries process runtime for desktop-owned processes. - - dbus-launch remains a direct one-shot spawn (it's not a long-running process, just produces env vars). - -- `sdks/typescript/src/client.ts` — Add `owner` filter option to `listProcesses()`. -- `docs/openapi.json` — Regenerate. - -**Risks**: -- Lock ordering: desktop runtime holds Mutex, process runtime uses RwLock. Release desktop Mutex before calling process runtime, or restructure. -- `log_path` field in `DesktopProcessInfo` no longer applies (logs are in-memory now). Remove or deprecate. - -**Test**: Integration: start desktop, `GET /v1/processes?owner=desktop` returns Xvfb+openbox. `GET /v1/processes?owner=user` excludes them. Desktop process logs are streamable via `GET /v1/processes/{id}/logs?follow=true`. Existing desktop lifecycle tests still pass. - ---- - -## Task 6: Screen recording API (ffmpeg x11grab) - -**What**: 6 endpoints for recording the desktop to MP4. - -**Endpoints**: -- `POST /v1/desktop/recording/start` — Start ffmpeg recording -- `POST /v1/desktop/recording/stop` — Stop recording (SIGTERM → wait → SIGKILL) -- `GET /v1/desktop/recordings` — List recordings -- `GET /v1/desktop/recordings/{id}` — Get recording metadata -- `GET /v1/desktop/recordings/{id}/download` — Serve MP4 file -- `DELETE /v1/desktop/recordings/{id}` — Delete recording - -**Files**: -- **New**: `server/packages/sandbox-agent/src/desktop_recording.rs` — Recording state, ffmpeg process management. `start_recording()` spawns ffmpeg via process runtime (owner=Desktop): `ffmpeg -f x11grab -video_size WxH -i :99 -c:v libx264 -preset ultrafast -r 30 {path}`. Recordings stored in `{state_dir}/recordings/`. -- `server/packages/sandbox-agent/src/desktop_types.rs` — Add recording request/response types. -- `server/packages/sandbox-agent/src/desktop_runtime.rs` — Wire recording manager, expose through desktop runtime. -- `server/packages/sandbox-agent/src/router.rs` — Add 6 routes + handlers. -- `server/packages/sandbox-agent/src/desktop_install.rs` — Add `ffmpeg` to dependency detection (soft: only error when recording is requested). -- `docker/runtime/Dockerfile` and `docker/test-agent/Dockerfile` — Add `ffmpeg` to apt-get. -- `sdks/typescript/src/client.ts` — Add 6 recording methods. -- `docs/openapi.json` — Regenerate. - -**Depends on**: Task 5 (ffmpeg runs as desktop-owned process). - -**Test**: Integration: start desktop → start recording → wait 2s → stop → list → download (verify MP4 magic bytes) → delete. - ---- - -## Task 7: Neko WebRTC desktop streaming + React component - -**What**: Integrate neko for WebRTC desktop streaming, mirroring the ProcessTerminal + Ghostty pattern. - -### Server side - -- **New**: `server/packages/sandbox-agent/src/desktop_streaming.rs` — Manages neko process via process runtime (owner=Desktop). Neko connects to existing Xvfb display, runs GStreamer pipeline for H.264 encoding. -- `server/packages/sandbox-agent/src/router.rs`: - - `GET /v1/desktop/stream/ws` — WebSocket proxy to neko's internal WebSocket. Upgrade request, bridge bidirectionally. - - `POST /v1/desktop/stream/start` / `POST /v1/desktop/stream/stop` — Lifecycle control. -- `docker/runtime/Dockerfile` and `docker/test-agent/Dockerfile` — Add neko binary + GStreamer packages (`gstreamer1.0-plugins-base`, `gstreamer1.0-plugins-good`, `gstreamer1.0-x`, `libgstreamer1.0-0`). Consider making this an optional Docker stage to avoid bloating the base image. - -### TypeScript SDK - -- **New**: `sdks/typescript/src/desktop-stream.ts` — `DesktopStreamSession` class ported from neko's `base.ts` (~500 lines): - - WebSocket for signaling (SDP offer/answer, ICE candidates) - - `RTCPeerConnection` for video stream - - `RTCDataChannel` for binary input (mouse: 7 bytes, keyboard: 11 bytes) - - Events: `onTrack(stream)`, `onConnect()`, `onDisconnect()`, `onError()` -- `sdks/typescript/src/client.ts` — Add `connectDesktopStream()` returning `DesktopStreamSession`, `buildDesktopStreamWebSocketUrl()`, `startDesktopStream()`, `stopDesktopStream()`. -- `sdks/typescript/src/index.ts` — Export `DesktopStreamSession`. - -### React SDK - -- **New**: `sdks/react/src/DesktopViewer.tsx` — Following `ProcessTerminal.tsx` pattern: - ``` - Props: client (Pick), height, className, style, onConnect, onDisconnect, onError - ``` - - `useEffect` → `client.connectDesktopStream()` → wire `onTrack` to `