From de1ebffe70db0f22a59d1f299fb60a0273f64e1e Mon Sep 17 00:00:00 2001 From: Hydra Date: Sun, 4 Oct 2026 13:47:52 +0300 Subject: [PATCH] Add saved project desktops and shared agent control --- apps/web/content/docs/concepts/internals.mdx | 6 + apps/web/content/docs/guides/desktops.mdx | 104 +++ apps/web/content/docs/guides/meta.json | 1 + daemon/DESKTOP.md | 111 ++- daemon/cmd/daemon/main.go | 9 + daemon/internal/agent/surface.go | 27 +- daemon/internal/agent/surface_test.go | 13 + daemon/internal/api/api.go | 8 + daemon/internal/api/desktop_catalog.go | 97 ++ daemon/internal/api/surfaces.go | 43 +- daemon/internal/orchestrator/surfaces.go | 5 +- daemon/internal/registry/surfaces.go | 4 + daemon/internal/surface/binding.go | 15 + daemon/internal/surface/catalog.go | 667 ++++++++++++++ daemon/internal/surface/catalog_test.go | 682 ++++++++++++++ daemon/internal/surface/claude_local_test.go | 140 +++ daemon/internal/surface/desktop_socket.go | 7 +- daemon/internal/surface/harness_live_test.go | 55 +- daemon/internal/surface/helper.go | 99 +++ daemon/internal/surface/mcp.go | 108 ++- daemon/internal/surface/oblien.go | 73 +- daemon/internal/surface/project_live_test.go | 191 ++++ daemon/internal/surface/project_tools.go | 141 +++ daemon/internal/surface/service.go | 187 +++- daemon/internal/surface/service_test.go | 9 +- daemon/internal/surface/types.go | 16 +- daemon/openapi.json | 877 ++++++++++++++++++- daemon/sdk/mindwire_test.go | 220 ++--- daemon/sdk/surfaces.go | 108 ++- packages/sdk/src/surfaces.ts | 94 +- packages/sdk/test/surfaces.test.ts | 45 + 31 files changed, 3855 insertions(+), 307 deletions(-) create mode 100644 apps/web/content/docs/guides/desktops.mdx create mode 100644 daemon/internal/api/desktop_catalog.go create mode 100644 daemon/internal/surface/catalog.go create mode 100644 daemon/internal/surface/catalog_test.go create mode 100644 daemon/internal/surface/claude_local_test.go create mode 100644 daemon/internal/surface/helper.go create mode 100644 daemon/internal/surface/project_live_test.go create mode 100644 daemon/internal/surface/project_tools.go diff --git a/apps/web/content/docs/concepts/internals.mdx b/apps/web/content/docs/concepts/internals.mdx index 527695c..c1d26af 100644 --- a/apps/web/content/docs/concepts/internals.mdx +++ b/apps/web/content/docs/concepts/internals.mdx @@ -133,6 +133,12 @@ without JSON. ## HTTP surface +Project [desktop sessions](/docs/guides/desktops) reuse the daemon's controller, +approval, artifact, and input-receipt services. Oblien catalog operations persist +project associations in the workspace registry; per-run MCP tools and the native +viewer select the same saved desktop. No separate agent desktop process or idle +catalog worker is required. + Public: `GET /healthz`. Everything else sits behind the bearer-token middleware. Every agent-specific route accepts `?agent=` (default when omitted); turns/runs/chats are shared, auth/config are per type. diff --git a/apps/web/content/docs/guides/desktops.mdx b/apps/web/content/docs/guides/desktops.mdx new file mode 100644 index 0000000..19e539a --- /dev/null +++ b/apps/web/content/docs/guides/desktops.mdx @@ -0,0 +1,104 @@ +--- +title: Project desktops +description: Saved desktop sessions for projects, native viewing, and shared agent control. +--- + +Mindwire can open a saved desktop on an Oblien workspace and let an agent work in +the same screen you watch from the app. Each Linux project can keep its own apps +and desktop profile. Closing the viewer releases control and leaves those apps +running. + +## Open a desktop + +In the app, open **Workspace → Desktop** to list saved desktops, or **Project → +Desktop** to see the desktops linked to that project. Chat tools provide the same +shortcut. Create a desktop with a name and supported resolution, then select it +to connect. Reopening it uses the same saved session ID. + +Desktop settings support renaming, starting, stopping, and deleting a session. +The current Oblien runtime requires a desktop to be stopped before its resolution +can change. Mindwire does not stop apps implicitly for a resize. Renaming works +while the desktop is running. + +| Action | What remains | +| --- | --- | +| Close the viewer or finish an agent run | Apps, profile, and project files | +| Stop the desktop | Profile and project files; apps close | +| Delete the desktop | Project files; the desktop is stopped and its private profile is removed | + +Oblien Mac workspaces expose one shared `console` screen with an OS-managed size. +Projects on that workspace share the console. A paired personal Mac keeps its +local opt-in flow: run `mindwire desktop enable` on the Mac and complete the macOS +setup. Display traffic uses its existing encrypted SSH connection. + +## Let an agent use it + +On an authorized Oblien workspace, Mindwire supplies desktop instructions and +tools to project chat runs. Codex and Claude use a temporary MCP configuration for +that run. Other supported command-capable harnesses use a temporary command helper +that calls the same service. Global harness configuration is unchanged. + +| Tool | Purpose | +| --- | --- | +| `surface_desktops` | List this project's saved desktops and session limits | +| `surface_project_desktop` | Create or reuse the project desktop and open view/control access | +| `surface_status` | Inspect desktop availability and its current controller | +| `surface_open` | Open desktop access using the same project selection and approval flow | +| `surface_capture` | Return a PNG and a fresh frame ID | +| `surface_action` | Click, double-click, drag, scroll, type, or use supported clipboard operations | +| `surface_release` | End this run's control session while leaving apps running | + +Approval happens before the agent creates, enables, or opens a desktop. Repeated +requests reuse the same creation and approval intent. A tool result opens the +same saved desktop in the app, and the user can take control at any time. + +The agent starts a development server with its normal command tool. To open a +browser in the correct Linux desktop, it uses Alt+F2, types +`xdg-open http://localhost:3000`, and presses Return. It should capture the screen +again to verify the result. It must not guess a `DISPLAY` value or start another +VNC server. + +## SDK and transport + +Saved project desktops require `surfaceProtocolVersion >= 2`. The client supplies +an expiring, workspace-scoped runtime authorization through `surfaces.bindRuntime`. +This is a private binding; an Oblien account login token is never passed to the +agent or stored in project metadata. The app prepares this binding before cloud +chat runs and when managing project desktops. + +Once the workspace binding and project exist: + +```ts +const { session: saved } = await mw.surfaces.ensureProjectDesktop(projectId); +const desktop = mw.surfaces.desktop(saved.id); +const viewer = await desktop.open({ + requestId: crypto.randomUUID(), + mode: "view", +}); +const status = await desktop.status(true); +// Close access when the viewer leaves; keep the saved desktop and its apps. +await desktop.close(viewer.id); +``` + +For a named desktop with a custom size, use `surfaces.createDesktop` with +`projectId`, `name`, `resolution`, and a stable `requestId`. Persist the request +before sending and retry the same key and body if its response is lost. +`listDesktops(projectId)`, `getDesktop`, `updateDesktop`, `startDesktop`, +`stopDesktop`, and `deleteDesktop` manage its lifecycle. Deletion also releases +that desktop's controllers and can safely be retried after completion. + +The Go SDK exposes the same catalog through `client.Surfaces`; select a saved +desktop with `ForDesktop(id)`. HTTP viewers select it with `?desktopId=...` on the +existing `/surfaces/desktop` routes. A saved desktop ID and an individual control +session ID identify different resources. + +Each saved desktop has one shared controller. Captures and input receipts are +bound to that desktop and run; a screenshot from another desktop cannot authorize +pointer input. When delivery is uncertain, inspect the screen or the existing +receipt rather than sending the action again with a new ID. + +Catalog management has no idle polling worker. The app caches lists and only +polls visible sessions while they are starting, stopping, or deleting. Slow +management operations do not block input or lease renewal on other open desktops. +Desktop profiles share the workspace's files and permissions; they are not +security sandboxes. diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json index 01e05b6..5a54cb7 100644 --- a/apps/web/content/docs/guides/meta.json +++ b/apps/web/content/docs/guides/meta.json @@ -18,6 +18,7 @@ "authentication", "destinations", "personal-computers", + "desktops", "project-sync" ] } diff --git a/daemon/DESKTOP.md b/daemon/DESKTOP.md index 7f13afb..a3a7ee8 100644 --- a/daemon/DESKTOP.md +++ b/daemon/DESKTOP.md @@ -1,6 +1,6 @@ # Workspace desktop control -Protocol version 1 is implemented in the daemon, Go and TypeScript SDKs, and the +Protocol version 2 is implemented in the daemon, Go and TypeScript SDKs, and the Mindwire iOS client. These source changes require a daemon release before existing workspaces can use them. `/healthz.surfaceProtocolVersion` is the compatibility gate; clients do not infer support from the app version. @@ -10,15 +10,16 @@ clients do not infer support from the app version. | Concern | Authority | | --- | --- | | Workspace existence, power, execution targets | Oblien | -| Desktop support, enablement, availability and expiring SSH grants | Oblien image/runtime desktop API | +| Desktop support, saved GUI sessions, resolution and expiring SSH grants | Oblien image/runtime desktop API | | Personal Mac desktop opt-in and local account login | Owner through `mindwire desktop` on the Mac | -| Sessions, current controller, approval and input receipts | Mindwire's workspace surface service | +| Project associations, control sessions, approvals and input receipts | Mindwire's workspace surface service | | Agent execution and permission responses | Existing run/interaction services | | Screenshots referenced by chat tools | Workspace artifact store | | Navigation, zoom, keyboard, cached snapshots and collapsed tool cards | Client | -Desktop scope is the whole workspace. Projects and agent profiles in the same -workspace share one controller. The provider's cloud workspace ID and the registry +Each saved desktop has one controller shared by agents and phone viewers on that +display. Projects on an Oblien Linux VM can use separate saved desktops; a Mac +console remains one shared screen. The provider's cloud workspace ID and the registry workspace ID are distinct; the binding validates both. Registry schema 3 adds `surface_records` to the existing SQLite database. Provider secrets stay in the existing private credential store, never registry snapshots or chat messages. @@ -28,11 +29,11 @@ existing private credential store, never registry snapshots or chat messages. Both transports carry native VNC/RFB. The iOS viewer uses Oblien's documented **desktop-only SSH tunnel**: -1. Read `/desktop/status` with a workspace gateway token. +1. Read `/desktop/sessions/:desktopId/status` with a workspace gateway token. 2. Enable `/desktop/enable` only after an explicit Connect action when needed. 3. The signed-in app obtains `POST /workspace/:id/desktop/ssh` through the management - API. Its owner session JWT remains in the app. -4. Validate the grant's workspace, expiry, destination and SHA-256 host fingerprint. + API with `{session_id: desktopId}`. Its owner session JWT remains in the app. +4. Validate the grant's workspace, saved session ID, expiry, destination and SHA-256 host fingerprint. 5. Connect to `ssh.oblien.com:22`, pin the host key, and open `direct-tcpip` to `127.0.0.1:5900`. The returned VNC authentication is `none` inside that tunnel. @@ -47,12 +48,12 @@ initialization, backpressure and closure are shared by both halves of the tunnel The viewer itself sends no keyboard, pointer or clipboard input. The workspace daemon uses the provider's authenticated binary WebSocket endpoint, -`/desktop/ws`, with a bearer header and the `binary` subprotocol, matching Oblien's +`/desktop/sessions/:desktopId/ws`, with a bearer header and the `binary` subprotocol, matching Oblien's desktop tunnel SDK. On macOS images, QEMU's display is a private socket in the Linux launcher; it is not a VNC server inside the Mac, and outbound SSH to the public gateway can be unavailable. WSS carries RFB bytes to that display. It does not change the iOS viewer or introduce a browser renderer. The daemon shares one RFB -connection across its sessions; individual agents use the shared tools rather than +connection per saved desktop across its control sessions; individual agents use the shared tools rather than opening their own display connections. The Go RFB adapter handles standard RFB 3.8 None security results, explicit BGR @@ -133,10 +134,10 @@ Mindwire's controller lease. ## Sessions, permission and input -Opening the iOS screen reads a cached capability check; it does not install a -service, enable access, create a VNC stream or take control. Connect opens a **view** -session. Taking control is explicit, with confirmation if another controller owns -it. Multiple viewers can stay connected. Exclusivity applies to Mindwire clients; +Opening a saved desktop connects a **view** session and acquires a free controller +by default. A busy controller requires explicit takeover. The picker does not +install services or start hidden display streams. Multiple viewers can stay +connected. Exclusivity applies to Mindwire clients; Oblien's dashboard or an external VNC app is outside this lease protocol. Agents request desktop permission through the same structured interaction that @@ -218,16 +219,18 @@ are preserved, and the built-in server name cannot be shadowed. The service owns desktop approval. Its private Codex MCP overlay uses the native [`default_tools_approval_mode = "approve"`](https://developers.openai.com/codex/mcp/) -setting; Claude receives exact allow rules for these five tools. This only lets +setting; Claude receives exact allow rules for these seven tools. This only lets calls reach the service's permission gate, avoiding a second native prompt per operation. Existing user settings and explicit deny rules remain in force. -The same five tools serve both harnesses: +The same tools serve both harnesses: | Tool | Result | | --- | --- | -| `surface_status` | Workspace desktop capability/state and current controller | -| `surface_open` | Approved view/control session | +| `surface_desktops` | Project desktops and provider limits | +| `surface_project_desktop` | Approved create/reuse/start of the project's desktop, with saved ID and control session | +| `surface_status` | Selected desktop capability/state and current controller | +| `surface_open` | Approved view/control session; project-aware when a catalog is bound | | `surface_capture` | Real image content, frame ID, geometry and durable artifact ID | | `surface_action` | Input receipt; transient clipboard output when requested | | `surface_release` | Close this run's session and release its input | @@ -239,7 +242,12 @@ and would discard accompanying image content. Shared normalization produces capture references. Native Codex/Claude history is merged with service-owned permissions and artifact metadata. Images remain in history after the turn ends, cancellation, reconnect or service restart; opening history does not expand cards. -Other harnesses are not currently given the built-in MCP server. +Harnesses without per-turn MCP support receive a run-scoped command helper using +the same MCP service. `"$MINDWIRE_DESKTOP_HELPER" --desktop-tool list` lists the +schemas; `--desktop-tool TOOL 'JSON'` calls one. Screenshots become private PNG +files for the harness's image tool. The loopback URL and run token come from the +environment; no global harness configuration changes. Files and authorization are +removed when the run finishes. PNG artifacts are authenticated via `/artifacts/:id`, stored with private file permissions and bounded to 8 MiB / 16 million pixels per image and 512 MiB per @@ -248,6 +256,45 @@ lazily after 24 hours. Input receipt deduplication is retained for at least 24 h and capped at 100,000 retained actions. Temporary grounding metadata is pruned independently, so pruning does not remove chat screenshots. +## Saved project desktops + +Oblien SDK 2.8 supplies `desktop.sessions` on management and runtime clients. The +app uses its typed Swift equivalent for workspace discovery and creation. The +daemon uses the root runtime `/desktop/sessions` API, never an execution-target +path. Capabilities decide virtual versus console mode, creation limits and sizes. + +`PUT /surfaces/desktop/runtime` registers an expiring workspace runtime token in +the existing private credentials file. Renewing it preserves active controllers. +The app registers it before project desktop operations and agent turns, including +when no viewer has opened. Saved desktops require surface protocol 2. + +Project links and create requests persist in `surface_records`. Creation is +serialized; its original body and hashed idempotency key survive lost responses +and daemon restarts. Retry the same request ID and body. Provider keys are 64 +bytes and names at most 80 UTF-8 bytes. Captures, receipts, control and tool cards +retain the saved desktop ID. Agent tools reject another project's desktop. + +Approval precedes enabling or creating the project's desktop. On Linux, use +Alt+F2 followed by `xdg-open http://localhost:3000` to open the browser in that +specific GUI session. Start the development server with the harness's normal +command tool; never guess DISPLAY or start another VNC server. The phone opens +the same ID from the project or tool card. Profiles share workspace files and +are not security sandboxes. + +Viewer close/run completion releases control and keeps apps running. Stop closes +apps but retains the profile. Delete first stops a virtual desktop, releases its +controllers, and removes its private profile; project files remain. Repeating a +completed delete succeeds. The current Oblien runtime requires a virtual desktop +to be stopped before changing its resolution; Mindwire never implicitly stops apps +for a resize. Renaming still works while running. A Mac console uses its OS size. +A saved console cannot compete with an open +legacy default viewer on that same screen. + +The catalog has no background refresh worker. Children are created only when +selected and use the root service's existing lease reaper. The app coalesces +operations, caches navigation reads, persists pending create requests, and polls +only visible sessions that are starting, stopping or deleting. + ## HTTP and SDK contract All routes use existing daemon authentication and transport. They are workspace @@ -260,6 +307,11 @@ scoped, regardless of `?agent=` or `withAgent()`. | GET | `/surfaces/desktop/local` | Safe local Mac setup status | | PUT | `/surfaces/desktop/local` | Write-only setup; requires the local CLI control credential | | PUT | `/surfaces/desktop/binding` | Write-only scoped provider authorization | +| PUT | `/surfaces/desktop/runtime` | Write-only runtime authorization for saved desktops and agent tools | +| GET / POST | `/surfaces/desktop/catalog` | List/create saved desktops; optional project association | +| GET / PATCH / DELETE | `/surfaces/desktop/catalog/:desktopId` | Inspect, rename/resize or stop-and-delete a desktop | +| POST | `/surfaces/desktop/catalog/:desktopId/start` or `/stop` | Start/stop the saved desktop | +| POST | `/surfaces/desktop/projects/:projectId/ensure` | Reuse or create the project's desktop | | GET | `/surfaces/desktop/events` | Current snapshot, then live revisions/heartbeats | | POST | `/surfaces/desktop/sessions` | Open view or control session | | POST | `/surfaces/desktop/sessions/:id/control` | Acquire, renew, release or user takeover | @@ -274,14 +326,20 @@ exposes `client.surfaces`, including a cancellable status watch. Embedded Go err and HTTP errors use the same code/status mapping. Provider failures are reflected in status snapshots; failed control actions return actionable protocol errors. +Select a display using HTTP `?desktopId=`, TypeScript +`client.surfaces.desktop(id)`, or Go `Client.Surfaces.ForDesktop(id)`. This scopes +status, events, control, captures and receipts. Catalog operations stay workspace +scoped. Saved provider desktop IDs differ from ephemeral control-session IDs. + Binding refreshes preserve active control. A fresh app gateway token does not replace an unexpired SSH grant unnecessarily. Authorization eventually expires; opening Desktop obtains another scoped grant. The app never refreshes by silently re-enabling a disabled Runtime API. Closing/backgrounding the viewer releases only its human session, leaving an authorized agent's independent session running. Temporary iOS inactive states keep the viewer connected. Foreground return opens -a fresh view session automatically, without reclaiming a control lease or -replaying input. Explicit Disconnect/Done cancels that intent. +a fresh view session and reacquires a free controller only if the user still wants +control. It never takes an occupied lease or replays input. Explicit Release, +Disconnect or Done cancels automatic control intent. ## Browser preview @@ -330,6 +388,17 @@ Opt-in tests: `WorkspaceDesktopLiveTests` on the signed-in iPhone, `CODEX_LOCAL=1 go test ./internal/agent/codex -run '^TestAppServerDesktopMCP$'`. Fixtures stay outside source control and must be removed after verification. +Saved-desktop tests cover concurrent creation, uncertain replies and restart, +independent controllers/captures, project isolation, denied approval, console +reuse, stop-before-delete and private helper cleanup. Native project-tool checks +use `TestHarnessProjectDesktopMCP` with `MINDWIRE_DESKTOP_HARNESS=codex` or +`claude-code`. For Claude, `MINDWIRE_DESKTOP_MODEL_FIXTURE=1` scripts only the +Messages API while exercising the installed CLI and real MCP service. +`TestProjectDesktopOblienLive` uses a private `MINDWIRE_PROJECT_DESKTOP_FIXTURE` +with workspace ID and runtime token. It creates a disposable virtual desktop, +checks resolution, MCP screenshot/input, shared viewer, stop/start and durable +reassociation, then deletes that test desktop. + Personal Mac coverage includes ARD authentication, local opt-in/disable, restart restoration, credential isolation, input receipts, display revocation and actual paired SSH forwarding over TCP and WebSocket. `MacDesktopTests` verifies cached and diff --git a/daemon/cmd/daemon/main.go b/daemon/cmd/daemon/main.go index 542c716..b1eb8e4 100644 --- a/daemon/cmd/daemon/main.go +++ b/daemon/cmd/daemon/main.go @@ -52,6 +52,15 @@ import ( ) func main() { + if len(os.Args) > 1 && os.Args[1] == "--desktop-tool" { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + if err := surface.DesktopToolHelper(ctx, os.Args[2:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } if len(os.Args) > 1 && (os.Args[1] == "--git-credential" || os.Args[1] == "--git-ssh") { var err error if os.Args[1] == "--git-credential" { diff --git a/daemon/internal/agent/surface.go b/daemon/internal/agent/surface.go index 301e130..a27a092 100644 --- a/daemon/internal/agent/surface.go +++ b/daemon/internal/agent/surface.go @@ -9,6 +9,7 @@ import ( // durable artifact references; base64 image/clipboard content stays out of feeds. type SurfaceToolAction struct { SurfaceID string `json:"surfaceId"` + DesktopID string `json:"desktopId,omitempty"` Operation string `json:"operation"` SessionID string `json:"sessionId,omitempty"` ReceiptID string `json:"receiptId,omitempty"` @@ -27,6 +28,12 @@ func NormalizeSurfaceTool(tool *ToolEvent) { return } known := strings.Contains(tool.Name, "mindwire_desktop") + helper := false + if !known && surfaceOutputPayload(tool.Output) != nil { + input, _ := json.Marshal(tool.Input) + helper = strings.Contains(string(input), "MINDWIRE_DESKTOP_HELPER") || strings.Contains(string(input), "--desktop-tool") + known = helper + } if tool.Action != nil && tool.Action.MCP != nil { known = known || tool.Action.MCP.Server == "mindwire_desktop" } @@ -34,24 +41,42 @@ func NormalizeSurfaceTool(tool *ToolEvent) { return } tool.Input = surfaceCardInput(tool.Input) + if helper { + tool.Input = map[string]any{"desktopHelper": true} + } if tool.Action == nil { tool.Action = &ToolAction{Kind: KindMCP, MCP: &MCPCall{Server: "mindwire_desktop"}} } else { copy := *tool.Action tool.Action = © } + if helper { + tool.Action = &ToolAction{Kind: KindMCP, MCP: &MCPCall{Server: "mindwire_desktop"}} + } op := "desktop" for _, name := range []string{"status", "open", "capture", "action", "release"} { if strings.Contains(tool.Name, "surface_"+name) { op = name } } + if strings.Contains(tool.Name, "surface_desktops") { + op = "list" + } + if strings.Contains(tool.Name, "surface_project_desktop") { + op = "open" + } view := &SurfaceToolAction{SurfaceID: "desktop", Operation: op} if tool.Action.Surface != nil { copy := *tool.Action.Surface view = © } if payload := surfaceOutputPayload(tool.Output); payload != nil { + if desktopID, ok := payload["desktopId"].(string); ok { + view.DesktopID = desktopID + } + if id, ok := payload["surfaceId"].(string); ok { + view.SurfaceID = id + } if operation, ok := payload["operation"].(string); ok { view.Operation = operation } @@ -85,7 +110,7 @@ func NormalizeSurfaceTool(tool *ToolEvent) { if tool.Action.Surface == nil { tool.Action.Surface = view } - title := map[string]string{"status": "Check desktop", "open": "Open desktop", "capture": "Capture desktop", "action": "Desktop input", "click": "Click desktop", "drag": "Drag on desktop", "scroll": "Scroll desktop", "pointer": "Move pointer", "key": "Press keys", "text": "Type text", "clipboard_read": "Read desktop clipboard", "clipboard_write": "Write desktop clipboard", "release": "Release desktop"}[view.Operation] + title := map[string]string{"list": "Project desktops", "status": "Check desktop", "open": "Open desktop", "capture": "Capture desktop", "action": "Desktop input", "click": "Click desktop", "drag": "Drag on desktop", "scroll": "Scroll desktop", "pointer": "Move pointer", "key": "Press keys", "text": "Type text", "clipboard_read": "Read desktop clipboard", "clipboard_write": "Write desktop clipboard", "release": "Release desktop"}[view.Operation] if title == "" { title = "Desktop" } diff --git a/daemon/internal/agent/surface_test.go b/daemon/internal/agent/surface_test.go index efd8020..ceb1ce1 100644 --- a/daemon/internal/agent/surface_test.go +++ b/daemon/internal/agent/surface_test.go @@ -83,3 +83,16 @@ func TestSurfaceHistoryRetainsOneToolAndResolvedApproval(t *testing.T) { t.Fatal("normalizer mutated native transcript") } } + +func TestDesktopHelperNormalizesToTheSavedDesktopWithoutCommandOrImageBytes(t *testing.T) { + event := ToolEvent{Name: "exec_command", Input: map[string]any{"command": `"$MINDWIRE_DESKTOP_HELPER" --desktop-tool surface_capture '{}'`}, + Action: &ToolAction{Kind: KindShell}, Output: `{"mindwireSurface":{"surfaceId":"ds_0123456789abcdef","desktopId":"ds_0123456789abcdef","operation":"capture","capture":{"id":"frame","artifactId":"artifact","geometry":{"width":960,"height":640}}},"imagePath":"/private/run/image.png"}`} + NormalizeSurfaceTool(&event) + if event.Action.Kind != KindMCP || event.Action.Surface.DesktopID != "ds_0123456789abcdef" || event.Action.Surface.Capture == nil { + t.Fatal("helper lost selected desktop metadata") + } + encoded, _ := json.Marshal(event) + if strings.Contains(string(encoded), "--desktop-tool") || strings.Contains(string(encoded), "/private/run") { + t.Fatal("helper command or local image path leaked into transcript") + } +} diff --git a/daemon/internal/api/api.go b/daemon/internal/api/api.go index cec05cd..91c5756 100644 --- a/daemon/internal/api/api.go +++ b/daemon/internal/api/api.go @@ -160,6 +160,14 @@ func (a *API) Routes() []Route { {"GET", "/surfaces", a.surfacesList}, {"GET", "/surfaces/desktop", a.surfaceStatus}, {"PUT", "/surfaces/desktop/binding", a.surfaceBind}, + {"PUT", "/surfaces/desktop/runtime", a.surfaceRuntimeBind}, + {"GET", "/surfaces/desktop/catalog", a.surfaceCatalog}, + {"POST", "/surfaces/desktop/catalog", a.surfaceCreateDesktop}, + {"GET", "/surfaces/desktop/catalog/{desktopID}", a.surfaceDesktopOperation}, + {"PATCH", "/surfaces/desktop/catalog/{desktopID}", a.surfaceDesktopOperation}, + {"DELETE", "/surfaces/desktop/catalog/{desktopID}", a.surfaceDesktopOperation}, + {"POST", "/surfaces/desktop/catalog/{desktopID}/{operation}", a.surfaceDesktopOperation}, + {"POST", "/surfaces/desktop/projects/{id}/ensure", a.surfaceProjectDesktop}, {"GET", "/surfaces/desktop/local", a.surfaceLocalInfo}, {"PUT", "/surfaces/desktop/local", a.surfaceLocalConfigure}, {"GET", "/surfaces/desktop/events", a.surfaceEvents}, diff --git a/daemon/internal/api/desktop_catalog.go b/daemon/internal/api/desktop_catalog.go new file mode 100644 index 0000000..c81efba --- /dev/null +++ b/daemon/internal/api/desktop_catalog.go @@ -0,0 +1,97 @@ +package api + +import ( + "github.com/oblien/mindwire/daemon/internal/surface" + "net/http" +) + +func (a *API) surfaceRuntimeBind(w http.ResponseWriter, r *http.Request) { + s := a.desktop(w, nil) + if s == nil { + return + } + var binding surface.RuntimeBinding + if !desktopBody(w, r, &binding) { + return + } + if err := s.BindRuntime(binding, a.store); err != nil { + surfaceError(w, err) + return + } + writeJSON(w, 200, map[string]bool{"configured": true}) +} +func (a *API) surfaceCatalog(w http.ResponseWriter, r *http.Request) { + s := a.desktop(w, nil) + if s == nil { + return + } + value, err := s.ListDesktops(r.Context(), r.URL.Query().Get("projectId")) + if err != nil { + surfaceError(w, err) + return + } + writeJSON(w, 200, value) +} +func (a *API) surfaceCreateDesktop(w http.ResponseWriter, r *http.Request) { + s := a.desktop(w, nil) + if s == nil { + return + } + var request surface.CreateDesktopRequest + if !desktopBody(w, r, &request) { + return + } + value, err := s.CreateDesktop(r.Context(), request) + if err != nil { + surfaceError(w, err) + return + } + writeJSON(w, 201, surface.DesktopResult{Success: true, Session: value}) +} +func (a *API) surfaceProjectDesktop(w http.ResponseWriter, r *http.Request) { + s := a.desktop(w, nil) + if s == nil { + return + } + value, err := s.EnsureProjectDesktop(r.Context(), r.PathValue("id")) + if err != nil { + surfaceError(w, err) + return + } + writeJSON(w, 200, surface.DesktopResult{Success: true, Session: value}) +} +func (a *API) surfaceDesktopOperation(w http.ResponseWriter, r *http.Request) { + s := a.desktop(w, nil) + if s == nil { + return + } + operation := r.PathValue("operation") + var update *surface.UpdateDesktopRequest + switch r.Method { + case "GET": + operation = "get" + case "PATCH": + operation = "update" + update = &surface.UpdateDesktopRequest{} + if !desktopBody(w, r, update) { + return + } + case "DELETE": + operation = "delete" + case "POST": + if operation != "start" && operation != "stop" { + surfaceError(w, &surface.Error{Code: "invalid_request", Message: "Choose start or stop."}) + return + } + } + value, err := s.DesktopOperation(r.Context(), r.PathValue("desktopID"), operation, update) + if err != nil { + surfaceError(w, err) + return + } + if operation == "delete" && value.Session.ID == "" { + writeJSON(w, 200, map[string]bool{"success": value.Success}) + return + } + writeJSON(w, 200, value) +} diff --git a/daemon/internal/api/surfaces.go b/daemon/internal/api/surfaces.go index a364043..e46132c 100644 --- a/daemon/internal/api/surfaces.go +++ b/daemon/internal/api/surfaces.go @@ -39,7 +39,7 @@ func (a *API) ServeDesktopViewer(ctx context.Context, sessionID string, stream i } func (a *API) surfaceLocalInfo(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -58,7 +58,7 @@ func (a *API) surfaceLocalConfigure(w http.ResponseWriter, r *http.Request) { surfaceError(w, &surface.Error{Code: "forbidden", Message: "Run mindwire desktop on this Mac to manage desktop access."}) return } - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -80,10 +80,18 @@ func surfaceError(w http.ResponseWriter, err error) { writeJSON(w, status, map[string]any{"error": detail.Message, "code": detail.Code}) } -func (a *API) desktop(w http.ResponseWriter) *surface.Service { +func (a *API) desktop(w http.ResponseWriter, r *http.Request) *surface.Service { if a.surfaces == nil { surfaceError(w, &surface.Error{Code: "unsupported", Message: "Desktop control requires an updated workspace service."}) } + if a.surfaces != nil && r != nil { + value, err := a.surfaces.ForDesktop(r.URL.Query().Get("desktopId")) + if err != nil { + surfaceError(w, err) + return nil + } + return value + } return a.surfaces } func desktopBody(w http.ResponseWriter, r *http.Request, out any) bool { @@ -94,14 +102,14 @@ func desktopBody(w http.ResponseWriter, r *http.Request, out any) bool { return true } func (a *API) surfacesList(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } writeJSON(w, 200, []surface.Snapshot{s.Snapshot()}) } func (a *API) surfaceStatus(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -113,7 +121,7 @@ func (a *API) surfaceStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.Snapshot()) } func (a *API) surfaceBind(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, nil) if s == nil { return } @@ -125,11 +133,16 @@ func (a *API) surfaceBind(w http.ResponseWriter, r *http.Request) { surfaceError(w, err) return } - snapshot, _ := s.Refresh(r.Context()) + selected, err := s.ForDesktop(binding.DesktopID) + if err != nil { + surfaceError(w, err) + return + } + snapshot, _ := selected.Refresh(r.Context()) writeJSON(w, 200, snapshot) } func (a *API) surfaceOpen(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -145,7 +158,7 @@ func (a *API) surfaceOpen(w http.ResponseWriter, r *http.Request) { writeJSON(w, 201, value) } func (a *API) surfaceControl(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -161,7 +174,7 @@ func (a *API) surfaceControl(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, value) } func (a *API) surfaceClose(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -172,7 +185,7 @@ func (a *API) surfaceClose(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"closed": true}) } func (a *API) surfaceCapture(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -184,7 +197,7 @@ func (a *API) surfaceCapture(w http.ResponseWriter, r *http.Request) { writeJSON(w, 201, value) } func (a *API) surfaceAction(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -200,7 +213,7 @@ func (a *API) surfaceAction(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, value) } func (a *API) surfaceReceipt(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -212,7 +225,7 @@ func (a *API) surfaceReceipt(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, value) } func (a *API) artifact(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } @@ -225,7 +238,7 @@ func (a *API) artifact(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, value) } func (a *API) surfaceEvents(w http.ResponseWriter, r *http.Request) { - s := a.desktop(w) + s := a.desktop(w, r) if s == nil { return } diff --git a/daemon/internal/orchestrator/surfaces.go b/daemon/internal/orchestrator/surfaces.go index 1c1a7b3..c78eba1 100644 --- a/daemon/internal/orchestrator/surfaces.go +++ b/daemon/internal/orchestrator/surfaces.go @@ -69,7 +69,7 @@ func (s *Supervisor) desktopTurn(ctx context.Context, a *Agent, turn runner.Turn if service == nil { return turn, func() {}, nil } - actor := surface.Actor{Kind: "agent", Name: a.ID(), RunID: turn.RunID, ChatID: turn.ChatID} + actor := surface.Actor{Kind: "agent", Name: a.ID(), RunID: turn.RunID, ChatID: turn.ChatID, CWD: turn.CWD} overlay, env, cleanup, err := service.BindRun(ctx, actor, a.ID(), turn.Options.MCPServers) if err != nil { return turn, func() {}, err @@ -87,6 +87,9 @@ func (s *Supervisor) desktopTurn(ctx context.Context, a *Agent, turn runner.Turn for key, value := range env { turn.Environment[key] = value } + if env["MINDWIRE_DESKTOP_HELPER"] != "" { + turn.Message += surface.FallbackInstructions() + } turn.AttachEmitter = func(emit agent.Emit) func() { s.mu.Lock() s.serviceEmit[turn.RunID] = emit diff --git a/daemon/internal/registry/surfaces.go b/daemon/internal/registry/surfaces.go index 64fb50a..7c68d4f 100644 --- a/daemon/internal/registry/surfaces.go +++ b/daemon/internal/registry/surfaces.go @@ -53,6 +53,10 @@ func (st *Store) SurfaceList(kind string) ([]json.RawMessage, error) { func (st *Store) Identity() string { return st.identity } +// Desktop tools resolve the run's native working directory through the same +// project records used by the app. They never invent a second project registry. +func (st *Store) ProjectAtPath(path string) (*Project, error) { return projectAtPath(st.db, path, "") } + // Prune only transient control metadata. Chat image retention is independent. func (st *Store) SurfacePruneBefore(kind string, before time.Time) error { _, err := st.db.Exec("DELETE FROM surface_records WHERE kind=? AND updated_at < ?", kind, before.UTC().Format(time.RFC3339Nano)) diff --git a/daemon/internal/surface/binding.go b/daemon/internal/surface/binding.go index 91cd4f6..eb87dea 100644 --- a/daemon/internal/surface/binding.go +++ b/daemon/internal/surface/binding.go @@ -33,6 +33,12 @@ func NewConfigured(db *registry.Store, credentials Credentials) (*Service, error } } } + if raw := credentials.Get(runtimeBindingKey); raw != "" { + var binding RuntimeBinding + if json.Unmarshal([]byte(raw), &binding) == nil && binding.RegistryID == db.Identity() { + _ = s.BindRuntime(binding, credentials) + } + } return s, nil } @@ -43,6 +49,13 @@ func (s *Service) Bind(binding Binding, credentials Credentials) error { if _, err := NewOblien(binding); err != nil { return err } + if binding.DesktopID != "" { + expires := runtimeExpiry(binding.GatewayToken) + if expires.IsZero() { + expires = binding.Connection.ExpiresAt + } + return s.BindRuntime(RuntimeBinding{RegistryID: binding.RegistryID, WorkspaceID: binding.WorkspaceID, GatewayToken: binding.GatewayToken, ExpiresAt: expires}, credentials) + } s.operation.Lock() defer s.operation.Unlock() if s.macFactory != nil { @@ -68,7 +81,9 @@ func (s *Service) Bind(binding Binding, credentials Credentials) error { return err } if ok && old.binding.Connection == binding.Connection { + old.mu.Lock() old.binding.GatewayToken = binding.GatewayToken + old.mu.Unlock() return nil } provider, err := NewOblien(binding) diff --git a/daemon/internal/surface/catalog.go b/daemon/internal/surface/catalog.go new file mode 100644 index 0000000..8b3b00b --- /dev/null +++ b/daemon/internal/surface/catalog.go @@ -0,0 +1,667 @@ +package surface + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/oblien/mindwire/daemon/internal/registry" +) + +const runtimeBindingKey = "#surface:oblien-runtime" + +// RuntimeBinding is write-only and workspace-scoped. No account credential is +// stored in the daemon. It enables tools even before a human opens the viewer. +type RuntimeBinding struct { + RegistryID string `json:"registryId"` + WorkspaceID string `json:"workspaceId"` + GatewayToken string `json:"gatewayToken"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` +} + +type DesktopResolution struct { + Width int `json:"width"` + Height int `json:"height"` +} +type DesktopSessionCapabilities struct { + Mode string `json:"mode"` + MaxSessions int `json:"max_sessions"` + CanCreate bool `json:"can_create"` + Resolution *struct { + Min DesktopResolution `json:"min"` + Max DesktopResolution `json:"max"` + Default DesktopResolution `json:"default"` + } `json:"resolution,omitempty"` +} +type SavedDesktop struct { + ID string `json:"id"` + Name string `json:"name"` + Resolution *DesktopResolution `json:"resolution,omitempty"` + State string `json:"state"` + Managed bool `json:"managed"` + Available bool `json:"available"` + CanResize *bool `json:"can_resize,omitempty"` + CanDelete *bool `json:"can_delete,omitempty"` + DeletionPending *bool `json:"deletion_pending,omitempty"` + Mode string `json:"mode,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Error string `json:"error,omitempty"` +} +type DesktopCatalog struct { + Success bool `json:"success"` + Sessions []SavedDesktop `json:"sessions"` + Capabilities DesktopSessionCapabilities `json:"capabilities"` + ProjectID string `json:"projectId,omitempty"` +} +type DesktopResult struct { + Success bool `json:"success"` + Session SavedDesktop `json:"session"` +} +type CreateDesktopRequest struct { + RequestID string `json:"requestId"` + ProjectID string `json:"projectId,omitempty"` + Name string `json:"name"` + Resolution *DesktopResolution `json:"resolution,omitempty"` +} +type UpdateDesktopRequest struct { + Name *string `json:"name,omitempty"` + Resolution *DesktopResolution `json:"resolution,omitempty"` +} +type desktopCreation struct { + Request CreateDesktopRequest `json:"request"` + Resolution *DesktopResolution `json:"resolution,omitempty"` + Result *SavedDesktop `json:"result,omitempty"` +} +type desktopLink struct { + ProjectID string `json:"projectId"` + DesktopID string `json:"desktopId"` +} + +// The catalog has no polling worker. Only open controllers are instantiated, +// and the root Service's existing lease reaper also covers those controllers. +type desktopCatalog struct { + // Management operations serialize creation/lifecycle requests. Existing + // viewers use childrenMu so a slow stop/delete cannot stall other displays' + // input, heartbeats or the shared lease reaper. Map/closed writes hold both. + mu sync.Mutex + childrenMu sync.Mutex + binding RuntimeBinding + backend *Oblien + children map[string]*Service + closed bool + providerFactory func(string) (Provider, error) // integration-test seam +} + +var desktopIDPattern = regexp.MustCompile(`^(console|ds_[a-f0-9]{16})$`) + +func validDesktopID(id string) bool { return desktopIDPattern.MatchString(id) } + +func runtimeExpiry(token string) time.Time { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{} + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{} + } + var claims struct { + Exp int64 `json:"exp"` + } + if json.Unmarshal(raw, &claims) != nil || claims.Exp <= 0 { + return time.Time{} + } + return time.Unix(claims.Exp, 0) +} + +func (s *Service) BindRuntime(binding RuntimeBinding, credentials Credentials) error { + if s.catalog == nil || binding.RegistryID != s.db.Identity() { + return problem("workspace_mismatch", "Reload this workspace before authorizing its desktops.") + } + if binding.ExpiresAt.IsZero() { + binding.ExpiresAt = runtimeExpiry(binding.GatewayToken) + } + if binding.ExpiresAt.IsZero() || binding.GatewayToken == "" { + return problem("invalid_binding", "Provide the workspace runtime's expiring desktop authorization.") + } + p, err := NewOblien(Binding{RegistryID: binding.RegistryID, WorkspaceID: binding.WorkspaceID, GatewayToken: binding.GatewayToken, RuntimeExpiresAt: binding.ExpiresAt}) + if err != nil { + return err + } + s.operation.Lock() + defer s.operation.Unlock() + if s.macFactory != nil { + return problem("unsupported", "This computer uses its local desktop setup.") + } + s.mu.Lock() + previous := s.provider + s.mu.Unlock() + if old, ok := previous.(*Oblien); ok && old.bindingSnapshot().WorkspaceID != binding.WorkspaceID { + return problem("workspace_mismatch", "This service belongs to another Oblien workspace.") + } + c := s.catalog + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return problem("unavailable", "The desktop service stopped.") + } + if c.binding.WorkspaceID != "" && c.binding.WorkspaceID != binding.WorkspaceID { + return problem("workspace_mismatch", "This service belongs to another Oblien workspace.") + } + if c.binding == binding { + return nil + } + encoded, err := json.Marshal(binding) + if err != nil { + return err + } + if err = credentials.Set(runtimeBindingKey, string(encoded)); err != nil { + return err + } + c.binding = binding + if c.backend != nil { + p.base = c.backend.base + p.http = c.backend.http + } + c.backend = p + for _, child := range c.children { + child.operation.Lock() + child.mu.Lock() + if provider, ok := child.provider.(*Oblien); ok { + provider.mu.Lock() + provider.binding.GatewayToken = binding.GatewayToken + provider.binding.RuntimeExpiresAt = binding.ExpiresAt + provider.mu.Unlock() + child.snapshot.AuthorizationExpiresAt = &binding.ExpiresAt + child.signalLocked() + } + child.mu.Unlock() + child.operation.Unlock() + } + return nil +} + +func (s *Service) HasDesktopCatalog() bool { + if s.catalog == nil { + return false + } + s.catalog.mu.Lock() + defer s.catalog.mu.Unlock() + return s.catalog.backend != nil && !s.catalog.closed +} +func (s *Service) desktopChildren() []*Service { + if s.catalog == nil { + return nil + } + s.catalog.childrenMu.Lock() + defer s.catalog.childrenMu.Unlock() + out := make([]*Service, 0, len(s.catalog.children)) + for _, child := range s.catalog.children { + out = append(out, child) + } + return out +} + +// ForDesktop selects a physical display. A Mindwire control session is a +// different ID, and can never be used to select or change the physical display. +func (s *Service) ForDesktop(id string) (*Service, error) { + if id == "" || id == DesktopID { + return s, nil + } + if !validDesktopID(id) { + return nil, problem("invalid_request", "Invalid saved desktop ID.") + } + if s.catalog == nil { + if s.surfaceID == id { + return s, nil + } + return nil, problem("unsupported", "Select desktops on the workspace service.") + } + c := s.catalog + c.childrenMu.Lock() + if c.closed { + c.childrenMu.Unlock() + return nil, problem("unavailable", "The desktop service stopped.") + } + cached := c.children[id] + c.childrenMu.Unlock() + if cached != nil { + return cached, nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, problem("unavailable", "The desktop service stopped.") + } + if id == "console" { + s.mu.Lock() + legacyViewer := len(s.sessions) > 0 + s.mu.Unlock() + if legacyViewer { + return nil, problem("desktop_conflict", "Close the older desktop viewer before opening this saved console.") + } + } + if child := c.children[id]; child != nil { + return child, nil + } + if c.backend == nil { + return nil, problem("needs_authorization", "Refresh the workspace to authorize its saved desktops.") + } + if len(c.children) >= 32 { + return nil, problem("session_limit", "Close an unused desktop before opening another.") + } + binding := c.backend.bindingSnapshot() + binding.DesktopID = id + provider, err := NewOblien(binding) + if err != nil { + return nil, err + } + provider.base = c.backend.base + provider.http = c.backend.http + var p Provider = provider + if c.providerFactory != nil { + p, err = c.providerFactory(id) + if err != nil { + return nil, err + } + } + s.mu.Lock() + approval := s.approval + s.mu.Unlock() + child := &Service{db: s.db, artifacts: s.artifacts, surfaceID: id, provider: p, approval: approval, + sessions: map[string]*sessionState{}, openIDs: map[string]string{}, viewers: map[string]io.ReadWriteCloser{}, changed: make(chan struct{}), stop: make(chan struct{}), now: s.now} + child.snapshot = Snapshot{ID: id, DesktopID: id, WorkspaceID: s.db.Identity(), Kind: "desktop", Provider: "oblien", Version: Version, InstanceID: newID(), State: "disconnected"} + expiry := p.ExpiresAt() + if !expiry.IsZero() { + child.snapshot.AuthorizationExpiresAt = &expiry + } + c.childrenMu.Lock() + c.children[id] = child + c.childrenMu.Unlock() + return child, nil +} + +func (s *Service) serviceForSession(id string, actor Actor) (*Service, error) { + for _, candidate := range append([]*Service{s}, s.desktopChildren()...) { + candidate.mu.Lock() + _, exists := candidate.sessions[id] + candidate.mu.Unlock() + if exists { + if _, err := candidate.sessionCopy(id, actor); err != nil { + return nil, err + } + return candidate, nil + } + } + return nil, problem("session_expired", "Open a new desktop control session.") +} + +func (s *Service) projectForActor(actor Actor) (*registry.Project, error) { + if actor.ProjectID != "" { + return s.db.Project(actor.ProjectID) + } + if actor.ChatID != "" { + _, _, project, err := s.db.ChatContext(actor.ChatID) + if err == nil && project != nil { + return project, nil + } + } + if actor.CWD != "" { + path := filepath.Clean(actor.CWD) + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real + } + return s.db.ProjectAtPath(path) + } + return nil, registry.ErrNotFound +} + +func (s *Service) desktopLinks(projectID string) (map[string]bool, error) { + rows, err := s.db.SurfaceList("project_desktop_link") + if err != nil { + return nil, err + } + ids := map[string]bool{} + for _, row := range rows { + var link desktopLink + if err := json.Unmarshal(row, &link); err != nil { + return nil, err + } + if link.ProjectID == projectID { + ids[link.DesktopID] = true + } + } + return ids, nil +} +func (s *Service) listDesktopsLocked(ctx context.Context, projectID string) (DesktopCatalog, error) { + c := s.catalog + if c == nil || c.backend == nil { + return DesktopCatalog{}, problem("needs_authorization", "Refresh the workspace to authorize its desktops.") + } + if projectID != "" { + if _, err := s.db.Project(projectID); err != nil { + return DesktopCatalog{}, err + } + } + var result DesktopCatalog + if err := c.backend.json(ctx, "GET", "/desktop/sessions", nil, &result); err != nil { + return result, err + } + if result.Capabilities.Mode != "virtual" && result.Capabilities.Mode != "console" { + return result, problem("unsupported", "Update the workspace runtime to use saved desktops.") + } + if result.Sessions == nil { + result.Sessions = []SavedDesktop{} + } + if projectID != "" { + links, err := s.desktopLinks(projectID) + if err != nil { + return result, err + } + filtered := []SavedDesktop{} + for _, item := range result.Sessions { + if links[item.ID] { + filtered = append(filtered, item) + } + } + result.Sessions = filtered + result.ProjectID = projectID + } + return result, nil +} +func (s *Service) ListDesktops(ctx context.Context, projectID string) (DesktopCatalog, error) { + if s.catalog == nil { + return DesktopCatalog{}, problem("unsupported", "Saved desktops are available on Oblien workspaces.") + } + s.catalog.mu.Lock() + defer s.catalog.mu.Unlock() + return s.listDesktopsLocked(ctx, projectID) +} + +func (s *Service) createDesktopLocked(ctx context.Context, req CreateDesktopRequest, capabilities DesktopSessionCapabilities) (SavedDesktop, error) { + if !validRequest(req.RequestID) || len(req.Name) > 80 || strings.TrimSpace(req.Name) == "" { + return SavedDesktop{}, problem("invalid_request", "Use a desktop name of 1–80 bytes and a stable request ID.") + } + if req.ProjectID != "" { + if _, err := s.db.Project(req.ProjectID); err != nil { + return SavedDesktop{}, err + } + } + var previous desktopCreation + err := s.db.SurfaceGet("desktop_create", req.RequestID, &previous) + if err == nil { + a, _ := json.Marshal(req) + b, _ := json.Marshal(previous.Request) + if string(a) != string(b) { + return SavedDesktop{}, problem("request_conflict", "This request ID already created a different desktop.") + } + if previous.Result != nil { + return *previous.Result, nil + } + } else if !errors.Is(err, registry.ErrNotFound) { + return SavedDesktop{}, err + } + if req.Resolution != nil { + limits := capabilities.Resolution + if capabilities.Mode == "console" || limits == nil { + return SavedDesktop{}, problem("invalid_request", "This console uses the operating system's display size.") + } + if req.Resolution.Width < limits.Min.Width || req.Resolution.Height < limits.Min.Height || req.Resolution.Width > limits.Max.Width || req.Resolution.Height > limits.Max.Height { + return SavedDesktop{}, problem("invalid_request", "Choose a resolution within this workspace's supported range.") + } + } + resolution := req.Resolution + if resolution == nil && capabilities.Mode == "virtual" && capabilities.Resolution != nil { + resolution = &capabilities.Resolution.Default + } + if err == nil { + resolution = previous.Resolution + } + if err := s.db.SurfacePut("desktop_create", req.RequestID, desktopCreation{Request: req, Resolution: resolution}); err != nil { + return SavedDesktop{}, err + } + sum := sha256.Sum256([]byte(s.db.Identity() + ":" + req.RequestID)) + body := struct { + Name string `json:"name"` + Resolution *DesktopResolution `json:"resolution,omitempty"` + Key string `json:"idempotency_key"` + // The runtime accepts at most 64 bytes for an idempotency key. The hash + // already includes the workspace identity, so no additional prefix is needed. + }{req.Name, resolution, hex.EncodeToString(sum[:])} + var result DesktopResult + if err := s.catalog.backend.json(ctx, "POST", "/desktop/sessions", body, &result); err != nil { + return SavedDesktop{}, err + } + if !result.Success || !validDesktopID(result.Session.ID) { + return SavedDesktop{}, problem("unavailable", "The provider did not return a saved desktop ID. Retry this same request.") + } + if req.ProjectID != "" { + if err := s.db.SurfacePut("project_desktop_link", req.ProjectID+":"+result.Session.ID, desktopLink{req.ProjectID, result.Session.ID}); err != nil { + return SavedDesktop{}, err + } + } + if err := s.db.SurfacePut("desktop_create", req.RequestID, desktopCreation{Request: req, Resolution: resolution, Result: &result.Session}); err != nil { + return SavedDesktop{}, err + } + return result.Session, nil +} +func (s *Service) CreateDesktop(ctx context.Context, req CreateDesktopRequest) (SavedDesktop, error) { + if s.catalog == nil { + return SavedDesktop{}, problem("unsupported", "Saved desktops are available on Oblien workspaces.") + } + s.catalog.mu.Lock() + defer s.catalog.mu.Unlock() + list, err := s.listDesktopsLocked(ctx, "") + if err != nil { + return SavedDesktop{}, err + } + return s.createDesktopLocked(ctx, req, list.Capabilities) +} + +// EnsureProjectDesktop is atomic across callers and durable across a daemon +// restart. An uncertain create always reuses its saved request and provider key. +func (s *Service) EnsureProjectDesktop(ctx context.Context, projectID string) (SavedDesktop, error) { + if s.catalog == nil { + return SavedDesktop{}, problem("unsupported", "Saved desktops are available on Oblien workspaces.") + } + s.catalog.mu.Lock() + defer s.catalog.mu.Unlock() + project, err := s.db.Project(projectID) + if err != nil { + return SavedDesktop{}, err + } + if s.catalog.backend == nil { + return SavedDesktop{}, problem("needs_authorization", "Refresh the workspace desktop authorization.") + } + var status ProviderStatus + if err := s.catalog.backend.json(ctx, "GET", "/desktop/status", nil, &status); err != nil { + return SavedDesktop{}, err + } + if !status.Supported { + return SavedDesktop{}, problem("desktop_setup_required", "Install a desktop in the workspace's Desktop settings first.") + } + if !status.Enabled { + if err := s.catalog.backend.json(ctx, "POST", "/desktop/enable", nil, nil); err != nil { + return SavedDesktop{}, err + } + } + list, err := s.listDesktopsLocked(ctx, projectID) + if err != nil { + return SavedDesktop{}, err + } + for _, item := range list.Sessions { + if item.State != "deleting" { + return s.startDesktopLocked(ctx, item) + } + } + if list.Capabilities.Mode == "console" { + all, err := s.listDesktopsLocked(ctx, "") + if err != nil { + return SavedDesktop{}, err + } + for _, item := range all.Sessions { + if item.ID == "console" && item.State != "deleting" { + if err := s.db.SurfacePut("project_desktop_link", projectID+":"+item.ID, desktopLink{projectID, item.ID}); err != nil { + return SavedDesktop{}, err + } + return s.startDesktopLocked(ctx, item) + } + } + } + var req CreateDesktopRequest + err = s.db.SurfaceGet("project_desktop_default", projectID, &req) + if err != nil && !errors.Is(err, registry.ErrNotFound) { + return SavedDesktop{}, err + } + if req.RequestID != "" { + var old desktopCreation + if err := s.db.SurfaceGet("desktop_create", req.RequestID, &old); err == nil && old.Result != nil { + req = CreateDesktopRequest{} + } + } + if req.RequestID == "" { + req = CreateDesktopRequest{RequestID: newID(), ProjectID: projectID, Name: project.Name} + if strings.TrimSpace(req.Name) == "" { + req.Name = filepath.Base(project.Path) + } + if len(req.Name) > 80 { + req.Name = "Project desktop" + } + if err := s.db.SurfacePut("project_desktop_default", projectID, req); err != nil { + return SavedDesktop{}, err + } + } + return s.createDesktopLocked(ctx, req, list.Capabilities) +} + +func (s *Service) startDesktopLocked(ctx context.Context, item SavedDesktop) (SavedDesktop, error) { + if item.State != "stopped" && item.State != "failed" { + return item, nil + } + var result DesktopResult + err := s.catalog.backend.json(ctx, "POST", "/desktop/sessions/"+item.ID+"/start", nil, &result) + return result.Session, err +} +func (s *Service) DesktopOperation(ctx context.Context, id, operation string, update *UpdateDesktopRequest) (DesktopResult, error) { + if !validDesktopID(id) { + return DesktopResult{}, problem("invalid_request", "Invalid saved desktop ID.") + } + if s.catalog == nil { + return DesktopResult{}, problem("unsupported", "Saved desktops are available on Oblien workspaces.") + } + c := s.catalog + c.mu.Lock() + defer c.mu.Unlock() + if c.backend == nil { + return DesktopResult{}, problem("needs_authorization", "Refresh the workspace desktop authorization.") + } + method, path := "GET", "/desktop/sessions/"+id + var body any + switch operation { + case "get": + case "update": + if update == nil || (update.Name != nil && (strings.TrimSpace(*update.Name) == "" || len(*update.Name) > 80)) { + return DesktopResult{}, problem("invalid_request", "Use a desktop name of 1–80 bytes.") + } + method = "PATCH" + body = update + case "start", "stop": + method = "POST" + path += "/" + operation + case "delete": + // Oblien requires virtual desktops to be stopped before deleting their + // saved profile. Keep the user-confirmed operation in the shared service + // so live agents and viewers lose their leases as soon as stop succeeds. + if id != "console" { + cleanup, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + var current DesktopResult + if err := c.backend.json(cleanup, "GET", path, nil, ¤t); err != nil { + if desktopWasDeleted(err) { + c.retireDesktop(id) + return DesktopResult{Success: true}, nil + } + return current, err + } + if current.Session.State == "deleting" || (current.Session.DeletionPending != nil && *current.Session.DeletionPending) { + return current, nil + } + if current.Session.CanDelete != nil && !*current.Session.CanDelete { + return DesktopResult{}, problem("invalid_request", "This workspace does not allow deleting that desktop.") + } + for current.Session.State == "starting" || current.Session.State == "stopping" { + if err := c.waitDesktop(cleanup, path, ¤t); err != nil { + return current, err + } + } + if current.Session.State != "stopped" { + if err := c.backend.json(cleanup, "POST", path+"/stop", nil, ¤t); err != nil { + return current, err + } + c.retireDesktop(id) + for current.Session.State != "stopped" { + if err := c.waitDesktop(cleanup, path, ¤t); err != nil { + return current, err + } + } + } + } + method = "DELETE" + default: + return DesktopResult{}, problem("invalid_request", "Unknown desktop operation.") + } + var result DesktopResult + if err := c.backend.json(ctx, method, path, body, &result); err != nil { + if operation == "delete" && desktopWasDeleted(err) { + c.retireDesktop(id) + return DesktopResult{Success: true}, nil + } + return result, err + } + if operation == "stop" || operation == "delete" { + c.retireDesktop(id) + } + return result, nil +} + +func desktopWasDeleted(err error) bool { + var detail *Error + return errors.As(err, &detail) && detail.Code == "desktop_not_found" +} + +// Caller holds the catalog lock; these helpers never create a background poller. +func (c *desktopCatalog) waitDesktop(ctx context.Context, path string, out *DesktopResult) error { + timer := time.NewTimer(500 * time.Millisecond) + defer timer.Stop() + select { + case <-ctx.Done(): + return problem("preparing", "The desktop is still stopping. Retry Delete when it has stopped.") + case <-timer.C: + } + return c.backend.json(ctx, "GET", path, nil, out) +} + +func (c *desktopCatalog) retireDesktop(id string) { + if child := c.children[id]; child != nil { + c.childrenMu.Lock() + delete(c.children, id) + c.childrenMu.Unlock() + child.Close() + child.mu.Lock() + child.snapshot.State = "disconnected" + child.snapshot.Controller = nil + child.sessions = map[string]*sessionState{} + child.signalLocked() + child.mu.Unlock() + } +} diff --git a/daemon/internal/surface/catalog_test.go b/daemon/internal/surface/catalog_test.go new file mode 100644 index 0000000..658f08f --- /dev/null +++ b/daemon/internal/surface/catalog_test.go @@ -0,0 +1,682 @@ +package surface + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/oblien/mindwire/daemon/internal/registry" + "github.com/oblien/mindwire/daemon/internal/session" +) + +// Exercises the actual runtime HTTP catalog with independent display providers. +// No existing account, project, display or credential is used by these tests. +type catalogFixture struct { + mu sync.Mutex + server *httptest.Server + items map[string]SavedDesktop + keys map[string]string + bodies [][]byte + posts, starts, stops, enables, deletes int + loseFirstResponse bool + mode string + defaultWidth int + providers map[string]*testProvider + stopEntered chan struct{} + stopRelease <-chan struct{} +} + +func newCatalogFixture(t *testing.T) *catalogFixture { + t.Helper() + f := &catalogFixture{items: map[string]SavedDesktop{}, keys: map[string]string{}, mode: "virtual", defaultWidth: 1280, providers: map[string]*testProvider{}} + f.server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.server.Close) + return f +} +func (f *catalogFixture) serve(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/stop") { + f.mu.Lock() + entered, release := f.stopEntered, f.stopRelease + f.mu.Unlock() + if entered != nil { + close(entered) + } + if release != nil { + select { + case <-release: + case <-r.Context().Done(): + return + } + } + } + f.mu.Lock() + defer f.mu.Unlock() + if r.Header.Get("Authorization") != "Bearer runtime-test" { + http.Error(w, "unauthorized", 401) + return + } + w.Header().Set("Content-Type", "application/json") + write := func(v any) { _ = json.NewEncoder(w).Encode(v) } + if r.URL.Path == "/desktop/status" { + write(ProviderStatus{Supported: true, Enabled: false}) + return + } + if r.URL.Path == "/desktop/enable" { + f.enables++ + write(map[string]bool{"success": true}) + return + } + if r.URL.Path == "/desktop/sessions" { + if r.Method == "GET" { + items := []SavedDesktop{} + for _, item := range f.items { + items = append(items, item) + } + write(map[string]any{"success": true, "sessions": items, "capabilities": map[string]any{ + "mode": f.mode, "max_sessions": 10, "can_create": len(f.items) < 10, + "resolution": map[string]any{"min": DesktopResolution{640, 480}, "max": DesktopResolution{3840, 2160}, "default": DesktopResolution{f.defaultWidth, 800}}, + }}) + return + } + if r.Method == "POST" { + body, _ := io.ReadAll(r.Body) + var request struct { + Name string `json:"name"` + Key string `json:"idempotency_key"` + Resolution *DesktopResolution `json:"resolution"` + } + if json.Unmarshal(body, &request) != nil || request.Key == "" || len(request.Key) > 64 || len(request.Name) > 80 { + http.Error(w, "invalid desktop name or idempotency key", 400) + return + } + f.posts++ + f.bodies = append(f.bodies, body) + id := f.keys[request.Key] + if id == "" { + id = fmt.Sprintf("ds_%016x", len(f.items)+1) + if f.mode == "console" { + id = "console" + } + f.keys[request.Key] = id + f.items[id] = SavedDesktop{ID: id, Name: request.Name, Resolution: request.Resolution, State: "running", Managed: true, Available: true, Mode: f.mode} + } + if f.loseFirstResponse && f.posts == 1 { + http.Error(w, "accepted but response interrupted", 503) + return + } + write(DesktopResult{true, f.items[id]}) + return + } + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/desktop/sessions/"), "/") + item, ok := f.items[parts[0]] + if !ok { + http.NotFound(w, r) + return + } + if r.Method == "DELETE" { + if item.ID != "console" && item.State != "stopped" { + http.Error(w, "stop the desktop before deleting its saved profile", http.StatusConflict) + return + } + f.deletes++ + delete(f.items, item.ID) + write(map[string]bool{"success": true}) + return + } + if r.Method == "PATCH" { + var update UpdateDesktopRequest + if json.NewDecoder(r.Body).Decode(&update) != nil { + http.Error(w, "invalid update", http.StatusBadRequest) + return + } + if update.Resolution != nil && item.State != "stopped" { + w.WriteHeader(http.StatusConflict) + write(map[string]any{"success": false, "error": "Stop the desktop before changing its resolution"}) + return + } + if update.Name != nil { + item.Name = *update.Name + } + if update.Resolution != nil { + item.Resolution = update.Resolution + } + f.items[item.ID] = item + } + if len(parts) == 2 && r.Method == "POST" { + if parts[1] == "start" { + f.starts++ + item.State = "running" + item.Available = true + } + if parts[1] == "stop" { + f.stops++ + item.State = "stopped" + item.Available = false + } + f.items[item.ID] = item + } + write(DesktopResult{true, item}) +} +func (f *catalogFixture) configure(t *testing.T, s *Service, creds Credentials) { + t.Helper() + if err := s.BindRuntime(RuntimeBinding{RegistryID: s.db.Identity(), WorkspaceID: "163119cc95a4dddc", GatewayToken: "runtime-test", ExpiresAt: time.Now().Add(time.Hour)}, creds); err != nil { + t.Fatal(err) + } + s.catalog.mu.Lock() + s.catalog.backend.base = f.server.URL + s.catalog.providerFactory = func(id string) (Provider, error) { + f.mu.Lock() + defer f.mu.Unlock() + p := &testProvider{size: Geometry{7, 5, 1}} + f.providers[id] = p + return p, nil + } + s.catalog.mu.Unlock() +} +func catalogService(t *testing.T) (*Service, *catalogFixture, Credentials) { + t.Helper() + s, _, db := testService(t) + if err := db.Import(registry.Import{Projects: []registry.Project{ + {Record: registry.Record{ID: "project-a"}, Name: "App", Path: t.TempDir()}, + {Record: registry.Record{ID: "project-b"}, Name: "Other", Path: t.TempDir()}, + }}); err != nil { + t.Fatal(err) + } + creds, err := session.Open(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + f := newCatalogFixture(t) + f.configure(t, s, creds) + return s, f, creds +} + +func TestDesktopResolutionRequiresExplicitStop(t *testing.T) { + s, f, _ := catalogService(t) + desktop, err := s.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Fatal(err) + } + name, size := "Renamed desktop", &DesktopResolution{1024, 640} + _, err = s.DesktopOperation(t.Context(), desktop.ID, "update", &UpdateDesktopRequest{Name: &name, Resolution: size}) + code(t, err, "desktop_conflict") + if err.Error() != "Stop this desktop before changing its resolution." { + t.Fatalf("lost actionable provider precondition: %v", err) + } + if f.stops != 0 || f.items[desktop.ID].Name != desktop.Name { + t.Fatal("a rejected resize stopped apps or partially renamed the desktop") + } + renamed, err := s.DesktopOperation(t.Context(), desktop.ID, "update", &UpdateDesktopRequest{Name: &name}) + if err != nil || renamed.Session.Name != name || f.stops != 0 { + t.Fatalf("rename should work while running: %+v %v", renamed, err) + } + if _, err = s.DesktopOperation(t.Context(), desktop.ID, "stop", nil); err != nil { + t.Fatal(err) + } + resized, err := s.DesktopOperation(t.Context(), desktop.ID, "update", &UpdateDesktopRequest{Resolution: size}) + if err != nil || resized.Session.Resolution == nil || *resized.Session.Resolution != *size || resized.Session.State != "stopped" { + t.Fatalf("stopped resolution update failed: %+v %v", resized, err) + } +} + +func TestSlowDesktopStopDoesNotBlockAnotherDisplaysInputOrLeaseReaper(t *testing.T) { + s, f, _ := catalogService(t) + a, err := s.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Fatal(err) + } + b, err := s.EnsureProjectDesktop(t.Context(), "project-b") + if err != nil { + t.Fatal(err) + } + user := Actor{Kind: "user"} + viewer, err := s.Open(t.Context(), user, OpenRequest{RequestID: "view-b", Mode: "control", DesktopID: b.ID}) + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + f.mu.Lock() + f.stopEntered, f.stopRelease = entered, release + f.mu.Unlock() + var unblock sync.Once + defer unblock.Do(func() { close(release) }) + stopped := make(chan error, 1) + go func() { _, err := s.DesktopOperation(t.Context(), a.ID, "stop", nil); stopped <- err }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("stop never reached the runtime") + } + input := make(chan error, 1) + go func() { + selected, err := s.ForDesktop(b.ID) + if err == nil { + _, err = selected.Apply(t.Context(), user, ActionRequest{RequestID: "input-on-b", SessionID: viewer.ID, + ControlGeneration: viewer.Controller.Generation, Action: Action{Kind: "key", Keys: []string{"Escape"}}}) + } + if err == nil && s.ActiveSessionCount() != 1 { + err = fmt.Errorf("another desktop lost its viewer") + } + input <- err + }() + select { + case err = <-input: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("a slow management request stalled a different display") + } + unblock.Do(func() { close(release) }) + if err := <-stopped; err != nil { + t.Fatal(err) + } +} + +func TestProjectDesktopConcurrentEnsureAndRestart(t *testing.T) { + s, f, creds := catalogService(t) + var wg sync.WaitGroup + ids := make(chan string, 12) + for range 12 { + wg.Add(1) + go func() { + defer wg.Done() + desktop, err := s.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Error(err) + return + } + ids <- desktop.ID + }() + } + wg.Wait() + close(ids) + for id := range ids { + if id != "ds_0000000000000001" { + t.Fatalf("duplicate desktop %s", id) + } + } + if f.posts != 1 { + t.Fatalf("created %d times", f.posts) + } + s.Close() + restored, err := NewConfigured(s.db, creds) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + f.configure(t, restored, creds) + list, err := restored.ListDesktops(t.Context(), "project-a") + if err != nil || len(list.Sessions) != 1 { + t.Fatalf("lost saved association: %+v %v", list, err) + } + same, err := restored.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil || same.ID != list.Sessions[0].ID || f.posts != 1 { + t.Fatalf("restart created another desktop: %+v %v", same, err) + } +} + +func TestDeleteRunningProjectDesktopReleasesOnlyItsControllerAndCanRetry(t *testing.T) { + s, fixture, _ := catalogService(t) + a, err := s.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Fatal(err) + } + b, err := s.EnsureProjectDesktop(t.Context(), "project-b") + if err != nil { + t.Fatal(err) + } + user := Actor{Kind: "user"} + for _, desktop := range []SavedDesktop{a, b} { + if _, err := s.Open(t.Context(), user, OpenRequest{RequestID: newID(), DesktopID: desktop.ID, Mode: "control"}); err != nil { + t.Fatal(err) + } + } + for range 2 { + result, err := s.DesktopOperation(t.Context(), a.ID, "delete", nil) + if err != nil || !result.Success { + t.Fatalf("idempotent delete: %+v %v", result, err) + } + } + if fixture.stops != 1 || fixture.deletes != 1 { + t.Fatalf("duplicate lifecycle mutations: stops=%d deletes=%d", fixture.stops, fixture.deletes) + } + if s.ActiveSessionCount() != 1 { + t.Fatal("delete retained its viewer or closed another desktop") + } + other, err := s.ForDesktop(b.ID) + if err != nil || other.Snapshot().Controller == nil { + t.Fatal("deleting one desktop interrupted another controller") + } +} + +func TestProjectDesktopUncertainCreateUsesDurableIdenticalRequest(t *testing.T) { + s, f, creds := catalogService(t) + f.loseFirstResponse = true + if _, err := s.EnsureProjectDesktop(t.Context(), "project-a"); err == nil { + t.Fatal("expected lost response") + } + s.Close() + restored, err := NewConfigured(s.db, creds) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + f.configure(t, restored, creds) + f.defaultWidth = 1440 // changed provider defaults must not alter the accepted request + desktop, err := restored.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Fatal(err) + } + if len(f.items) != 1 || f.posts != 2 || desktop.ID != "ds_0000000000000001" { + t.Fatalf("uncertain retry duplicated desktop: %+v", desktop) + } + if !bytes.Equal(f.bodies[0], f.bodies[1]) { + t.Fatal("retry changed its body or idempotency key") + } +} + +func TestSavedDesktopsKeepControlCaptureAndProjectScopesSeparate(t *testing.T) { + s, f, _ := catalogService(t) + a, err := s.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Fatal(err) + } + b, err := s.EnsureProjectDesktop(t.Context(), "project-b") + if err != nil { + t.Fatal(err) + } + list, err := s.ListDesktops(t.Context(), "project-a") + if err != nil || len(list.Sessions) != 1 || list.Sessions[0].ID != a.ID { + t.Fatalf("wrong project list: %+v %v", list, err) + } + user := Actor{Kind: "user"} + x, err := s.Open(t.Context(), user, OpenRequest{RequestID: "open-a", Mode: "control", DesktopID: a.ID}) + if err != nil { + t.Fatal(err) + } + y, err := s.Open(t.Context(), user, OpenRequest{RequestID: "open-b", Mode: "control", DesktopID: b.ID}) + if err != nil { + t.Fatal(err) + } + if x.DesktopID != a.ID || y.DesktopID != b.ID || s.ActiveSessionCount() != 2 { + t.Fatal("controllers were not scoped to saved displays") + } + frame, err := s.Capture(t.Context(), x.ID, user) + if err != nil { + t.Fatal(err) + } + _, err = s.Apply(t.Context(), Actor{Kind: "agent", RunID: "unrelated"}, pointerRequest(y, frame)) + code(t, err, "forbidden") + // A human can control both displays, but a frame from A must never target B. + agent := Actor{Kind: "agent", RunID: "b", desktopApproved: true} + _ = s.CloseSession(y.ID, user) + y, err = s.Open(t.Context(), agent, OpenRequest{RequestID: "agent-b", Mode: "control", DesktopID: b.ID}) + if err != nil { + t.Fatal(err) + } + _, err = s.Apply(t.Context(), agent, pointerRequest(y, frame)) + code(t, err, "stale_frame") + child, _ := s.ForDesktop(a.ID) + if child.Snapshot().Controller.SessionID != x.ID { + t.Fatal("opening B stole control of A") + } + if err := s.CloseSession(x.ID, user); err != nil { + t.Fatal(err) + } + if f.stops != 0 || f.deletes != 0 { + t.Fatal("viewer close stopped or deleted the saved desktop") + } +} + +func readToolPayload(t *testing.T, result *mcp.CallToolResult, err error) toolPayload { + t.Helper() + if err != nil || result == nil { + t.Fatalf("tool transport: %v", err) + } + var value struct { + Surface toolPayload `json:"mindwireSurface"` + } + if err := json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &value); err != nil { + t.Fatal(err) + } + return value.Surface +} +func TestProjectDesktopToolsApprovalSameViewerAndRunIsolation(t *testing.T) { + for _, harness := range []string{"codex", "claude-code"} { + t.Run(harness, func(t *testing.T) { + s, f, _ := catalogService(t) + var approvals atomic.Int32 + s.SetApproval(func(_ context.Context, actor Actor, _ string) error { + if f.posts != 0 && approvals.Load() == 0 { + t.Error("desktop created before approval") + } + if actor.ProjectID != "project-a" { + t.Error("lost project context") + } + approvals.Add(1) + return nil + }) + actor := Actor{Kind: "agent", Name: "Agent", RunID: "run-a", ProjectID: "project-a", ChatID: "chat-a"} + client, finish := mcpClient(t, s, t.Context(), actor, harness) + defer finish() + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{Name: "surface_project_desktop", Arguments: map[string]any{"requestId": "ui-work", "mode": "control"}}) + opened := readToolPayload(t, result, err) + if result.IsError || opened.Session == nil || opened.DesktopID == "" { + t.Fatalf("open failed: %+v", opened) + } + result, err = client.CallTool(t.Context(), &mcp.CallToolParams{Name: "surface_project_desktop", Arguments: map[string]any{"requestId": "ui-work", "mode": "control"}}) + retry := readToolPayload(t, result, err) + if retry.Session.ID != opened.Session.ID || approvals.Load() != 1 || f.posts != 1 { + t.Fatal("duplicate approval, session, or desktop") + } + viewer, err := s.Open(t.Context(), Actor{Kind: "user"}, OpenRequest{RequestID: "phone", Mode: "view", DesktopID: opened.DesktopID}) + if err != nil { + t.Fatal(err) + } + if viewer.DesktopID != opened.Session.DesktopID { + t.Fatal("phone is watching a different desktop") + } + result, err = client.CallTool(t.Context(), &mcp.CallToolParams{Name: "surface_capture", Arguments: map[string]any{"sessionId": opened.Session.ID}}) + capture := readToolPayload(t, result, err) + if result.IsError || capture.Capture == nil || len(result.Content) != 2 { + t.Fatal("agent did not receive the actual image") + } + other, err := s.EnsureProjectDesktop(t.Context(), "project-b") + if err != nil { + t.Fatal(err) + } + result, err = client.CallTool(t.Context(), &mcp.CallToolParams{Name: "surface_project_desktop", Arguments: map[string]any{"requestId": "wrong-project", "mode": "control", "desktopId": other.ID}}) + denied := readToolPayload(t, result, err) + if denied.Error == nil || denied.Error.Code != "forbidden" { + t.Fatal("agent accessed another project's desktop") + } + finish() + if s.ActiveSessionCount() != 1 { + t.Fatal("run cleanup should keep the phone viewer only") + } + if f.stops != 0 || f.deletes != 0 { + t.Fatal("run cleanup destroyed the desktop") + } + }) + } +} + +func TestProjectDesktopDenialDoesNotCreateOrEnable(t *testing.T) { + s, f, _ := catalogService(t) + s.SetApproval(func(context.Context, Actor, string) error { return problem("denied", "Declined") }) + result, _, err := s.openForRun(t.Context(), Actor{Kind: "agent", ProjectID: "project-a"}, OpenRequest{RequestID: "request", Mode: "control"}) + if err != nil || !result.IsError || f.posts != 0 || f.enables != 0 { + t.Fatal("denial enabled or created a desktop") + } +} + +func TestConcurrentProjectDesktopOpenCannotDuplicateApproval(t *testing.T) { + s, f, _ := catalogService(t) + entered, allow := make(chan struct{}), make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(allow) }) + var approvals atomic.Int32 + s.SetApproval(func(ctx context.Context, _ Actor, _ string) error { + if approvals.Add(1) == 1 { + close(entered) + } + select { + case <-allow: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + actor := Actor{Kind: "agent", Name: "Agent", RunID: "run-a", ProjectID: "project-a"} + request := OpenRequest{RequestID: "ui-work", Mode: "control"} + type openedResult struct { + result *mcp.CallToolResult + err error + } + first := make(chan openedResult, 1) + go func() { + result, _, err := s.openForRun(t.Context(), actor, request) + first <- openedResult{result, err} + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("approval never started") + } + result, _, err := s.openForRun(t.Context(), actor, request) + pending := readToolPayload(t, result, err) + if pending.Error == nil || pending.Error.Code != "approval_pending" || approvals.Load() != 1 || f.posts != 0 { + t.Fatalf("duplicate request bypassed or repeated approval: %+v", pending) + } + once.Do(func() { close(allow) }) + var completed openedResult + select { + case completed = <-first: + case <-time.After(5 * time.Second): + t.Fatal("approved desktop did not open") + } + opened := readToolPayload(t, completed.result, completed.err) + if opened.Session == nil { + t.Fatalf("first open failed: %+v", opened) + } + result, _, err = s.openForRun(t.Context(), actor, request) + retry := readToolPayload(t, result, err) + if retry.Session == nil || retry.Session.ID != opened.Session.ID || approvals.Load() != 1 || f.posts != 1 { + t.Fatalf("completed retry repeated approval or creation: %+v", retry) + } +} + +func TestSavedConsoleCannotCompeteWithLegacyController(t *testing.T) { + s, _, _ := testService(t) + legacy := mustOpen(t, s, Actor{Kind: "user"}, "control") + creds, err := session.Open(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + f := newCatalogFixture(t) + f.configure(t, s, creds) + _, err = s.ForDesktop("console") + code(t, err, "desktop_conflict") + if err := s.CloseSession(legacy.ID, Actor{Kind: "user"}); err != nil { + t.Fatal(err) + } + _, err = s.Open(t.Context(), Actor{Kind: "user"}, OpenRequest{RequestID: "old-client", Mode: "control"}) + code(t, err, "desktop_selection_required") + if _, err = s.ForDesktop("console"); err != nil { + t.Fatal(err) + } +} + +func TestProjectDesktopLifecycleAndSharedConsole(t *testing.T) { + s, f, _ := catalogService(t) + f.mode = "console" + a, err := s.EnsureProjectDesktop(t.Context(), "project-a") + if err != nil { + t.Fatal(err) + } + b, err := s.EnsureProjectDesktop(t.Context(), "project-b") + if err != nil { + t.Fatal(err) + } + if a.ID != "console" || b.ID != a.ID || f.posts != 1 { + t.Fatal("console was duplicated") + } + var body map[string]any + _ = json.Unmarshal(f.bodies[0], &body) + if body["resolution"] != nil { + t.Fatal("console creation sent a virtual resolution") + } + if _, err = s.DesktopOperation(t.Context(), a.ID, "stop", nil); err != nil { + t.Fatal(err) + } + if _, err = s.EnsureProjectDesktop(t.Context(), "project-a"); err != nil || f.starts != 1 { + t.Fatalf("stopped desktop was not reused: %v", err) + } + if _, err = s.DesktopOperation(t.Context(), a.ID, "delete", nil); err != nil { + t.Fatal(err) + } + if _, err = s.EnsureProjectDesktop(t.Context(), "project-a"); err != nil || f.posts != 2 { + t.Fatalf("removed desktop could not be replaced: %v", err) + } +} + +func TestCommandDesktopHelperUsesSameToolsAndRevokesRunAccess(t *testing.T) { + s, f, _ := catalogService(t) + s.SetApproval(func(context.Context, Actor, string) error { return nil }) + overlay, env, finish, err := s.BindRun(t.Context(), Actor{Kind: "agent", RunID: "fallback", ProjectID: "project-a"}, "opencode", json.RawMessage(`{"existing":true}`)) + if err != nil { + t.Fatal(err) + } + defer finish() + if string(overlay) != `{"existing":true}` { + t.Fatal("fallback changed harness configuration") + } + for key, value := range env { + t.Setenv(key, value) + } + var output bytes.Buffer + if err := DesktopToolHelper(t.Context(), []string{"surface_project_desktop", `{"requestId":"helper-open","mode":"control"}`}, &output); err != nil { + t.Fatal(err) + } + var result struct { + Surface toolPayload `json:"mindwireSurface"` + ImagePath string `json:"imagePath"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil || result.Surface.Session == nil { + t.Fatalf("helper output: %s %v", output.String(), err) + } + output.Reset() + if err := DesktopToolHelper(t.Context(), []string{"surface_capture", fmt.Sprintf(`{"sessionId":%q}`, result.Surface.Session.ID)}, &output); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(result.ImagePath); err != nil || info.Mode().Perm() != 0600 { + t.Fatal("screenshot not private") + } + finish() + if _, err := os.Stat(env["MINDWIRE_DESKTOP_IMAGES"]); !os.IsNotExist(err) { + t.Fatal("run screenshot directory retained") + } + if err := DesktopToolHelper(t.Context(), []string{"list"}, io.Discard); err == nil { + t.Fatal("ended run token still accepted") + } + if s.ActiveSessionCount() != 0 || f.stops != 0 { + t.Fatal("helper cleanup leaked control or stopped the display") + } +} diff --git a/daemon/internal/surface/claude_local_test.go b/daemon/internal/surface/claude_local_test.go new file mode 100644 index 0000000..be62dab --- /dev/null +++ b/daemon/internal/surface/claude_local_test.go @@ -0,0 +1,140 @@ +package surface + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// Only the Messages API is scripted. The installed Claude CLI, MCP discovery, +// permissions, image delivery, normalization and desktop service remain real. +func localClaudeDesktopModel(t *testing.T, s *Service, env map[string]string) { + t.Helper() + var step atomic.Int32 + var imageReceived atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/count_tokens") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"input_tokens":100}`) + return + } + if !strings.HasSuffix(r.URL.Path, "/messages") || r.Method != "POST" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[],"has_more":false}`) + return + } + body, _ := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + var request struct { + Stream bool `json:"stream"` + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + if json.Unmarshal(body, &request) != nil { + http.Error(w, "invalid Messages request", 400) + return + } + if strings.Contains(string(body), `"image/png"`) && strings.Contains(string(body), `"base64"`) { + imageReceived.Store(true) + } + operations := []string{"desktops", "project_desktop", "capture", "action", "release"} + index := int(step.Load()) + var block map[string]any + reason := "end_turn" + if index >= len(operations) { + block = map[string]any{"type": "text", "text": "Desktop protocol complete."} + } else { + operation, name := operations[index], "" + for _, tool := range request.Tools { + if strings.HasSuffix(tool.Name, "__surface_"+operation) && strings.Contains(tool.Name, MCPName) { + name = tool.Name + break + } + } + if name == "" { + t.Errorf("Claude did not discover surface_%s", operation) + block = map[string]any{"type": "text", "text": "Missing desktop tool."} + } else { + args := map[string]any{} + switch operation { + case "project_desktop": + args = map[string]any{"requestId": "cli-open", "mode": "control"} + case "capture", "action", "release": + selected, err := s.ForDesktop("ds_0000000000000001") + if err != nil { + t.Error(err) + http.Error(w, "desktop unavailable", 500) + return + } + control := selected.Snapshot().Controller + if control == nil { + t.Error("Claude did not acquire the shared controller") + http.Error(w, "missing controller", 500) + return + } + args["sessionId"] = control.SessionID + if operation == "action" { + args["requestId"], args["controlGeneration"] = "cli-escape", control.Generation + args["action"] = map[string]any{"kind": "key", "keys": []string{"Escape"}} + } + } + step.Add(1) + block = map[string]any{"type": "tool_use", "id": fmt.Sprintf("toolu_desktop_%d", index), "name": name, "input": args} + reason = "tool_use" + } + } + message := map[string]any{"id": fmt.Sprintf("msg_desktop_%d", index), "type": "message", "role": "assistant", "model": "claude-haiku-4-5", "content": []any{block}, "stop_reason": reason, "stop_sequence": nil, "usage": map[string]int{"input_tokens": 100, "output_tokens": 20}} + if !request.Stream { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(message) + return + } + w.Header().Set("Content-Type", "text/event-stream") + start := map[string]any{} + for k, v := range message { + start[k] = v + } + start["content"], start["stop_reason"] = []any{}, nil + write := func(kind string, data map[string]any) { + data["type"] = kind + encoded, _ := json.Marshal(data) + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, encoded) + } + write("message_start", map[string]any{"message": start}) + if reason == "tool_use" { + write("content_block_start", map[string]any{"index": 0, "content_block": map[string]any{"type": "tool_use", "id": block["id"], "name": block["name"], "input": map[string]any{}}}) + args, _ := json.Marshal(block["input"]) + write("content_block_delta", map[string]any{"index": 0, "delta": map[string]any{"type": "input_json_delta", "partial_json": string(args)}}) + } else { + write("content_block_start", map[string]any{"index": 0, "content_block": map[string]any{"type": "text", "text": ""}}) + write("content_block_delta", map[string]any{"index": 0, "delta": map[string]any{"type": "text_delta", "text": block["text"]}}) + } + write("content_block_stop", map[string]any{"index": 0}) + write("message_delta", map[string]any{"delta": map[string]any{"stop_reason": reason, "stop_sequence": nil}, "usage": map[string]int{"output_tokens": 20}}) + write("message_stop", map[string]any{}) + })) + t.Cleanup(server.Close) + env["CLAUDE_CONFIG_DIR"] = t.TempDir() + env["ANTHROPIC_BASE_URL"], env["ANTHROPIC_API_KEY"] = server.URL, "fixture-api-key" + for _, key := range []string{"ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS"} { + env[key] = "" + } + for _, key := range []string{"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"} { + env[key] = "0" + } + env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + env["ENABLE_TOOL_SEARCH"] = "false" + t.Cleanup(func() { + if step.Load() != 5 { + t.Errorf("native Claude completed %d/5 desktop operations", step.Load()) + } + if !imageReceived.Load() { + t.Error("MCP screenshot did not reach Claude as image input") + } + }) +} diff --git a/daemon/internal/surface/desktop_socket.go b/daemon/internal/surface/desktop_socket.go index e7b89d7..7d14153 100644 --- a/daemon/internal/surface/desktop_socket.go +++ b/daemon/internal/surface/desktop_socket.go @@ -15,10 +15,11 @@ import ( // server inside macOS. HTTPS/WSS also works where outbound SSH is unavailable. // The iOS viewer independently uses the provider's native, pinned SSH tunnel. func (p *Oblien) desktopSocket(ctx context.Context) (net.Conn, error) { - if p.binding.GatewayToken == "" { + binding := p.bindingSnapshot() + if binding.GatewayToken == "" { return nil, problem("needs_authorization", "Refresh the workspace desktop authorization.") } - endpoint, err := url.Parse(p.base + "/desktop/ws") + endpoint, err := url.Parse(p.base + p.desktopPath() + "/ws") if err != nil { return nil, err } @@ -30,7 +31,7 @@ func (p *Oblien) desktopSocket(ctx context.Context) (net.Conn, error) { dialCtx, cancel := context.WithTimeout(ctx, 12*time.Second) defer cancel() conn, response, err := websocket.Dial(dialCtx, endpoint.String(), &websocket.DialOptions{ - HTTPHeader: http.Header{"Authorization": {"Bearer " + p.binding.GatewayToken}}, + HTTPHeader: http.Header{"Authorization": {"Bearer " + binding.GatewayToken}}, Subprotocols: []string{"binary"}, CompressionMode: websocket.CompressionDisabled, }) if err != nil { diff --git a/daemon/internal/surface/harness_live_test.go b/daemon/internal/surface/harness_live_test.go index ca17336..7fb4462 100644 --- a/daemon/internal/surface/harness_live_test.go +++ b/daemon/internal/surface/harness_live_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "strings" + "sync/atomic" "testing" "time" @@ -15,6 +16,15 @@ import ( // Opt-in native CLI check against a synthetic desktop. Uses the developer's // existing CLI login without reading or exporting its credentials. func TestHarnessDesktopMCP(t *testing.T) { + testHarnessDesktop(t, false) +} + +func TestHarnessProjectDesktopMCP(t *testing.T) { + testHarnessDesktop(t, true) +} + +func testHarnessDesktop(t *testing.T, project bool) { + t.Helper() harness := os.Getenv("MINDWIRE_DESKTOP_HARNESS") if harness == "" { t.Skip("opt in with an authenticated codex or claude-code CLI") @@ -29,17 +39,29 @@ func TestHarnessDesktopMCP(t *testing.T) { if adapter == nil { t.Fatal("unknown test harness") } - s, provider, _ := testService(t) - provider.size = Geometry{128, 96, 1} - approvals := 0 - s.SetApproval(func(context.Context, Actor, string) error { approvals++; return nil }) + var s *Service + var fixture *catalogFixture + actor := Actor{Kind: "agent", Name: harness, RunID: "native-cli", ChatID: "native-cli"} + if project { + s, fixture, _ = catalogService(t) + actor.ProjectID = "project-a" + } else { + var provider *testProvider + s, provider, _ = testService(t) + provider.size = Geometry{128, 96, 1} + } + var approvals atomic.Int32 + s.SetApproval(func(context.Context, Actor, string) error { approvals.Add(1); return nil }) ctx, cancel := context.WithTimeout(t.Context(), 110*time.Second) defer cancel() - overlay, env, cleanup, err := s.BindRun(ctx, Actor{Kind: "agent", Name: harness, RunID: "native-cli", ChatID: "native-cli"}, harness, nil) + overlay, env, cleanup, err := s.BindRun(ctx, actor, harness, nil) if err != nil { t.Fatal(err) } defer cleanup() + if harness == "claude-code" && project && os.Getenv("MINDWIRE_DESKTOP_MODEL_FIXTURE") == "1" { + localClaudeDesktopModel(t, s, env) + } config := map[string]string{"model": "haiku", "permission-mode": "dontAsk", "max-turns": "8", "disallowed-tools": "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,Agent,Skill"} if harness == "codex" { @@ -51,6 +73,9 @@ func TestHarnessDesktopMCP(t *testing.T) { } observed := map[string]bool{} prompt := "Verify the synthetic mindwire_desktop MCP service. Use only its tools. First surface_status; then surface_open in control mode with requestId cli-open; then surface_capture. Use that session ID, controlGeneration and frameId to surface_action with requestId cli-escape, action kind key and keys [Escape]. Finally surface_release. Do not use any other tool, inspect files, or run commands. Briefly describe the image and report completion. These are synthetic test pixels, not a real desktop." + if project { + prompt = strings.ReplaceAll(prompt, "First surface_status; then surface_open", "First surface_desktops; then surface_project_desktop") + } result, err := adapter.RunStream(ctx, agent.TurnInput{Message: prompt, CWD: t.TempDir(), Config: config, Env: env, Options: agent.TurnOptions{MCPServers: overlay, ClaudeSettings: settings}}, func(ev agent.Event) { if ev.Type == agent.EventResult || ev.Type == agent.EventError { t.Logf("NATIVE_DESKTOP terminal=%s", ev.Type) @@ -61,6 +86,9 @@ func TestHarnessDesktopMCP(t *testing.T) { agent.NormalizeSurfaceTool(ev.Tool) if ev.Type == agent.EventToolResult && ev.Tool.Action != nil && ev.Tool.Action.Surface != nil { action := ev.Tool.Action.Surface + if project && action.Operation != "list" && action.DesktopID != "ds_0000000000000001" { + t.Errorf("native desktop card lost its saved desktop ID: %s", action.Operation) + } if action.Operation == "capture" && action.Capture == nil { t.Error("native CLI result lost its screenshot artifact") } @@ -74,12 +102,23 @@ func TestHarnessDesktopMCP(t *testing.T) { if result.IsError { t.Fatalf("native CLI ended with an error: %s", strings.TrimSpace(result.Text)) } - for _, operation := range []string{"status", "open", "capture", "key", "release"} { + operations := []string{"status", "open", "capture", "key", "release"} + if project { + operations[0] = "list" + } + for _, operation := range operations { if !observed[operation] { t.Errorf("native CLI did not complete %s", operation) } } - if approvals != 1 { - t.Errorf("want one desktop permission, got %d", approvals) + if approvals.Load() != 1 { + t.Errorf("want one desktop permission, got %d", approvals.Load()) + } + if fixture != nil { + fixture.mu.Lock() + defer fixture.mu.Unlock() + if fixture.posts != 1 || len(fixture.providers) != 1 { + t.Errorf("native project desktop was duplicated: creates=%d providers=%d", fixture.posts, len(fixture.providers)) + } } } diff --git a/daemon/internal/surface/helper.go b/daemon/internal/surface/helper.go new file mode 100644 index 0000000..626d273 --- /dev/null +++ b/daemon/internal/surface/helper.go @@ -0,0 +1,99 @@ +package surface + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type helperAuthorization struct{ token string } + +func (a helperAuthorization) RoundTrip(req *http.Request) (*http.Response, error) { + copy := req.Clone(req.Context()) + copy.Header.Set("Authorization", "Bearer "+a.token) + return http.DefaultTransport.RoundTrip(copy) +} + +// DesktopToolHelper bridges command-only harnesses to the same run-scoped MCP +// tools. Provider tokens and the daemon owner credential never enter this path. +func DesktopToolHelper(ctx context.Context, args []string, out io.Writer) error { + endpoint, err := url.Parse(os.Getenv("MINDWIRE_DESKTOP_URL")) + token := os.Getenv(runTokenEnv) + if err != nil || endpoint.Scheme != "http" || endpoint.Hostname() != "127.0.0.1" || endpoint.Path != "/mcp" || endpoint.User != nil || endpoint.RawQuery != "" || token == "" { + return errors.New("desktop tools are available only inside an active Mindwire project run") + } + if len(args) < 1 || len(args) > 2 { + return errors.New("usage: --desktop-tool list | TOOL_NAME 'JSON_ARGUMENTS'") + } + client := mcp.NewClient(&mcp.Implementation{Name: "mindwire-desktop-helper", Version: "2"}, nil) + connection, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: endpoint.String(), + HTTPClient: &http.Client{Transport: helperAuthorization{token}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, + DisableStandaloneSSE: true, MaxRetries: -1}, nil) + if err != nil { + return errors.New("could not reach this run's desktop tools; the run may have ended") + } + defer connection.Close() + if args[0] == "list" { + result, err := connection.ListTools(ctx, nil) + if err != nil { + return err + } + return json.NewEncoder(out).Encode(result) + } + if !strings.HasPrefix(args[0], "surface_") { + return errors.New("choose a desktop tool from --desktop-tool list") + } + arguments := map[string]any{} + if len(args) == 2 { + if len(args[1]) > 2<<20 { + return errors.New("desktop arguments are too large") + } + if err := json.Unmarshal([]byte(args[1]), &arguments); err != nil { + return errors.New("desktop arguments must be a JSON object") + } + } + result, err := connection.CallTool(ctx, &mcp.CallToolParams{Name: args[0], Arguments: arguments}) + if err != nil { + return err + } + response := map[string]any{} + for _, block := range result.Content { + switch block := block.(type) { + case *mcp.TextContent: + var payload map[string]any + if json.Unmarshal([]byte(block.Text), &payload) == nil { + for key, value := range payload { + response[key] = value + } + } + case *mcp.ImageContent: + directory := os.Getenv("MINDWIRE_DESKTOP_IMAGES") + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0077 != 0 || block.MIMEType != "image/png" || len(block.Data) > 16<<20 { + return errors.New("this run's private screenshot directory is unavailable") + } + path := filepath.Join(directory, newID()+".png") + if err := os.WriteFile(path, block.Data, 0600); err != nil { + return err + } + response["imagePath"] = path + } + } + response["isError"] = result.IsError + if err := json.NewEncoder(out).Encode(response); err != nil { + return err + } + if result.IsError { + return fmt.Errorf("desktop tool %s did not complete; see its structured error", args[0]) + } + return nil +} diff --git a/daemon/internal/surface/mcp.go b/daemon/internal/surface/mcp.go index 1a55556..b38ecdd 100644 --- a/daemon/internal/surface/mcp.go +++ b/daemon/internal/surface/mcp.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net" "net/http" + "os" "strings" "sync" "time" @@ -29,9 +30,13 @@ func (p *runIPC) close() { _ = p.server.Close() } // BindRun returns a per-turn, harness-native MCP overlay and private environment. // Existing MCP servers are preserved; the built-in name cannot be shadowed. func (s *Service) BindRun(ctx context.Context, actor Actor, harness string, existing json.RawMessage) (json.RawMessage, map[string]string, func(), error) { - if harness != "codex" && harness != "claude-code" { + native := harness == "codex" || harness == "claude-code" + if !native && !s.HasDesktopCatalog() { return existing, nil, func() {}, nil } + if project, err := s.projectForActor(actor); err == nil && project != nil { + actor.ProjectID = project.ID + } s.mu.Lock() if s.ipc == nil { listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -71,10 +76,39 @@ func (s *Service) BindRun(ctx context.Context, actor Actor, harness string, exis p.mu.Lock() p.runs[token] = handler p.mu.Unlock() + imageDir := "" + if !native { + var err error + imageDir, err = os.MkdirTemp("", "mindwire-desktop-") + if err != nil { + p.mu.Lock() + delete(p.runs, token) + p.mu.Unlock() + return nil, nil, nil, err + } + } var once sync.Once - cleanup := func() { once.Do(func() { p.mu.Lock(); delete(p.runs, token); p.mu.Unlock(); s.CloseRun(actor.RunID) }) } + cleanup := func() { + once.Do(func() { + p.mu.Lock() + delete(p.runs, token) + p.mu.Unlock() + s.CloseRun(actor.RunID) + if imageDir != "" { + _ = os.RemoveAll(imageDir) + } + }) + } stop := context.AfterFunc(ctx, cleanup) finish := func() { stop(); cleanup() } + if !native { + helper, err := os.Executable() + if err != nil { + finish() + return nil, nil, nil, err + } + return existing, map[string]string{runTokenEnv: token, "MINDWIRE_DESKTOP_URL": p.url, "MINDWIRE_DESKTOP_HELPER": helper, "MINDWIRE_DESKTOP_IMAGES": imageDir}, finish, nil + } servers := map[string]json.RawMessage{} if len(existing) > 0 { if err := json.Unmarshal(existing, &servers); err != nil { @@ -136,7 +170,7 @@ func PermissionSettings(harness string, existing json.RawMessage) (json.RawMessa return nil, err } } - for _, tool := range []string{"surface_status", "surface_open", "surface_capture", "surface_action", "surface_release"} { + for _, tool := range []string{"surface_status", "surface_desktops", "surface_project_desktop", "surface_open", "surface_capture", "surface_action", "surface_release"} { rule := "mcp__" + MCPName + "__" + tool found := false for _, existing := range allow { @@ -155,13 +189,16 @@ type sessionInput struct { SessionID string `json:"sessionId"` } type toolPayload struct { - SurfaceID string `json:"surfaceId"` - Operation string `json:"operation"` - Session *Session `json:"session,omitempty"` - Snapshot *Snapshot `json:"snapshot,omitempty"` - Capture *Capture `json:"capture,omitempty"` - Receipt *Receipt `json:"receipt,omitempty"` - Error *Error `json:"error,omitempty"` + SurfaceID string `json:"surfaceId"` + DesktopID string `json:"desktopId,omitempty"` + Desktop *SavedDesktop `json:"desktop,omitempty"` + Catalog *DesktopCatalog `json:"catalog,omitempty"` + Operation string `json:"operation"` + Session *Session `json:"session,omitempty"` + Snapshot *Snapshot `json:"snapshot,omitempty"` + Capture *Capture `json:"capture,omitempty"` + Receipt *Receipt `json:"receipt,omitempty"` + Error *Error `json:"error,omitempty"` } func toolResult(payload toolPayload, image []byte, err error) (*mcp.CallToolResult, any, error) { @@ -181,7 +218,7 @@ func toolResult(payload toolPayload, image []byte, err error) (*mcp.CallToolResu } func (s *Service) mcpServer(run context.Context, actor Actor) *mcp.Server { - server := mcp.NewServer(&mcp.Implementation{Name: MCPName, Version: "1"}, nil) + server := mcp.NewServer(&mcp.Implementation{Name: MCPName, Version: "2"}, &mcp.ServerOptions{Instructions: s.desktopInstructions(actor)}) // Bind cancellation to both the RPC and the owning run. The HTTP session may // outlive a disconnected tool request, but it may never outlive this run. operation := func(ctx context.Context) (context.Context, func()) { @@ -195,19 +232,42 @@ func (s *Service) mcpServer(run context.Context, actor Actor) *mcp.Server { mcp.AddTool(server, &mcp.Tool{Name: "surface_status", Description: "Check the shared workspace desktop and its current controller. No desktop access is enabled automatically."}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { ctx, cancel := operation(ctx) defer cancel() + if s.HasDesktopCatalog() && actor.ProjectID != "" { + catalog, err := s.ListDesktops(ctx, actor.ProjectID) + return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "status", Catalog: &catalog}, nil, err) + } snapshot, err := s.Refresh(ctx) return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "status", Snapshot: &snapshot}, nil, err) }) - mcp.AddTool(server, &mcp.Tool{Name: "surface_open", Description: "Open the shared desktop in view or control mode. Requests user approval once for this session. A controller is exclusive within Mindwire. Use a stable requestId. Never bypass a busy or revoked controller."}, func(ctx context.Context, _ *mcp.CallToolRequest, in OpenRequest) (*mcp.CallToolResult, any, error) { + var openMu sync.Mutex + open := func(ctx context.Context, _ *mcp.CallToolRequest, in OpenRequest) (*mcp.CallToolResult, any, error) { ctx, cancel := operation(ctx) defer cancel() - session, err := s.Open(ctx, actor, in) - return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "open", Session: &session}, nil, err) - }) + openMu.Lock() + defer openMu.Unlock() + return s.openForRun(ctx, actor, in) + } + mcp.AddTool(server, &mcp.Tool{Name: "surface_open", Description: "Open this project's saved desktop in view or control mode, creating it if needed on an Oblien VM. Requests user approval once for this control session. Use a stable requestId. Never bypass a busy or revoked controller."}, open) + if s.HasDesktopCatalog() { + mcp.AddTool(server, &mcp.Tool{Name: "surface_project_desktop", Description: "Create or reconnect to this project's saved GUI desktop. Returns its saved desktopId and a control session. Closing releases control but keeps apps and the desktop profile. The user can watch this exact desktop from the project or tool result."}, open) + mcp.AddTool(server, &mcp.Tool{Name: "surface_desktops", Description: "List saved desktops linked to the current project, with resolution, state and provider session limits. Does not create or start a desktop."}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + ctx, cancel := operation(ctx) + defer cancel() + if actor.ProjectID == "" { + return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "list"}, nil, problem("project_required", "Open a project chat to use a project desktop.")) + } + catalog, err := s.ListDesktops(ctx, actor.ProjectID) + return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "list", Catalog: &catalog}, nil, err) + }) + } mcp.AddTool(server, &mcp.Tool{Name: "surface_capture", Description: "Observe the desktop as an image. Returns a frame ID, dimensions and a durable artifact. Capture again after input; pointer actions require a frame younger than 30 seconds."}, func(ctx context.Context, _ *mcp.CallToolRequest, in sessionInput) (*mcp.CallToolResult, any, error) { ctx, cancel := operation(ctx) defer cancel() - capture, err := s.Capture(ctx, in.SessionID, actor) + selected, err := s.serviceForSession(in.SessionID, actor) + if err != nil { + return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "capture"}, nil, err) + } + capture, err := selected.Capture(ctx, in.SessionID, actor) var data []byte if err == nil { content, e := s.artifacts.Get(capture.ArtifactID) @@ -216,19 +276,27 @@ func (s *Service) mcpServer(run context.Context, actor Actor) *mcp.Server { data = content.Data } } - return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "capture", Capture: &capture}, data, err) + return toolResult(toolPayload{SurfaceID: selected.surfaceID, DesktopID: selected.Snapshot().DesktopID, Operation: "capture", Capture: &capture}, data, err) }) mcp.AddTool(server, &mcp.Tool{Name: "surface_action", Description: "Send desktop input through the shared controller. Use the controlGeneration from surface_open, frameId from surface_capture, and a unique stable requestId. Coordinates are pixels in that image. Kinds: click, drag, scroll, key (e.g. [Meta,l]), text, clipboard_read/write, pointer, release. A dispatched receipt confirms delivery to VNC, not the application's result; capture to verify. Never repeat an outcome_unknown action blindly."}, func(ctx context.Context, _ *mcp.CallToolRequest, in ActionRequest) (*mcp.CallToolResult, any, error) { ctx, cancel := operation(ctx) defer cancel() - receipt, err := s.Apply(ctx, actor, in) + selected, err := s.serviceForSession(in.SessionID, actor) + if err != nil { + return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "action"}, nil, err) + } + receipt, err := selected.Apply(ctx, actor, in) if err == nil && receipt.Error != nil { err = receipt.Error } - return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "action", Receipt: &receipt}, nil, err) + return toolResult(toolPayload{SurfaceID: selected.surfaceID, DesktopID: selected.Snapshot().DesktopID, Operation: "action", Receipt: &receipt}, nil, err) }) mcp.AddTool(server, &mcp.Tool{Name: "surface_release", Description: "Close this run's desktop session and release all held input. Does not disable the workspace display or close another user's viewer."}, func(ctx context.Context, _ *mcp.CallToolRequest, in sessionInput) (*mcp.CallToolResult, any, error) { - return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "release"}, nil, s.CloseSession(in.SessionID, actor)) + selected, err := s.serviceForSession(in.SessionID, actor) + if err != nil { + return toolResult(toolPayload{SurfaceID: DesktopID, Operation: "release"}, nil, err) + } + return toolResult(toolPayload{SurfaceID: selected.surfaceID, DesktopID: selected.Snapshot().DesktopID, Operation: "release"}, nil, selected.CloseSession(in.SessionID, actor)) }) return server } diff --git a/daemon/internal/surface/oblien.go b/daemon/internal/surface/oblien.go index e5f66d4..e0b6e8c 100644 --- a/daemon/internal/surface/oblien.go +++ b/daemon/internal/surface/oblien.go @@ -20,12 +20,15 @@ import ( // Binding is write-only. Its SSH grant is desktop-only, expires at the provider's // deadline, and must match the workspace for which it was issued. type Binding struct { - RegistryID string `json:"registryId"` - WorkspaceID string `json:"workspaceId"` - GatewayToken string `json:"gatewayToken,omitempty"` - Connection SSHConnection `json:"connection"` + RegistryID string `json:"registryId"` + WorkspaceID string `json:"workspaceId"` + GatewayToken string `json:"gatewayToken,omitempty"` + Connection SSHConnection `json:"connection"` + DesktopID string `json:"desktopId,omitempty"` + RuntimeExpiresAt time.Time `json:"runtimeExpiresAt,omitempty"` } type SSHConnection struct { + SessionID string `json:"session_id,omitempty"` ExpiresAt time.Time `json:"expires_at"` SSH SSHSettings `json:"ssh"` VNC VNCSettings `json:"vnc"` @@ -55,12 +58,25 @@ type Oblien struct { func NewOblien(binding Binding) (*Oblien, error) { c := binding.Connection - if !regexp.MustCompile(`^[a-fA-F0-9]{16}$`).MatchString(binding.WorkspaceID) || len(binding.GatewayToken) > 8192 || c.SSH.Host != "ssh.oblien.com" || c.SSH.Port != 22 || + if !regexp.MustCompile(`^[a-fA-F0-9]{16}$`).MatchString(binding.WorkspaceID) || len(binding.GatewayToken) > 8192 || + (binding.DesktopID != "" && !validDesktopID(binding.DesktopID)) { + return nil, problem("invalid_binding", "Invalid workspace desktop binding.") + } + if c.SSH.Host == "" && !binding.RuntimeExpiresAt.IsZero() && binding.GatewayToken != "" { + if time.Now().After(binding.RuntimeExpiresAt) { + return nil, problem("needs_authorization", "Refresh the workspace desktop authorization.") + } + return &Oblien{binding: binding, http: &http.Client{Timeout: 20 * time.Second}, base: "https://workspace.oblien.com"}, nil + } + if c.SSH.Host != "ssh.oblien.com" || c.SSH.Port != 22 || c.VNC.Host != "127.0.0.1" || c.VNC.Port != 5900 || c.VNC.Authentication != "none" || !strings.HasPrefix(c.SSH.Username, "desktop-") || c.SSH.Password == "" || len(c.SSH.Password) > 2048 || !strings.HasPrefix(c.SSH.HostKeyFingerprint, "SHA256:") { return nil, problem("invalid_binding", "Use the desktop SSH connection issued by Oblien for this workspace.") } + if binding.DesktopID != "" && c.SessionID != binding.DesktopID { + return nil, problem("workspace_mismatch", "The desktop grant belongs to another saved desktop.") + } if !strings.Contains(c.SSH.Username, binding.WorkspaceID) { return nil, problem("workspace_mismatch", "Desktop credentials belong to another workspace.") } @@ -69,7 +85,20 @@ func NewOblien(binding Binding) (*Oblien, error) { } return &Oblien{binding: binding, http: &http.Client{Timeout: 20 * time.Second}, base: "https://workspace.oblien.com"}, nil } -func (p *Oblien) ExpiresAt() time.Time { return p.binding.Connection.ExpiresAt } +func (p *Oblien) bindingSnapshot() Binding { p.mu.Lock(); defer p.mu.Unlock(); return p.binding } +func (p *Oblien) ExpiresAt() time.Time { + b := p.bindingSnapshot() + if !b.RuntimeExpiresAt.IsZero() { + return b.RuntimeExpiresAt + } + return b.Connection.ExpiresAt +} +func (p *Oblien) desktopPath() string { + if id := p.bindingSnapshot().DesktopID; id != "" { + return "/desktop/sessions/" + id + } + return "/desktop" +} func (p *Oblien) expired() error { if time.Now().After(p.ExpiresAt()) { _ = p.Close() @@ -78,14 +107,15 @@ func (p *Oblien) expired() error { return nil } func (p *Oblien) request(ctx context.Context, method, path string, body []byte, contentType string) (*http.Response, error) { - if p.binding.GatewayToken == "" { + binding := p.bindingSnapshot() + if binding.GatewayToken == "" { return nil, problem("needs_authorization", "Refresh the workspace desktop connection to use this operation.") } req, err := http.NewRequestWithContext(ctx, method, p.base+path, bytes.NewReader(body)) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+p.binding.GatewayToken) + req.Header.Set("Authorization", "Bearer "+binding.GatewayToken) if contentType != "" { req.Header.Set("Content-Type", contentType) } @@ -94,13 +124,34 @@ func (p *Oblien) request(ctx context.Context, method, path string, body []byte, return nil, problem("unavailable", "The workspace desktop service could not be reached.") } if response.StatusCode < 200 || response.StatusCode >= 300 { - response.Body.Close() + defer response.Body.Close() + if response.StatusCode == 400 || response.StatusCode == 422 { + return nil, problem("invalid_request", "The desktop settings were rejected. Use a name of 1–80 bytes and a supported screen size.") + } if response.StatusCode == 401 || response.StatusCode == 403 { return nil, problem("needs_authorization", "Refresh the workspace desktop authorization.") } if response.StatusCode == 404 { + if strings.HasPrefix(path, "/desktop/sessions/") { + return nil, problem("desktop_not_found", "This saved desktop was removed. Choose or create another desktop.") + } return nil, problem("unsupported", "This workspace runtime does not expose desktop access.") } + if response.StatusCode == 409 { + var detail struct { + Error string `json:"error"` + } + _ = json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&detail) + // Use known provider preconditions without exposing arbitrary upstream + // error bodies (which may contain private connection details). + switch strings.ToLower(strings.TrimSpace(detail.Error)) { + case "stop the desktop before changing its resolution": + return nil, problem("desktop_conflict", "Stop this desktop before changing its resolution.") + case "stop the desktop before deleting its saved profile": + return nil, problem("desktop_conflict", "Stop this desktop before deleting it.") + } + return nil, problem("desktop_conflict", "The desktop is busy or the session limit has been reached. Refresh its status.") + } return nil, problem("unavailable", "The workspace display is not ready. Retry when the workspace is running.") } return response, nil @@ -130,7 +181,7 @@ func (p *Oblien) Status(ctx context.Context) (ProviderStatus, error) { return ProviderStatus{}, err } var status ProviderStatus - if err := p.json(ctx, "GET", "/desktop/status", nil, &status); err != nil { + if err := p.json(ctx, "GET", p.desktopPath()+"/status", nil, &status); err != nil { return status, err } var runtimes struct { @@ -147,7 +198,7 @@ func (p *Oblien) Status(ctx context.Context) (ProviderStatus, error) { return status, err } target := runtimes.Default - parts := strings.Split(p.binding.GatewayToken, ".") + parts := strings.Split(p.bindingSnapshot().GatewayToken, ".") if len(parts) == 3 { if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil { var claims struct { diff --git a/daemon/internal/surface/project_live_test.go b/daemon/internal/surface/project_live_test.go new file mode 100644 index 0000000..6fdce3d --- /dev/null +++ b/daemon/internal/surface/project_live_test.go @@ -0,0 +1,191 @@ +package surface + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/oblien/mindwire/daemon/internal/registry" + "github.com/oblien/mindwire/daemon/internal/session" +) + +// Creates its own disposable virtual desktop. Never sends input to a saved +// user desktop or the main console. The fixture holds only a runtime token. +func TestProjectDesktopOblienLive(t *testing.T) { + fixturePath := os.Getenv("MINDWIRE_PROJECT_DESKTOP_FIXTURE") + if fixturePath == "" { + t.Skip("opt in with a private workspace runtime fixture") + } + data, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatal(err) + } + var fixture struct { + WorkspaceID string `json:"workspaceId"` + Token string `json:"token"` + } + if json.Unmarshal(data, &fixture) != nil || fixture.Token == "" { + t.Fatal("invalid private fixture") + } + root := filepath.Dir(fixturePath) + db, err := registry.Open(filepath.Join(t.TempDir(), "workspace.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err = db.Import(registry.Import{Projects: []registry.Project{{Record: registry.Record{ID: "verification"}, Name: "Mindwire desktop verification", Path: t.TempDir()}}}); err != nil { + t.Fatal(err) + } + creds, err := session.Open(filepath.Join(t.TempDir(), "credentials.json")) + if err != nil { + t.Fatal(err) + } + s, err := New(db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err = s.BindRuntime(RuntimeBinding{RegistryID: db.Identity(), WorkspaceID: fixture.WorkspaceID, GatewayToken: fixture.Token}, creds); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 150*time.Second) + defer cancel() + list, err := s.ListDesktops(ctx, "verification") + if err != nil { + t.Fatal(err) + } + if list.Capabilities.Mode != "virtual" || !list.Capabilities.CanCreate { + t.Skip("this workspace cannot create a disposable virtual desktop") + } + request := CreateDesktopRequest{RequestID: newID(), ProjectID: "verification", Name: "Mindwire project desktop verification", Resolution: &DesktopResolution{960, 640}} + desktop, err := s.CreateDesktop(ctx, request) + if err != nil { + desktop, err = s.CreateDesktop(ctx, request) + } + if err != nil { + t.Fatal(err) + } + if desktop.ID == "console" { + t.Fatal("provider returned the shared console for a virtual desktop") + } + receipt, _ := json.Marshal(map[string]string{"workspaceId": fixture.WorkspaceID, "desktopId": desktop.ID}) + if err = os.WriteFile(filepath.Join(root, "live-desktop-receipt.json"), receipt, 0600); err != nil { + t.Fatal(err) + } + defer func() { + s.CloseRun("live-agent") + cleanup, done := context.WithTimeout(context.Background(), 30*time.Second) + defer done() + if _, err := s.DesktopOperation(cleanup, desktop.ID, "delete", nil); err != nil { + t.Errorf("remove disposable desktop %s: %v", desktop.ID, err) + } + }() + var approvals atomic.Int32 + s.SetApproval(func(context.Context, Actor, string) error { approvals.Add(1); return nil }) + actor := Actor{Kind: "agent", Name: "Desktop verification", RunID: "live-agent", ProjectID: "verification"} + client, finish := mcpClient(t, s, ctx, actor, "codex") + defer finish() + call := func(name string, args any) toolPayload { + t.Helper() + result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + payload := readToolPayload(t, result, err) + if result.IsError { + t.Fatalf("%s: %v", name, payload.Error) + } + return payload + } + opened := call("surface_project_desktop", map[string]any{"requestId": "live-open", "mode": "control"}) + if opened.DesktopID != desktop.ID || opened.Session == nil { + t.Fatal("agent opened another desktop") + } + viewer, err := s.Open(ctx, Actor{Kind: "user"}, OpenRequest{RequestID: "live-phone", Mode: "view", DesktopID: desktop.ID}) + if err != nil { + t.Fatal(err) + } + defer s.CloseSession(viewer.ID, Actor{Kind: "user"}) + if viewer.DesktopID != opened.Session.DesktopID { + t.Fatal("viewer and agent are on different displays") + } + frame := call("surface_capture", map[string]any{"sessionId": opened.Session.ID}) + if frame.Capture == nil || frame.Capture.Geometry.Width != 960 || frame.Capture.Geometry.Height != 640 { + t.Fatalf("wrong created resolution: %+v", frame.Capture) + } + before, err := s.artifacts.Get(frame.Capture.ArtifactID) + if err != nil { + t.Fatal(err) + } + input := func(action Action) { + t.Helper() + call("surface_action", ActionRequest{RequestID: newID(), SessionID: opened.Session.ID, ControlGeneration: opened.Session.Controller.Generation, Action: action}) + } + input(Action{Kind: "key", Keys: []string{"Alt", "F2"}}) + time.Sleep(700 * time.Millisecond) + input(Action{Kind: "text", Text: "mindwire-saved-desktop-input-test"}) + time.Sleep(500 * time.Millisecond) + frame = call("surface_capture", map[string]any{"sessionId": opened.Session.ID}) + after, err := s.artifacts.Get(frame.Capture.ArtifactID) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(before.Data, after.Data) { + t.Fatal("desktop did not change after opening the run dialog and typing") + } + if err = os.WriteFile(filepath.Join(root, "live-desktop-input.png"), after.Data, 0600); err != nil { + t.Fatal(err) + } + input(Action{Kind: "key", Keys: []string{"Escape"}}) + call("surface_release", map[string]any{"sessionId": opened.Session.ID}) + if approvals.Load() != 1 { + t.Fatal("duplicate permission") + } + _ = s.CloseSession(viewer.ID, Actor{Kind: "user"}) + if _, err = s.DesktopOperation(ctx, desktop.ID, "stop", nil); err != nil { + t.Fatal(err) + } + for { + result, err := s.DesktopOperation(ctx, desktop.ID, "get", nil) + if err != nil { + t.Fatal(err) + } + if result.Session.State == "stopped" { + break + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(500 * time.Millisecond): + } + } + s.Close() + restored, err := NewConfigured(db, creds) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + resumed, err := restored.EnsureProjectDesktop(ctx, "verification") + if err != nil || resumed.ID != desktop.ID { + t.Fatalf("restart did not reuse the saved desktop: %s %v", resumed.ID, err) + } + for !resumed.Available { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-time.After(500 * time.Millisecond): + } + result, err := restored.DesktopOperation(ctx, desktop.ID, "get", nil) + if err != nil { + t.Fatal(err) + } + resumed = result.Session + if resumed.State == "failed" { + t.Fatal("saved desktop did not restart") + } + } + t.Logf("LIVE_PROJECT_DESKTOP %s: created at 960x640, MCP screenshot/input, shared viewer, stop/start and durable reassociation passed", desktop.ID) +} diff --git a/daemon/internal/surface/project_tools.go b/daemon/internal/surface/project_tools.go new file mode 100644 index 0000000..f8013b3 --- /dev/null +++ b/daemon/internal/surface/project_tools.go @@ -0,0 +1,141 @@ +package surface + +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const projectDesktopInstructions = `Mindwire provides a saved desktop for this project on its Oblien VM. For UI work call surface_project_desktop with a stable requestId and mode "control". The service asks the user for desktop access, creates or reuses the project's desktop, and starts it if stopped. The user can watch the same desktop in the app. +Use surface_capture for a PNG screenshot and fresh frameId; use surface_action for pixel clicks, double clicks (count:2), drags, scrolling, keyboard chords and text. Pass sessionId, the controller generation, a unique stable requestId, and a frameId younger than 30 seconds for pointer input. Capture again after actions to verify the result. On Linux use Alt+F2 to open the desktop's Run dialog, type a command such as "xdg-open http://localhost:3000", and press Return to open a browser in this exact GUI session. Start the project's web server with the harness's normal command tool. Never guess DISPLAY or launch a separate VNC server. Desktop profiles share workspace files; they are not security sandboxes. +Use surface_release when finished. It leaves apps, the profile and files running for reconnection. Do not stop/delete a desktop just because a task ends. A user can take control at any time; stop input if control is revoked or busy. Never blindly repeat an outcome_unknown action. Provider credentials are private to Mindwire and are never needed by the harness. Only this run can use its desktop control sessions.` + +func (s *Service) desktopInstructions(actor Actor) string { + if !s.HasDesktopCatalog() { + return "Use Mindwire's desktop tools to request access, capture the screen, send input and release control. Do not bypass a busy controller." + } + return projectDesktopInstructions + fmt.Sprintf("\nCurrent project ID: %q. Desktop selection is restricted to this project's saved desktops.", actor.ProjectID) +} + +// Shell-capable harnesses without per-turn MCP support use the same MCP service +// through the daemon helper. Nothing is written into their global config. +func FallbackInstructions() string { + return "\n\n[Mindwire workspace tools]\n" + projectDesktopInstructions + ` +This harness can call the tools using "$MINDWIRE_DESKTOP_HELPER" --desktop-tool TOOL_NAME 'JSON_ARGUMENTS'. Use --desktop-tool list to inspect the full schemas. The helper reads this run's private authorization from its environment; never print it. Capture returns imagePath: open that PNG using your native image tool. Example: "$MINDWIRE_DESKTOP_HELPER" --desktop-tool surface_project_desktop '{"requestId":"ui-work-1","mode":"control"}'.` + "\n[/Mindwire workspace tools]" +} + +func (s *Service) openForRun(ctx context.Context, actor Actor, in OpenRequest) (*mcp.CallToolResult, any, error) { + if !s.HasDesktopCatalog() { + session, err := s.Open(ctx, actor, in) + return toolResult(toolPayload{SurfaceID: s.surfaceID, Operation: "open", Session: &session}, nil, err) + } + payload := toolPayload{SurfaceID: DesktopID, Operation: "open"} + if !validRequest(in.RequestID) || (in.Mode != "view" && in.Mode != "control") { + return toolResult(payload, nil, problem("invalid_request", "Provide a stable requestId and choose view or control.")) + } + if actor.ProjectID == "" { + return toolResult(payload, nil, problem("project_required", "Open a project chat before using a project desktop.")) + } + key := actor.Kind + ":" + actor.RunID + ":" + in.RequestID + s.mu.Lock() + if pending, exists := s.projectOpens[key]; exists { + s.mu.Unlock() + if pending != in { + return toolResult(payload, nil, problem("request_conflict", "This request ID is already opening a different desktop session.")) + } + return toolResult(payload, nil, problem("approval_pending", "This desktop request is still being prepared or waiting for approval.")) + } + if len(s.projectOpens) >= 32 { + s.mu.Unlock() + return toolResult(payload, nil, problem("session_limit", "Wait for a pending desktop request to finish.")) + } + if s.projectOpens == nil { + s.projectOpens = map[string]OpenRequest{} + } + s.projectOpens[key] = in + s.mu.Unlock() + defer func() { s.mu.Lock(); delete(s.projectOpens, key); s.mu.Unlock() }() + // Completed and in-flight retries share the same approval/creation intent. + for _, child := range s.desktopChildren() { + child.mu.Lock() + id := child.openIDs[key] + child.mu.Unlock() + if id != "" { + if in.DesktopID != "" && in.DesktopID != child.surfaceID { + return toolResult(payload, nil, problem("request_conflict", "This request ID opened another desktop.")) + } + session, err := child.sessionCopy(id, actor) + return toolResult(toolPayload{SurfaceID: child.surfaceID, DesktopID: child.surfaceID, Operation: "open", Session: &session}, nil, err) + } + } + if in.DesktopID != "" { + links, err := s.desktopLinks(actor.ProjectID) + if err != nil { + return toolResult(payload, nil, err) + } + if !links[in.DesktopID] { + return toolResult(payload, nil, problem("forbidden", "Choose a desktop linked to the current project.")) + } + } + s.mu.Lock() + approve := s.approval + s.mu.Unlock() + if approve == nil { + return toolResult(payload, nil, problem("approval_required", "Approve desktop access for this agent run.")) + } + if err := approve(ctx, actor, "Allow "+actor.Name+" to create or open this project's desktop and control it for this session?"); err != nil { + return toolResult(payload, nil, err) + } + var desktop SavedDesktop + var err error + if in.DesktopID == "" { + desktop, err = s.EnsureProjectDesktop(ctx, actor.ProjectID) + } else { + var result DesktopResult + result, err = s.DesktopOperation(ctx, in.DesktopID, "get", nil) + desktop = result.Session + if err == nil && (desktop.State == "stopped" || desktop.State == "failed") { + result, err = s.DesktopOperation(ctx, in.DesktopID, "start", nil) + desktop = result.Session + } + } + if err != nil { + return toolResult(payload, nil, err) + } + payload.DesktopID = desktop.ID + payload.SurfaceID = desktop.ID + payload.Desktop = &desktop + deadline := time.NewTimer(45 * time.Second) + defer deadline.Stop() + for !desktop.Available { + if desktop.State == "failed" || desktop.State == "deleting" { + return toolResult(payload, nil, problem("unavailable", "The project desktop did not start. Check its status in the app.")) + } + timer := time.NewTimer(500 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return toolResult(payload, nil, ctx.Err()) + case <-deadline.C: + timer.Stop() + return toolResult(payload, nil, problem("preparing", "The desktop is saved and still starting. Retry to reconnect to this same desktop.")) + case <-timer.C: + } + result, err := s.DesktopOperation(ctx, desktop.ID, "get", nil) + if err != nil { + return toolResult(payload, nil, err) + } + desktop = result.Session + } + selected, err := s.ForDesktop(desktop.ID) + if err != nil { + return toolResult(payload, nil, err) + } + actor.desktopApproved = true + in.DesktopID = desktop.ID + session, err := selected.Open(ctx, actor, in) + payload.Session = &session + return toolResult(payload, nil, err) +} diff --git a/daemon/internal/surface/service.go b/daemon/internal/surface/service.go index 2b0b2a9..87ee18c 100644 --- a/daemon/internal/surface/service.go +++ b/daemon/internal/surface/service.go @@ -34,23 +34,26 @@ type sessionState struct { lastSeen time.Time } type Service struct { - mu sync.Mutex - operation sync.Mutex - db *registry.Store - artifacts *artifact.Store - provider Provider - snapshot Snapshot - sessions map[string]*sessionState - openIDs map[string]string - changed chan struct{} - stop chan struct{} - closeOnce sync.Once - approval Approval - now func() time.Time - ipc *runIPC - lastPrune time.Time - macFactory func(LocalDesktopSettings) (*MacDesktop, error) - viewers map[string]io.ReadWriteCloser + mu sync.Mutex + operation sync.Mutex + db *registry.Store + artifacts *artifact.Store + provider Provider + snapshot Snapshot + sessions map[string]*sessionState + openIDs map[string]string + projectOpens map[string]OpenRequest // guarded by mu; approval/creation currently in flight + changed chan struct{} + stop chan struct{} + closeOnce sync.Once + approval Approval + now func() time.Time + ipc *runIPC + lastPrune time.Time + macFactory func(LocalDesktopSettings) (*MacDesktop, error) + viewers map[string]io.ReadWriteCloser + surfaceID string // immutable; saved desktop ID for child controllers + catalog *desktopCatalog // root only; children reuse this Service's control implementation } func New(db *registry.Store) (*Service, error) { @@ -59,7 +62,8 @@ func New(db *registry.Store) (*Service, error) { return nil, err } s := &Service{db: db, artifacts: artifacts, sessions: map[string]*sessionState{}, - openIDs: map[string]string{}, viewers: map[string]io.ReadWriteCloser{}, changed: make(chan struct{}), stop: make(chan struct{}), now: time.Now} + openIDs: map[string]string{}, viewers: map[string]io.ReadWriteCloser{}, changed: make(chan struct{}), stop: make(chan struct{}), now: time.Now, surfaceID: DesktopID} + s.catalog = &desktopCatalog{children: map[string]*Service{}} s.snapshot = Snapshot{ID: DesktopID, WorkspaceID: db.Identity(), Kind: "desktop", Provider: "oblien", Version: Version, InstanceID: newID(), State: "not_configured"} if err := s.pruneRecords(); err != nil { @@ -95,8 +99,29 @@ func validRequest(id string) bool { return len(id) > 0 && len(id) <= 128 && strings.IndexFunc(id, func(r rune) bool { return r < 33 || r > 126 }) < 0 } func (s *Service) Artifacts() *artifact.Store { return s.artifacts } -func (s *Service) ActiveSessionCount() int { s.mu.Lock(); defer s.mu.Unlock(); return len(s.sessions) } -func (s *Service) SetApproval(fn Approval) { s.mu.Lock(); s.approval = fn; s.mu.Unlock() } +func (s *Service) ActiveSessionCount() int { + s.mu.Lock() + n := len(s.sessions) + s.mu.Unlock() + for _, child := range s.desktopChildren() { + n += child.ActiveSessionCount() + } + return n +} +func (s *Service) SetApproval(fn Approval) { + if s.catalog != nil { + s.catalog.mu.Lock() + defer s.catalog.mu.Unlock() + } + s.mu.Lock() + s.approval = fn + s.mu.Unlock() + if s.catalog != nil { + for _, child := range s.catalog.children { + child.SetApproval(fn) + } + } +} func (s *Service) signalLocked() { s.snapshot.Revision++ close(s.changed) @@ -197,6 +222,13 @@ func (s *Service) Refresh(ctx context.Context) (Snapshot, error) { } func (s *Service) Open(ctx context.Context, actor Actor, req OpenRequest) (Session, error) { + if req.DesktopID != "" && req.DesktopID != s.surfaceID { + child, err := s.ForDesktop(req.DesktopID) + if err != nil { + return Session{}, err + } + return child.Open(ctx, actor, req) + } if !validRequest(req.RequestID) || (req.Mode != "view" && req.Mode != "control") { return Session{}, problem("invalid_request", "Choose view or control and provide a request ID.") } @@ -204,6 +236,15 @@ func (s *Service) Open(ctx context.Context, actor Actor, req OpenRequest) (Sessi return Session{}, problem("invalid_request", "Session name is too long.") } s.operation.Lock() + if s.catalog != nil { + s.catalog.mu.Lock() + configured := s.catalog.backend != nil + s.catalog.mu.Unlock() + if configured { + s.operation.Unlock() + return Session{}, problem("desktop_selection_required", "Select a saved desktop before opening a viewer.") + } + } s.mu.Lock() key := actor.Kind + ":" + actor.RunID + ":" + req.RequestID if id := s.openIDs[key]; id != "" { @@ -267,7 +308,7 @@ func (s *Service) Open(ctx context.Context, actor Actor, req OpenRequest) (Sessi if name == "" { name = "You" } - session := &sessionState{Session: Session{ID: newID(), SurfaceID: DesktopID, Actor: actor.Kind, Name: name, ChatID: actor.ChatID, RunID: actor.RunID, Mode: "view", CreatedAt: at}, lastSeen: at} + session := &sessionState{Session: Session{ID: newID(), SurfaceID: s.surfaceID, DesktopID: s.snapshot.DesktopID, Actor: actor.Kind, Name: name, ChatID: actor.ChatID, RunID: actor.RunID, Mode: "view", CreatedAt: at}, lastSeen: at} s.sessions[session.ID] = session s.openIDs[key] = session.ID s.snapshot.ProviderStatus = status @@ -302,7 +343,7 @@ func (s *Service) authorize(ctx context.Context, id string, actor Actor) error { } authorized := session.authorized s.mu.Unlock() - if actor.Kind == "agent" && !authorized { + if actor.Kind == "agent" && !authorized && !actor.desktopApproved { if fn == nil { return problem("approval_required", "Desktop control needs permission for this agent run.") } @@ -334,6 +375,15 @@ func (s *Service) sessionLocked(id string, actor Actor) (*sessionState, error) { } func (s *Service) Control(ctx context.Context, id string, actor Actor, req ControlRequest) (Session, error) { + if s.catalog != nil { + selected, err := s.serviceForSession(id, actor) + if err != nil { + return Session{}, err + } + if selected != s { + return selected.Control(ctx, id, actor, req) + } + } // Heartbeats never wait behind network input. A long paste must not expire // an otherwise connected user's lease. if req.Action == "renew" { @@ -453,7 +503,18 @@ func (s *Service) Control(ctx context.Context, id string, actor Actor, req Contr return out, nil } -func (s *Service) CloseSession(id string, actor Actor) error { return s.closeSession(id, actor, false) } +func (s *Service) CloseSession(id string, actor Actor) error { + if s.catalog != nil { + selected, err := s.serviceForSession(id, actor) + if err != nil { + return err + } + if selected != s { + return selected.CloseSession(id, actor) + } + } + return s.closeSession(id, actor, false) +} func (s *Service) closeSession(id string, actor Actor, expiredOnly bool) error { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -499,6 +560,9 @@ func (s *Service) closeSession(id string, actor Actor, expiredOnly bool) error { return nil } func (s *Service) CloseRun(runID string) { + for _, child := range s.desktopChildren() { + child.CloseRun(runID) + } s.mu.Lock() ids := []string{} for id, session := range s.sessions { @@ -513,6 +577,15 @@ func (s *Service) CloseRun(runID string) { } func (s *Service) Capture(ctx context.Context, sessionID string, actor Actor) (Capture, error) { + if s.catalog != nil { + selected, err := s.serviceForSession(sessionID, actor) + if err != nil { + return Capture{}, err + } + if selected != s { + return selected.Capture(ctx, sessionID, actor) + } + } s.operation.Lock() defer s.operation.Unlock() s.mu.Lock() @@ -547,7 +620,7 @@ func (s *Service) Capture(ctx context.Context, sessionID string, actor Actor) (C if err != nil { return Capture{}, err } - capture := Capture{ID: newID(), SurfaceID: DesktopID, ArtifactID: rec.ID, Mime: rec.Mime, Geometry: geometry, CreatedAt: s.now().UTC()} + capture := Capture{ID: newID(), SurfaceID: s.surfaceID, ArtifactID: rec.ID, Mime: rec.Mime, Geometry: geometry, CreatedAt: s.now().UTC()} if err := s.db.SurfacePut("surface_capture", capture.ID, captureRecord{Capture: capture, SessionID: sessionID}); err != nil { _ = s.artifacts.Delete(rec.ID) return Capture{}, err @@ -560,6 +633,15 @@ func (s *Service) Capture(ctx context.Context, sessionID string, actor Actor) (C return capture, nil } func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Receipt, error) { + if s.catalog != nil { + selected, err := s.serviceForSession(req.SessionID, actor) + if err != nil { + return Receipt{}, err + } + if selected != s { + return selected.Apply(ctx, actor, req) + } + } if !validRequest(req.RequestID) { return Receipt{}, problem("invalid_request", "Provide a stable action request ID.") } @@ -580,7 +662,7 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re fingerprint := hex.EncodeToString(sum[:]) var old savedReceipt if err := s.db.SurfaceGet("surface_receipt", req.RequestID, &old); err == nil { - if old.Fingerprint != fingerprint { + if old.Fingerprint != fingerprint || old.SurfaceID != s.surfaceID { return Receipt{}, problem("request_conflict", "This request ID was already used for different input.") } return old.Receipt, nil @@ -622,7 +704,7 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re } if actor.Kind == "agent" { var capture captureRecord - if req.FrameID == "" || s.db.SurfaceGet("surface_capture", req.FrameID, &capture) != nil || s.now().Sub(capture.CreatedAt) > 30*time.Second || capture.Geometry != *geometry || capture.SessionID != req.SessionID { + if req.FrameID == "" || s.db.SurfaceGet("surface_capture", req.FrameID, &capture) != nil || capture.SurfaceID != s.surfaceID || s.now().Sub(capture.CreatedAt) > 30*time.Second || capture.Geometry != *geometry || capture.SessionID != req.SessionID { return Receipt{}, problem("stale_frame", "Capture the desktop again; the previous observation is missing, old or resized.") } } else if req.GeometryRevision != geometry.Revision { @@ -642,7 +724,7 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re if count >= 100000 { return Receipt{}, problem("receipt_limit", "Desktop input storage is busy. Wait for older receipts to expire before sending more input.") } - rec := savedReceipt{Receipt: Receipt{ID: req.RequestID, SessionID: req.SessionID, SurfaceID: DesktopID, Kind: req.Action.Kind, Status: "dispatching", CreatedAt: s.now().UTC()}, Fingerprint: fingerprint} + rec := savedReceipt{Receipt: Receipt{ID: req.RequestID, SessionID: req.SessionID, SurfaceID: s.surfaceID, Kind: req.Action.Kind, Status: "dispatching", CreatedAt: s.now().UTC()}, Fingerprint: fingerprint} if err := s.db.SurfacePut("surface_receipt", rec.ID, rec); err != nil { return Receipt{}, err } @@ -671,6 +753,9 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re func (s *Service) Receipt(id string) (Receipt, error) { var rec savedReceipt err := s.db.SurfaceGet("surface_receipt", id, &rec) + if err == nil && rec.SurfaceID != s.surfaceID { + return Receipt{}, registry.ErrNotFound + } return rec.Receipt, err } func (s *Service) failLocked(err error) { @@ -705,6 +790,16 @@ func asError(err error) *Error { func (s *Service) Close() { s.closeOnce.Do(func() { close(s.stop) + if s.catalog != nil { + s.catalog.mu.Lock() + s.catalog.childrenMu.Lock() + s.catalog.closed = true + s.catalog.childrenMu.Unlock() + s.catalog.mu.Unlock() + } + for _, child := range s.desktopChildren() { + child.Close() + } if s.ipc != nil { s.ipc.close() } @@ -731,24 +826,30 @@ func (s *Service) reap() { return case <-ticker.C: } - s.mu.Lock() - var expired []Session - for _, session := range s.sessions { - if session.Actor == "agent" { - if c := s.snapshot.Controller; c != nil && c.SessionID == session.ID { - c.ExpiresAt = s.now().Add(15 * time.Second) - } - continue - } - if s.now().Sub(session.lastSeen) > 20*time.Second { - expired = append(expired, session.Session) + s.reapSessions() + for _, child := range s.desktopChildren() { + child.reapSessions() + } + } +} +func (s *Service) reapSessions() { + s.mu.Lock() + var expired []Session + for _, session := range s.sessions { + if session.Actor == "agent" { + if c := s.snapshot.Controller; c != nil && c.SessionID == session.ID { + c.ExpiresAt = s.now().Add(15 * time.Second) } + continue } - s.mu.Unlock() - for _, session := range expired { - _ = s.closeSession(session.ID, Actor{Kind: "user"}, true) + if s.now().Sub(session.lastSeen) > 20*time.Second { + expired = append(expired, session.Session) } } + s.mu.Unlock() + for _, session := range expired { + _ = s.closeSession(session.ID, Actor{Kind: "user"}, true) + } } func pointValid(x, y *int, g Geometry) bool { return x != nil && y != nil && *x >= 0 && *y >= 0 && *x < g.Width && *y < g.Height @@ -839,9 +940,9 @@ func ErrorResponse(err error) (*Error, int) { status = 400 case "forbidden", "denied": status = 403 - case "unsupported", "session_expired", "not_found": + case "unsupported", "session_expired", "not_found", "desktop_not_found": status = 404 - case "control_lost", "control_busy", "control_taken", "request_conflict", "workspace_mismatch", "stale_frame", "approval_pending": + case "control_lost", "control_busy", "control_taken", "request_conflict", "workspace_mismatch", "stale_frame", "approval_pending", "desktop_conflict": status = 409 case "needs_authorization", "disabled", "preparing", "approval_required", "desktop_setup_required", "screen_sharing_off", "desktop_authentication", "desktop_auth_protocol": status = 428 diff --git a/daemon/internal/surface/service_test.go b/daemon/internal/surface/service_test.go index 2dc0d43..8a49819 100644 --- a/daemon/internal/surface/service_test.go +++ b/daemon/internal/surface/service_test.go @@ -237,9 +237,14 @@ func TestDesktopPermissionOverlayPreservesUserRules(t *testing.T) { if value.Env["CUSTOM"] != "yes" || value.Permissions.DefaultMode != "default" || len(value.Permissions.Deny) != 1 || value.Permissions.Deny[0] != "mcp__mindwire_desktop__surface_action" { t.Fatal("desktop changed unrelated settings or explicit deny rules") } - if len(value.Permissions.Allow) != 6 || value.Permissions.Allow[0] != "Bash(git status)" { + if len(value.Permissions.Allow) != 8 || value.Permissions.Allow[0] != "Bash(git status)" { t.Fatal("permission overlay lost user rules or allowed extra tools") } + for _, allowed := range value.Permissions.Allow[1:] { + if !strings.HasPrefix(allowed, "mcp__mindwire_desktop__surface_") { + t.Fatal("allowed an unrelated tool") + } + } if output, err := PermissionSettings("codex", existing); err != nil || !bytes.Equal(output, existing) { t.Fatal("changed another harness's settings") } @@ -323,7 +328,7 @@ func TestDesktopUnknownOutcomeIsNeverReplayed(t *testing.T) { if strings.Contains(string(rows[0]), "private input") || strings.Contains(string(rows[0]), "clipboard-private") { t.Fatal("receipt persisted input or clipboard data") } - if err := db.SurfacePut("surface_receipt", "interrupted", savedReceipt{Receipt: Receipt{ID: "interrupted", Status: "dispatching"}}); err != nil { + if err := db.SurfacePut("surface_receipt", "interrupted", savedReceipt{Receipt: Receipt{ID: "interrupted", SurfaceID: DesktopID, Status: "dispatching"}}); err != nil { t.Fatal(err) } s.Close() diff --git a/daemon/internal/surface/types.go b/daemon/internal/surface/types.go index 98da45a..b2ae2d7 100644 --- a/daemon/internal/surface/types.go +++ b/daemon/internal/surface/types.go @@ -8,7 +8,7 @@ import ( "time" ) -const Version = 1 +const Version = 2 const DesktopID = "desktop" const MaxTextBytes = 1 << 20 const LocalDesktopVersion = 1 @@ -70,6 +70,7 @@ type Controller struct { type Snapshot struct { ID string `json:"id"` + DesktopID string `json:"desktopId,omitempty"` WorkspaceID string `json:"workspaceId"` Kind string `json:"kind"` Provider string `json:"provider"` @@ -88,6 +89,7 @@ type Snapshot struct { type Session struct { ID string `json:"id"` SurfaceID string `json:"surfaceId"` + DesktopID string `json:"desktopId,omitempty"` Actor string `json:"actor"` Name string `json:"name"` ChatID string `json:"chatId,omitempty"` @@ -98,6 +100,7 @@ type Session struct { } type OpenRequest struct { + DesktopID string `json:"desktopId,omitempty" jsonschema:"Saved provider desktop ID. Omit to use this project's desktop."` RequestID string `json:"requestId"` Name string `json:"name,omitempty"` Mode string `json:"mode"` @@ -156,10 +159,13 @@ type Capture struct { // Actor comes from authentication, not tool arguments. type Actor struct { - Kind string - Name string - RunID string - ChatID string + Kind string + Name string + RunID string + ChatID string + ProjectID string + CWD string + desktopApproved bool } type Provider interface { diff --git a/daemon/openapi.json b/daemon/openapi.json index c355419..8371d0b 100644 --- a/daemon/openapi.json +++ b/daemon/openapi.json @@ -50,7 +50,7 @@ }, { "name": "Surfaces", - "description": "Workspace desktop sessions, one controller shared across harnesses, and durable screenshot artifacts." + "description": "Saved project desktops, one controller per display, shared harness tools and durable screenshot artifacts." }, { "name": "Execution", @@ -194,7 +194,7 @@ }, "version": { "type": "string", - "description": "agent.Version \u2014 the definitions/protocol version." + "description": "agent.Version — the definitions/protocol version." }, "workspaceMetadataVersion": { "type": "integer", @@ -317,7 +317,7 @@ }, "numGoroutine": { "type": "integer", - "description": "Live goroutines \u2014 a rough concurrency gauge." + "description": "Live goroutines — a rough concurrency gauge." }, "memAllocBytes": { "type": "integer", @@ -353,7 +353,7 @@ }, "ProcessSample": { "type": "object", - "description": "One running turn's live resource use at a single sampling tick. Labels and numbers only \u2014 never a secret. A turn runs in its own process group; the figures are summed over that whole group (bash \u2192 node \u2192 the agent).", + "description": "One running turn's live resource use at a single sampling tick. Labels and numbers only — never a secret. A turn runs in its own process group; the figures are summed over that whole group (bash → node → the agent).", "properties": { "agent": { "type": "string", @@ -369,12 +369,12 @@ }, "pid": { "type": "integer", - "description": "Group-leader pid \u2014 the whole turn's process tree." + "description": "Group-leader pid — the whole turn's process tree." }, "cpuPercent": { "type": "number", "format": "double", - "description": "CPU% over the last sampling window (delta of cumulative CPU seconds \u00f7 wall-clock). 0 on the first tick a group is seen." + "description": "CPU% over the last sampling window (delta of cumulative CPU seconds ÷ wall-clock). 0 on the first tick a group is seen." }, "rssBytes": { "type": "integer", @@ -481,7 +481,7 @@ "additionalProperties": { "type": "string" }, - "description": "Per-turn setting overrides addressed by canonical key (see Field.canon). Resolved canon\u2192the selected agent's key and filtered to declared non-secret keys server-side; overrides win over the sticky config." + "description": "Per-turn setting overrides addressed by canonical key (see Field.canon). Resolved canon→the selected agent's key and filtered to declared non-secret keys server-side; overrides win over the sticky config." }, "systemPrompt": { "type": "string", @@ -598,7 +598,7 @@ "error", "cancelled" ], - "description": "Parent-only: why the resolve loop ended \u2014 \"done\" (the agent signalled completion), \"capped\" (hit the iteration/deadline bound), \"error\", or \"cancelled\"." + "description": "Parent-only: why the resolve loop ended — \"done\" (the agent signalled completion), \"capped\" (hit the iteration/deadline bound), \"error\", or \"cancelled\"." }, "iterations": { "type": "integer", @@ -674,7 +674,7 @@ }, "subtype": { "type": "string", - "description": "The agent's own terminal-result classifier when it distinguishes one (Claude's result subtype: \"success\", \"error_max_turns\", \"error_max_budget_usd\", \u2026). Absent for agents whose terminal event is always fully settled (Codex)." + "description": "The agent's own terminal-result classifier when it distinguishes one (Claude's result subtype: \"success\", \"error_max_turns\", \"error_max_budget_usd\", …). Absent for agents whose terminal event is always fully settled (Codex)." }, "incomplete": { "type": "boolean", @@ -703,7 +703,7 @@ "max_turns", "max_budget" ], - "description": "Why this iteration is running: \"start\" (first), \"max_turns\"/\"max_budget\" (resuming a continuable stop), or \"probe\" (a clean settle with no completion sentinel \u2014 probing for done)." + "description": "Why this iteration is running: \"start\" (first), \"max_turns\"/\"max_budget\" (resuming a continuable stop), or \"probe\" (a clean settle with no completion sentinel — probing for done)." }, "childRunId": { "type": "string", @@ -1130,7 +1130,7 @@ }, "CompactRequest": { "type": "object", - "description": "On-demand compaction. instructions is optional \u2014 when present it focuses the continuation summary (Claude's `/compact `); agents that don't honor focus still compact.", + "description": "On-demand compaction. instructions is optional — when present it focuses the continuation summary (Claude's `/compact `); agents that don't honor focus still compact.", "properties": { "instructions": { "type": "string" @@ -1298,7 +1298,7 @@ }, "resolve": { "type": "boolean", - "description": "Agent supports global-resolve runs (POST /turns {mode:\"resolve\"}). Unlike the other switches this is NOT gated in the daemon \u2014 resolve is pure daemon logic over the existing resume path, so every agent that can resume can be resolved; the flag is a UI hint only." + "description": "Agent supports global-resolve runs (POST /turns {mode:\"resolve\"}). Unlike the other switches this is NOT gated in the daemon — resolve is pure daemon logic over the existing resume path, so every agent that can resume can be resolved; the flag is a UI hint only." }, "protocol": { "type": "string", @@ -1429,7 +1429,7 @@ }, "path": { "type": "string", - "description": "Resolved absolute path \u2014 always set, even when the file is absent." + "description": "Resolved absolute path — always set, even when the file is absent." }, "exists": { "type": "boolean", @@ -1473,7 +1473,7 @@ }, "SubagentMeta": { "type": "object", - "description": "Best-effort parsed view of a subagent definition's frontmatter. A convenience only \u2014 the raw `content` is canonical. Every field is optional; absent frontmatter yields no meta object.", + "description": "Best-effort parsed view of a subagent definition's frontmatter. A convenience only — the raw `content` is canonical. Every field is optional; absent frontmatter yields no meta object.", "properties": { "name": { "type": "string" @@ -1575,7 +1575,7 @@ }, "CustomProvider": { "type": "object", - "description": "One registered custom OpenAI-compatible LLM provider. SECURITY: the API key is NEVER part of this shape \u2014 it is supplied write-only on PUT and reported only as `hasKey`. The harness config references the key solely through an env-var placeholder; the value lives in the daemon and enters a run only via the auth env path.", + "description": "One registered custom OpenAI-compatible LLM provider. SECURITY: the API key is NEVER part of this shape — it is supplied write-only on PUT and reported only as `hasKey`. The harness config references the key solely through an env-var placeholder; the value lives in the daemon and enters a run only via the auth env path.", "properties": { "id": { "type": "string", @@ -1808,7 +1808,7 @@ }, "Interaction": { "type": "object", - "description": "A structured, self-describing request the agent surfaces mid-turn for the client to render generically \u2014 and, when needsResponse, for the user to answer.", + "description": "A structured, self-describing request the agent surfaces mid-turn for the client to render generically — and, when needsResponse, for the user to answer.", "properties": { "id": { "type": "string" @@ -2034,7 +2034,7 @@ }, "AuthMethod": { "type": "object", - "description": "One way to authenticate an agent. Field-based methods collect `fields`; interactive ones drive a begin\u2192step\u2192status flow.", + "description": "One way to authenticate an agent. Field-based methods collect `fields`; interactive ones drive a begin→step→status flow.", "properties": { "id": { "type": "string" @@ -2414,7 +2414,7 @@ }, "NotifyChannelMasked": { "type": "object", - "description": "The read-side view of a notification channel. Secrets are never returned \u2014 the URL, token, HMAC secret, and header values are all withheld; only their presence (and the URL host, as a display hint) is reported.", + "description": "The read-side view of a notification channel. Secrets are never returned — the URL, token, HMAC secret, and header values are all withheld; only their presence (and the URL host, as a display hint) is reported.", "properties": { "id": { "type": "string", @@ -2491,7 +2491,7 @@ }, "url": { "type": "string", - "description": "Destination URL. Required (on create). For slack/discord this is the incoming-webhook URL; for telegram the full bot `sendMessage` URL. Write-only \u2014 never returned." + "description": "Destination URL. Required (on create). For slack/discord this is the incoming-webhook URL; for telegram the full bot `sendMessage` URL. Write-only — never returned." }, "headers": { "type": "object", @@ -2516,7 +2516,7 @@ }, "NotifyChannelTestResult": { "type": "object", - "description": "Outcome of a synthetic delivery to one channel. The send result is DATA \u2014 a failed delivery still returns HTTP 200 with `ok:false` and an `error` message.", + "description": "Outcome of a synthetic delivery to one channel. The send result is DATA — a failed delivery still returns HTTP 200 with `ok:false` and an `error` message.", "properties": { "ok": { "type": "boolean" @@ -3419,6 +3419,11 @@ "authentication" ], "additionalProperties": false + }, + "session_id": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." } }, "required": [ @@ -3427,6 +3432,11 @@ "vnc" ], "additionalProperties": false + }, + "desktopId": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." } }, "required": [ @@ -3513,6 +3523,11 @@ }, "mode": { "type": "string" + }, + "desktopId": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." } }, "required": [ @@ -3639,6 +3654,11 @@ "expiresAt" ], "additionalProperties": false + }, + "desktopId": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." } }, "required": [ @@ -3844,6 +3864,11 @@ "reason", "command" ] + }, + "desktopId": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." } }, "required": [ @@ -3907,6 +3932,11 @@ "height" ], "additionalProperties": false + }, + "desktopId": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." } }, "required": [ @@ -5932,6 +5962,241 @@ "required": [ "enabled" ] + }, + "DesktopRuntimeBinding": { + "type": "object", + "properties": { + "registryId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "gatewayToken": { + "type": "string", + "writeOnly": true + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "registryId", + "workspaceId", + "gatewayToken" + ], + "additionalProperties": false, + "description": "Private workspace-scoped runtime token, persisted only in the credential store. Never pass an account token. Refresh before expiry." + }, + "DesktopResolution": { + "type": "object", + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": false + }, + "SavedDesktop": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + }, + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "managed": { + "type": "boolean" + }, + "resolution": { + "$ref": "#/components/schemas/DesktopResolution" + }, + "mode": { + "type": "string" + }, + "can_resize": { + "type": "boolean" + }, + "can_delete": { + "type": "boolean" + }, + "deletion_pending": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "state", + "available", + "managed" + ], + "additionalProperties": false + }, + "DesktopCatalog": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "projectId": { + "type": "string" + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SavedDesktop" + } + }, + "capabilities": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "console", + "virtual" + ] + }, + "max_sessions": { + "type": "integer" + }, + "can_create": { + "type": "boolean" + }, + "resolution": { + "type": "object", + "properties": { + "min": { + "$ref": "#/components/schemas/DesktopResolution" + }, + "max": { + "$ref": "#/components/schemas/DesktopResolution" + }, + "default": { + "$ref": "#/components/schemas/DesktopResolution" + } + }, + "required": [ + "min", + "max", + "default" + ], + "additionalProperties": false + } + }, + "required": [ + "mode", + "max_sessions", + "can_create" + ], + "additionalProperties": false + } + }, + "required": [ + "success", + "sessions", + "capabilities" + ], + "additionalProperties": false + }, + "SavedDesktopResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "session": { + "$ref": "#/components/schemas/SavedDesktop" + } + }, + "required": [ + "success", + "session" + ], + "additionalProperties": false + }, + "SavedDesktopDeletion": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "session": { + "$ref": "#/components/schemas/SavedDesktop" + } + }, + "required": [ + "success" + ], + "additionalProperties": false + }, + "CreateSavedDesktop": { + "type": "object", + "properties": { + "requestId": { + "type": "string", + "description": "Stable idempotency ID; retry with exactly the same body after an uncertain result." + }, + "projectId": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "description": "Desktop name, 1–80 UTF-8 bytes." + }, + "resolution": { + "$ref": "#/components/schemas/DesktopResolution" + } + }, + "required": [ + "requestId", + "name" + ], + "additionalProperties": false + }, + "UpdateSavedDesktop": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 80, + "description": "Desktop name, 1–80 UTF-8 bytes." + }, + "resolution": { + "$ref": "#/components/schemas/DesktopResolution" + } + }, + "required": [], + "additionalProperties": false } } }, @@ -6249,7 +6514,7 @@ "Health" ], "summary": "Daemon process resource snapshot", - "description": "The daemon PROCESS's own runtime stats (heap in use, memory reserved from the OS, goroutines, GC cycles) plus host facts (cores, platform, uptime). Cheap and on-demand \u2014 a single runtime.ReadMemStats, no background sampling. Not the whole machine's RAM/CPU.", + "description": "The daemon PROCESS's own runtime stats (heap in use, memory reserved from the OS, goroutines, GC cycles) plus host facts (cores, platform, uptime). Cheap and on-demand — a single runtime.ReadMemStats, no background sampling. Not the whole machine's RAM/CPU.", "responses": { "200": { "description": "Snapshot", @@ -6270,7 +6535,7 @@ "Health" ], "summary": "Stream live per-turn CPU/memory (SSE)", - "description": "Server-Sent Events of live resource use for currently-running turns, grouped by agent. On-demand and refcounted: the daemon starts sampling only while at least one client is connected and stops the instant the last one disconnects \u2014 zero background work when nothing is watching. Each turn runs in its own process group; a sample is the whole group's summed RSS and its CPU% over the sampling window. Same `data: \\n\\n` framing and `: ping` heartbeat as the run stream; one ProcessFrame per frame. Optional `?agent=` filters each frame's samples to one agent. Payload carries labels and numbers only \u2014 no secrets.", + "description": "Server-Sent Events of live resource use for currently-running turns, grouped by agent. On-demand and refcounted: the daemon starts sampling only while at least one client is connected and stops the instant the last one disconnects — zero background work when nothing is watching. Each turn runs in its own process group; a sample is the whole group's summed RSS and its CPU% over the sampling window. Same `data: \\n\\n` framing and `: ping` heartbeat as the run stream; one ProcessFrame per frame. Optional `?agent=` filters each frame's samples to one agent. Payload carries labels and numbers only — no secrets.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -6552,7 +6817,7 @@ "Prompts" ], "summary": "Delete a persistent memory file", - "description": "Removes one scope's memory file (?scope=, default user) and returns the resulting doc (exists:false). Idempotent \u2014 deleting an absent file still succeeds.", + "description": "Removes one scope's memory file (?scope=, default user) and returns the resulting doc (exists:false). Idempotent — deleting an absent file still succeeds.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -6730,7 +6995,7 @@ "Prompts" ], "summary": "Delete a prompt template", - "description": "Removes one template by name at a scope (?scope=, default user). Idempotent \u2014 deleting an absent template still succeeds.", + "description": "Removes one template by name at a scope (?scope=, default user). Idempotent — deleting an absent template still succeeds.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -6921,7 +7186,7 @@ "Prompts" ], "summary": "Delete a subagent definition", - "description": "Removes one definition by name at a scope (?scope=, default user). Idempotent \u2014 deleting an absent definition still succeeds.", + "description": "Removes one definition by name at a scope (?scope=, default user). Idempotent — deleting an absent definition still succeeds.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -6974,7 +7239,7 @@ "MCP" ], "summary": "List persistent MCP servers", - "description": "Returns the agent's persistent MCP-server config across every supported scope, keyed scope\u2192name\u2192server. A missing config file yields an empty object for that scope. 400 if the agent has no persistent MCP-config module. Carries only an env-var NAME for HTTP auth (bearerTokenEnvVar), never a secret value; on Claude only the mcpServers subtree of the config is ever read.", + "description": "Returns the agent's persistent MCP-server config across every supported scope, keyed scope→name→server. A missing config file yields an empty object for that scope. 400 if the agent has no persistent MCP-config module. Carries only an env-var NAME for HTTP auth (bearerTokenEnvVar), never a secret value; on Claude only the mcpServers subtree of the config is ever read.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -7115,7 +7380,7 @@ "MCP" ], "summary": "Delete a persistent MCP server", - "description": "Removes one MCP server by name at a scope (?scope=, default user). Idempotent \u2014 deleting an absent server still succeeds.", + "description": "Removes one MCP server by name at a scope (?scope=, default user). Idempotent — deleting an absent server still succeeds.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -7168,7 +7433,7 @@ "Providers" ], "summary": "List custom LLM providers", - "description": "Returns the agent's registered custom LLM providers across every supported scope, keyed scope\u2192id\u2192provider. A missing config file yields an empty object for that scope. 400 if the agent has no custom-provider module. `hasKey` reports whether a secret is stored; the key is never returned.", + "description": "Returns the agent's registered custom LLM providers across every supported scope, keyed scope→id→provider. A missing config file yields an empty object for that scope. 400 if the agent has no custom-provider module. `hasKey` reports whether a secret is stored; the key is never returned.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -7256,7 +7521,7 @@ "Providers" ], "summary": "Register a custom LLM provider", - "description": "Registers one custom provider at a scope (?scope=, default user), writing the harness's native custom-endpoint config and preserving all sibling config. The write-only apiKey (when supplied) is stored in the daemon and referenced only via an env-var placeholder \u2014 never written literally. Echoes the stored provider back (hasKey reflects whether a key is stored; the key itself is never returned).", + "description": "Registers one custom provider at a scope (?scope=, default user), writing the harness's native custom-endpoint config and preserving all sibling config. The write-only apiKey (when supplied) is stored in the daemon and referenced only via an env-var placeholder — never written literally. Echoes the stored provider back (hasKey reflects whether a key is stored; the key itself is never returned).", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -7309,7 +7574,7 @@ "Providers" ], "summary": "Delete a custom LLM provider", - "description": "Removes one custom provider by id at a scope (?scope=, default user) and clears its stored key. Idempotent \u2014 deleting an absent provider still succeeds.", + "description": "Removes one custom provider by id at a scope (?scope=, default user) and clears its stored key. Idempotent — deleting an absent provider still succeeds.", "parameters": [ { "$ref": "#/components/parameters/agent" @@ -7578,7 +7843,7 @@ "Turns & runs" ], "summary": "List a resolve run's child iterations", - "description": "Returns the child runs of a global-resolve parent, oldest\u2192newest \u2014 the per-iteration turns of a resolve run (each streams onto the parent's topic). An ordinary turn (or a parent with no iterations yet) returns an empty list.", + "description": "Returns the child runs of a global-resolve parent, oldest→newest — the per-iteration turns of a resolve run (each streams onto the parent's topic). An ordinary turn (or a parent with no iterations yet) returns an empty list.", "parameters": [ { "$ref": "#/components/parameters/id" @@ -7763,7 +8028,7 @@ "Turns & runs" ], "summary": "Soft-stop a running turn", - "description": "Ask the agent to halt its current work WITHOUT the hard context kill `/cancel` does \u2014 the turn stays active for a follow-up over `/input`. Requires the agent's `interrupt` capability.", + "description": "Ask the agent to halt its current work WITHOUT the hard context kill `/cancel` does — the turn stays active for a follow-up over `/input`. Requires the agent's `interrupt` capability.", "parameters": [ { "$ref": "#/components/parameters/id" @@ -8368,7 +8633,7 @@ "Notifications" ], "summary": "List notification channels", - "description": "Returns every configured channel, masked. Secrets (URL, token, HMAC secret, header values) are never returned \u2014 only their presence and the URL host.", + "description": "Returns every configured channel, masked. Secrets (URL, token, HMAC secret, header values) are never returned — only their presence and the URL host.", "responses": { "200": { "description": "Channels (masked)", @@ -8485,7 +8750,7 @@ "Notifications" ], "summary": "Delete a notification channel", - "description": "Removes a channel by id. Idempotent \u2014 deleting an absent channel still succeeds.", + "description": "Removes a channel by id. Idempotent — deleting an absent channel still succeeds.", "parameters": [ { "$ref": "#/components/parameters/id" @@ -8669,7 +8934,7 @@ "Notifications" ], "summary": "Delete a routing rule", - "description": "Removes a rule by id. Idempotent \u2014 deleting an absent rule still succeeds.", + "description": "Removes a rule by id. Idempotent — deleting an absent rule still succeeds.", "parameters": [ { "$ref": "#/components/parameters/id" @@ -10170,6 +10435,15 @@ "default": false }, "description": "Explicitly check provider availability. Otherwise return the cached service snapshot." + }, + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } } ] } @@ -10339,7 +10613,18 @@ } } } - } + }, + "parameters": [ + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + } + ] } }, "/surfaces/desktop/sessions": { @@ -10428,7 +10713,18 @@ } } } - } + }, + "parameters": [ + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + } + ] } }, "/surfaces/desktop/sessions/{id}/control": { @@ -10516,6 +10812,15 @@ "schema": { "type": "string" } + }, + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } } ], "requestBody": { @@ -10623,6 +10928,15 @@ "schema": { "type": "string" } + }, + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } } ] } @@ -10712,6 +11026,15 @@ "schema": { "type": "string" } + }, + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } } ] } @@ -10802,7 +11125,18 @@ } } } - } + }, + "parameters": [ + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + } + ] } }, "/surfaces/desktop/actions/{id}": { @@ -10890,6 +11224,15 @@ "schema": { "type": "string" } + }, + { + "name": "desktopId", + "in": "query", + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } } ] } @@ -14941,7 +15284,7 @@ "tags": [ "Workspace" ], - "summary": "List a checkpoint\u2019s content objects in pages of 512", + "summary": "List a checkpoint’s content objects in pages of 512", "security": [ { "bearer": [] @@ -16211,6 +16554,462 @@ } ] } + }, + "/surfaces/desktop/runtime": { + "put": { + "operationId": "bindDesktopRuntime", + "tags": [ + "Surfaces" + ], + "summary": "Authorize saved workspace desktops", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + } + }, + "required": [ + "configured" + ], + "additionalProperties": false + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopRuntimeBinding" + } + } + } + } + } + }, + "/surfaces/desktop/catalog": { + "get": { + "operationId": "listSavedDesktops", + "tags": [ + "Surfaces" + ], + "summary": "List saved desktops, optionally linked to one project", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopCatalog" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "parameters": [ + { + "name": "projectId", + "in": "query", + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "operationId": "createSavedDesktop", + "tags": [ + "Surfaces" + ], + "summary": "Create a saved desktop with a durable project association", + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedDesktopResult" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSavedDesktop" + } + } + } + } + } + }, + "/surfaces/desktop/catalog/{desktopID}": { + "get": { + "operationId": "getSavedDesktop", + "tags": [ + "Surfaces" + ], + "summary": "Get a saved desktop", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedDesktopResult" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "parameters": [ + { + "name": "desktopID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + } + ] + }, + "patch": { + "operationId": "updateSavedDesktop", + "tags": [ + "Surfaces" + ], + "summary": "Rename or resize a saved desktop within provider capabilities", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedDesktopResult" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "parameters": [ + { + "name": "desktopID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedDesktop" + } + } + } + } + }, + "delete": { + "operationId": "deleteSavedDesktop", + "tags": [ + "Surfaces" + ], + "summary": "Delete a desktop and its private profile; may complete asynchronously", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedDesktopDeletion" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "parameters": [ + { + "name": "desktopID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + } + ] + } + }, + "/surfaces/desktop/catalog/{desktopID}/{operation}": { + "post": { + "operationId": "changeSavedDesktopState", + "tags": [ + "Surfaces" + ], + "summary": "Start or stop the saved desktop; stopping ends its apps", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedDesktopResult" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "parameters": [ + { + "name": "desktopID", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(console|ds_[a-f0-9]{16})$", + "description": "Saved provider desktop ID, distinct from a live control session ID." + } + }, + { + "name": "operation", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "start", + "stop" + ] + } + } + ] + } + }, + "/surfaces/desktop/projects/{id}/ensure": { + "post": { + "operationId": "ensureProjectDesktop", + "tags": [ + "Surfaces" + ], + "summary": "Atomically reuse or create and start a project desktop", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedDesktopResult" + } + } + } + }, + "default": { + "description": "Structured desktop error: invalid request, missing authorization, stale frame, lost controller or provider unavailable.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "error", + "code" + ] + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } } } } diff --git a/daemon/sdk/mindwire_test.go b/daemon/sdk/mindwire_test.go index 60a8878..6d804ce 100644 --- a/daemon/sdk/mindwire_test.go +++ b/daemon/sdk/mindwire_test.go @@ -647,112 +647,120 @@ func TestSDKRouteParity(t *testing.T) { // Managed Git invokes the daemon executable as its credential helper. An // arbitrary program embedding this library cannot handle those invocations; // the HTTP/TypeScript API covers it. See GIT_ACCESS.md for this boundary. - "GET /workspace/git": "HTTP/TypeScript: workspace.git.state; daemon helper required", - "PUT /workspace/git": "HTTP/TypeScript: workspace.git.setDefault; daemon helper required", - "DELETE /workspace/git/connections/{id}": "HTTP/TypeScript: workspace.git.forget; daemon helper required", - "GET /workspace/projects/{id}/git": "HTTP/TypeScript: workspace.git.project; daemon helper required", - "GET /workspace/projects/{id}/icon": "Workspace.ProjectIcon", - "PUT /workspace/projects/{id}/git": "HTTP/TypeScript: workspace.git.setProject; daemon helper required", - "POST /workspace/projects/{id}/git/{operation}": "HTTP/TypeScript: workspace.git.run; daemon helper required", - "POST /workspace/projects/{id}/git/operations": "HTTP/TypeScript: workspace.git.start; daemon Git service required", - "GET /workspace/projects/{id}/git/operations": "HTTP/TypeScript: workspace.git.operations; daemon Git service required", - "GET /workspace/git/operations/{id}": "HTTP/TypeScript: workspace.git.operation; daemon Git service required", - "POST /workspace/git/operations/{id}/cancel": "HTTP/TypeScript: workspace.git.cancel; daemon Git service required", - "GET /workspace/git/operations/{id}/stream": "HTTP/TypeScript: workspace.git.watch; daemon Git service required", - "GET /agent/software": "Client.Software", - "POST /turns": "Client.Turn", - "GET /runs/{id}": "Client.Run", - "GET /runs/{id}/snapshot": "Run.Snapshot", - "GET /runs/{id}/children": "Client.Children", - "POST /runs/{id}/cancel": "Run.Cancel", - "POST /runs/{id}/respond": "Run.Respond", - "POST /runs/{id}/input": "Run.SendInput", - "POST /runs/{id}/interrupt": "Run.Interrupt", - "POST /runs/{id}/set-model": "Run.SetModel", - "POST /runs/{id}/set-permission-mode": "Run.SetPermissionMode", - "GET /runs/{id}/stream": "Run.Stream", - "GET /doctor": "Client.Doctor", - "GET /chats": "Client.Chats", - "GET /surfaces": "Surfaces.List", - "GET /surfaces/desktop": "Surfaces.Status", - "PUT /surfaces/desktop/binding": "Surfaces.Bind", - "GET /surfaces/desktop/local": "Surfaces.LocalStatus", - "PUT /surfaces/desktop/local": "Surfaces.ConfigureLocal", - "GET /surfaces/desktop/events": "Surfaces.Changes", - "POST /surfaces/desktop/sessions": "Surfaces.Open", - "POST /surfaces/desktop/sessions/{id}/control": "Surfaces.Control", - "DELETE /surfaces/desktop/sessions/{id}": "Surfaces.Close", - "POST /surfaces/desktop/sessions/{id}/captures": "Surfaces.Capture", - "POST /surfaces/desktop/actions": "Surfaces.Action", - "GET /surfaces/desktop/actions/{id}": "Surfaces.Receipt", - "GET /artifacts/{id}": "Surfaces.Artifact", - "GET /workspace": "Workspace.Snapshot", - "GET /workspace/changes": "Workspace.Changes", - "GET /workspace/conversations": "Workspace.Conversations", - "POST /workspace/conversations/open": "Workspace.OpenConversation", - "POST /workspace/import": "Workspace.Import", - "POST /workspace/projects": "Workspace.CreateProject", - "POST /workspace/projects/{id}/remove": "Workspace.RemoveProjectFiles", - "GET /workspace/operations": "Workspace.Operations.List", - "GET /workspace/operations/{id}": "Workspace.Operations.Get", - "GET /workspace/operations/{id}/stream": "Workspace.Operations.Watch", - "POST /workspace/operations/{id}/cancel": "Workspace.Operations.Cancel", - "POST /workspace/operations/{id}/retry": "Workspace.Operations.Retry", - "PUT /workspace/{kind}/{id}": "WorkspaceCollection.Put", - "DELETE /workspace/{kind}/{id}": "WorkspaceCollection.Delete", - "PUT /chats/{id}": "Client.RenameChat", - "DELETE /chats/{id}": "Client.DeleteChat", - "POST /chats/{id}/fork": "Client.ForkChat", - "POST /chats/{id}/compact": "Client.Compact", - "GET /chats/{id}/messages": "Client.Messages", - "GET /chats/{id}/run": "Client.LatestRun", - "GET /catalog": "Client.Catalog", - "GET /agent": "Client.Agent", - "GET /models": "Client.Models", - "POST /setup": "Client.Setup", - "POST /update": "Client.Update", - "GET /setup": "Client.SetupStatus", - "GET /config": "Client.GetConfig", - "PUT /config": "Client.SetConfig", - "GET /memory": "Prompts.Memory", - "PUT /memory": "Prompts.SetMemory", - "DELETE /memory": "Prompts.DeleteMemory", - "GET /prompts": "Prompts.List", - "GET /prompts/{name}": "Prompts.Get", - "PUT /prompts/{name}": "Prompts.Set", - "DELETE /prompts/{name}": "Prompts.Delete", - "GET /subagents": "Prompts.Subagents", - "GET /subagents/{name}": "Prompts.Subagent", - "PUT /subagents/{name}": "Prompts.SetSubagent", - "DELETE /subagents/{name}": "Prompts.DeleteSubagent", - "GET /mcp": "MCP.List", - "GET /mcp/{name}": "MCP.Get", - "PUT /mcp/{name}": "MCP.Set", - "DELETE /mcp/{name}": "MCP.Delete", - "GET /providers": "Providers.List", - "GET /providers/{id}": "Providers.Get", - "PUT /providers/{id}": "Providers.Set", - "DELETE /providers/{id}": "Providers.Delete", - "GET /auth/methods": "Auth.Methods", - "POST /auth/begin": "Auth.Begin", - "POST /auth/step": "Auth.Step", - "GET /auth/status": "Auth.Status", - "POST /auth/logout": "Auth.Logout", - "PUT /notify/config": "Client.SetNotifyConfig", - "GET /notify/config": "Client.GetNotifyConfig", - "GET /notify/stream": "Client.Notifications", - "GET /notify/channels": "Client.NotifyChannels", - "POST /notify/channels": "Client.SetNotifyChannel", - "PUT /notify/channels/{id}": "Client.SetNotifyChannel", - "DELETE /notify/channels/{id}": "Client.DeleteNotifyChannel", - "POST /notify/channels/{id}/test": "Client.TestNotifyChannel", - "GET /notify/rules": "Client.NotifyRules", - "POST /notify/rules": "Client.SetNotifyRule", - "PUT /notify/rules/{id}": "Client.SetNotifyRule", - "DELETE /notify/rules/{id}": "Client.DeleteNotifyRule", - "GET /healthz": "Client.Health", - "GET /stats": "Client.Stats", - "GET /processes/stream": "Client.Processes", + "GET /workspace/git": "HTTP/TypeScript: workspace.git.state; daemon helper required", + "PUT /workspace/git": "HTTP/TypeScript: workspace.git.setDefault; daemon helper required", + "DELETE /workspace/git/connections/{id}": "HTTP/TypeScript: workspace.git.forget; daemon helper required", + "GET /workspace/projects/{id}/git": "HTTP/TypeScript: workspace.git.project; daemon helper required", + "GET /workspace/projects/{id}/icon": "Workspace.ProjectIcon", + "PUT /workspace/projects/{id}/git": "HTTP/TypeScript: workspace.git.setProject; daemon helper required", + "POST /workspace/projects/{id}/git/{operation}": "HTTP/TypeScript: workspace.git.run; daemon helper required", + "POST /workspace/projects/{id}/git/operations": "HTTP/TypeScript: workspace.git.start; daemon Git service required", + "GET /workspace/projects/{id}/git/operations": "HTTP/TypeScript: workspace.git.operations; daemon Git service required", + "GET /workspace/git/operations/{id}": "HTTP/TypeScript: workspace.git.operation; daemon Git service required", + "POST /workspace/git/operations/{id}/cancel": "HTTP/TypeScript: workspace.git.cancel; daemon Git service required", + "GET /workspace/git/operations/{id}/stream": "HTTP/TypeScript: workspace.git.watch; daemon Git service required", + "GET /agent/software": "Client.Software", + "POST /turns": "Client.Turn", + "GET /runs/{id}": "Client.Run", + "GET /runs/{id}/snapshot": "Run.Snapshot", + "GET /runs/{id}/children": "Client.Children", + "POST /runs/{id}/cancel": "Run.Cancel", + "POST /runs/{id}/respond": "Run.Respond", + "POST /runs/{id}/input": "Run.SendInput", + "POST /runs/{id}/interrupt": "Run.Interrupt", + "POST /runs/{id}/set-model": "Run.SetModel", + "POST /runs/{id}/set-permission-mode": "Run.SetPermissionMode", + "GET /runs/{id}/stream": "Run.Stream", + "GET /doctor": "Client.Doctor", + "GET /chats": "Client.Chats", + "GET /surfaces": "Surfaces.List", + "GET /surfaces/desktop": "Surfaces.Status", + "PUT /surfaces/desktop/binding": "Surfaces.Bind", + "PUT /surfaces/desktop/runtime": "Surfaces.BindRuntime", + "GET /surfaces/desktop/catalog": "Surfaces.ListDesktops", + "POST /surfaces/desktop/catalog": "Surfaces.CreateDesktop", + "GET /surfaces/desktop/catalog/{desktopID}": "Surfaces.GetDesktop", + "PATCH /surfaces/desktop/catalog/{desktopID}": "Surfaces.UpdateDesktop", + "DELETE /surfaces/desktop/catalog/{desktopID}": "Surfaces.DeleteDesktop", + "POST /surfaces/desktop/catalog/{desktopID}/{operation}": "Surfaces.StartDesktop / StopDesktop", + "POST /surfaces/desktop/projects/{id}/ensure": "Surfaces.EnsureProjectDesktop", + "GET /surfaces/desktop/local": "Surfaces.LocalStatus", + "PUT /surfaces/desktop/local": "Surfaces.ConfigureLocal", + "GET /surfaces/desktop/events": "Surfaces.Changes", + "POST /surfaces/desktop/sessions": "Surfaces.Open", + "POST /surfaces/desktop/sessions/{id}/control": "Surfaces.Control", + "DELETE /surfaces/desktop/sessions/{id}": "Surfaces.Close", + "POST /surfaces/desktop/sessions/{id}/captures": "Surfaces.Capture", + "POST /surfaces/desktop/actions": "Surfaces.Action", + "GET /surfaces/desktop/actions/{id}": "Surfaces.Receipt", + "GET /artifacts/{id}": "Surfaces.Artifact", + "GET /workspace": "Workspace.Snapshot", + "GET /workspace/changes": "Workspace.Changes", + "GET /workspace/conversations": "Workspace.Conversations", + "POST /workspace/conversations/open": "Workspace.OpenConversation", + "POST /workspace/import": "Workspace.Import", + "POST /workspace/projects": "Workspace.CreateProject", + "POST /workspace/projects/{id}/remove": "Workspace.RemoveProjectFiles", + "GET /workspace/operations": "Workspace.Operations.List", + "GET /workspace/operations/{id}": "Workspace.Operations.Get", + "GET /workspace/operations/{id}/stream": "Workspace.Operations.Watch", + "POST /workspace/operations/{id}/cancel": "Workspace.Operations.Cancel", + "POST /workspace/operations/{id}/retry": "Workspace.Operations.Retry", + "PUT /workspace/{kind}/{id}": "WorkspaceCollection.Put", + "DELETE /workspace/{kind}/{id}": "WorkspaceCollection.Delete", + "PUT /chats/{id}": "Client.RenameChat", + "DELETE /chats/{id}": "Client.DeleteChat", + "POST /chats/{id}/fork": "Client.ForkChat", + "POST /chats/{id}/compact": "Client.Compact", + "GET /chats/{id}/messages": "Client.Messages", + "GET /chats/{id}/run": "Client.LatestRun", + "GET /catalog": "Client.Catalog", + "GET /agent": "Client.Agent", + "GET /models": "Client.Models", + "POST /setup": "Client.Setup", + "POST /update": "Client.Update", + "GET /setup": "Client.SetupStatus", + "GET /config": "Client.GetConfig", + "PUT /config": "Client.SetConfig", + "GET /memory": "Prompts.Memory", + "PUT /memory": "Prompts.SetMemory", + "DELETE /memory": "Prompts.DeleteMemory", + "GET /prompts": "Prompts.List", + "GET /prompts/{name}": "Prompts.Get", + "PUT /prompts/{name}": "Prompts.Set", + "DELETE /prompts/{name}": "Prompts.Delete", + "GET /subagents": "Prompts.Subagents", + "GET /subagents/{name}": "Prompts.Subagent", + "PUT /subagents/{name}": "Prompts.SetSubagent", + "DELETE /subagents/{name}": "Prompts.DeleteSubagent", + "GET /mcp": "MCP.List", + "GET /mcp/{name}": "MCP.Get", + "PUT /mcp/{name}": "MCP.Set", + "DELETE /mcp/{name}": "MCP.Delete", + "GET /providers": "Providers.List", + "GET /providers/{id}": "Providers.Get", + "PUT /providers/{id}": "Providers.Set", + "DELETE /providers/{id}": "Providers.Delete", + "GET /auth/methods": "Auth.Methods", + "POST /auth/begin": "Auth.Begin", + "POST /auth/step": "Auth.Step", + "GET /auth/status": "Auth.Status", + "POST /auth/logout": "Auth.Logout", + "PUT /notify/config": "Client.SetNotifyConfig", + "GET /notify/config": "Client.GetNotifyConfig", + "GET /notify/stream": "Client.Notifications", + "GET /notify/channels": "Client.NotifyChannels", + "POST /notify/channels": "Client.SetNotifyChannel", + "PUT /notify/channels/{id}": "Client.SetNotifyChannel", + "DELETE /notify/channels/{id}": "Client.DeleteNotifyChannel", + "POST /notify/channels/{id}/test": "Client.TestNotifyChannel", + "GET /notify/rules": "Client.NotifyRules", + "POST /notify/rules": "Client.SetNotifyRule", + "PUT /notify/rules/{id}": "Client.SetNotifyRule", + "DELETE /notify/rules/{id}": "Client.DeleteNotifyRule", + "GET /healthz": "Client.Health", + "GET /stats": "Client.Stats", + "GET /processes/stream": "Client.Processes", } actual := map[string]bool{} diff --git a/daemon/sdk/surfaces.go b/daemon/sdk/surfaces.go index 0aa1b2c..d279478 100644 --- a/daemon/sdk/surfaces.go +++ b/daemon/sdk/surfaces.go @@ -27,30 +27,56 @@ type SurfaceError = surface.Error type ArtifactContent = artifact.Content // Surfaces is workspace scoped. All WithAgent views share the same controller. -type Surfaces struct{ c *Client } +type Surfaces struct { + c *Client + selected *surface.Service +} + +func (s *Surfaces) desktop() *surface.Service { + if s.selected != nil { + return s.selected + } + return s.c.core.surfaces +} + +// ForDesktop scopes status/events and receipts to a saved provider desktop. +func (s *Surfaces) ForDesktop(id string) (*Surfaces, error) { + selected, err := s.c.core.surfaces.ForDesktop(id) + if err != nil { + return nil, surfaceAPIError("Surfaces.ForDesktop", err) + } + return &Surfaces{c: s.c, selected: selected}, nil +} var surfaceUser = surface.Actor{Kind: "user", Name: "You"} -func (s *Surfaces) List() []SurfaceSnapshot { return []SurfaceSnapshot{s.c.core.surfaces.Snapshot()} } +func (s *Surfaces) List() []SurfaceSnapshot { return []SurfaceSnapshot{s.desktop().Snapshot()} } func (s *Surfaces) Status(ctx context.Context, refresh bool) (SurfaceSnapshot, error) { if refresh { - snapshot, _ := s.c.core.surfaces.Refresh(ctx) + snapshot, _ := s.desktop().Refresh(ctx) return snapshot, nil } - return s.c.core.surfaces.Snapshot(), nil + return s.desktop().Snapshot(), nil } func (s *Surfaces) Bind(ctx context.Context, binding SurfaceBinding) (SurfaceSnapshot, error) { + if s.selected != nil && s.selected.Snapshot().DesktopID != binding.DesktopID { + return SurfaceSnapshot{}, surfaceAPIError("Surfaces.Bind", &surface.Error{Code: "workspace_mismatch", Message: "The desktop grant belongs to another saved desktop."}) + } if err := s.c.core.surfaces.Bind(binding, s.c.core.store); err != nil { return SurfaceSnapshot{}, surfaceAPIError("Surfaces.Bind", err) } - snapshot, _ := s.c.core.surfaces.Refresh(ctx) + selected, err := s.c.core.surfaces.ForDesktop(binding.DesktopID) + if err != nil { + return SurfaceSnapshot{}, surfaceAPIError("Surfaces.Bind", err) + } + snapshot, _ := selected.Refresh(ctx) return snapshot, nil } func (s *Surfaces) LocalStatus(ctx context.Context) (LocalDesktopInfo, error) { - value, err := s.c.core.surfaces.LocalDesktopInfo(ctx) + value, err := s.desktop().LocalDesktopInfo(ctx) if err != nil { - if err = s.c.core.surfaces.EnableLocalDesktop(s.c.core.store); err == nil { - value, err = s.c.core.surfaces.LocalDesktopInfo(ctx) + if err = s.desktop().EnableLocalDesktop(s.c.core.store); err == nil { + value, err = s.desktop().LocalDesktopInfo(ctx) } } return value, surfaceAPIError("Surfaces.LocalStatus", err) @@ -59,42 +85,42 @@ func (s *Surfaces) LocalStatus(ctx context.Context) (LocalDesktopInfo, error) { // ConfigureLocal is an in-process owner operation. HTTP clients must instead // supply the separate local control credential through the Mac CLI. func (s *Surfaces) ConfigureLocal(ctx context.Context, settings LocalDesktopSettings) (SurfaceSnapshot, error) { - if _, err := s.c.core.surfaces.LocalDesktopInfo(ctx); err != nil { - if err = s.c.core.surfaces.EnableLocalDesktop(s.c.core.store); err != nil { + if _, err := s.desktop().LocalDesktopInfo(ctx); err != nil { + if err = s.desktop().EnableLocalDesktop(s.c.core.store); err != nil { return SurfaceSnapshot{}, surfaceAPIError("Surfaces.ConfigureLocal", err) } } - value, err := s.c.core.surfaces.ConfigureLocalDesktop(ctx, settings, s.c.core.store) + value, err := s.desktop().ConfigureLocalDesktop(ctx, settings, s.c.core.store) return value, surfaceAPIError("Surfaces.ConfigureLocal", err) } func (s *Surfaces) Open(ctx context.Context, req SurfaceOpenRequest) (SurfaceSession, error) { - value, err := s.c.core.surfaces.Open(ctx, surfaceUser, req) + value, err := s.desktop().Open(ctx, surfaceUser, req) return value, surfaceAPIError("Surfaces.Open", err) } func (s *Surfaces) Control(ctx context.Context, id string, req SurfaceControlRequest) (SurfaceSession, error) { - value, err := s.c.core.surfaces.Control(ctx, id, surfaceUser, req) + value, err := s.desktop().Control(ctx, id, surfaceUser, req) return value, surfaceAPIError("Surfaces.Control", err) } func (s *Surfaces) Close(id string) error { - return surfaceAPIError("Surfaces.Close", s.c.core.surfaces.CloseSession(id, surfaceUser)) + return surfaceAPIError("Surfaces.Close", s.desktop().CloseSession(id, surfaceUser)) } func (s *Surfaces) Capture(ctx context.Context, id string) (SurfaceCapture, error) { - value, err := s.c.core.surfaces.Capture(ctx, id, surfaceUser) + value, err := s.desktop().Capture(ctx, id, surfaceUser) return value, surfaceAPIError("Surfaces.Capture", err) } func (s *Surfaces) Action(ctx context.Context, req SurfaceActionRequest) (SurfaceReceipt, error) { - value, err := s.c.core.surfaces.Apply(ctx, surfaceUser, req) + value, err := s.desktop().Apply(ctx, surfaceUser, req) return value, surfaceAPIError("Surfaces.Action", err) } func (s *Surfaces) Receipt(id string) (SurfaceReceipt, error) { - value, err := s.c.core.surfaces.Receipt(id) + value, err := s.desktop().Receipt(id) return value, surfaceAPIError("Surfaces.Receipt", err) } func (s *Surfaces) Artifact(id string) (ArtifactContent, error) { - value, err := s.c.core.surfaces.Artifacts().Get(id) + value, err := s.desktop().Artifacts().Get(id) return value, surfaceAPIError("Surfaces.Artifact", err) } -func (s *Surfaces) Changes() <-chan struct{} { return s.c.core.surfaces.Changes() } +func (s *Surfaces) Changes() <-chan struct{} { return s.desktop().Changes() } func surfaceAPIError(op string, err error) error { if err == nil { @@ -103,3 +129,47 @@ func surfaceAPIError(op string, err error) error { detail, status := surface.ErrorResponse(err) return &APIError{Op: op, Status: status, Message: detail.Message, Cause: err} } + +// Saved desktops are durable; control sessions and live leases remain ephemeral. +type DesktopRuntimeBinding = surface.RuntimeBinding +type DesktopResolution = surface.DesktopResolution +type SavedDesktop = surface.SavedDesktop +type DesktopCatalog = surface.DesktopCatalog +type DesktopResult = surface.DesktopResult +type CreateSavedDesktop = surface.CreateDesktopRequest +type UpdateSavedDesktop = surface.UpdateDesktopRequest + +func (s *Surfaces) BindRuntime(binding DesktopRuntimeBinding) error { + return surfaceAPIError("Surfaces.BindRuntime", s.c.core.surfaces.BindRuntime(binding, s.c.core.store)) +} +func (s *Surfaces) ListDesktops(ctx context.Context, projectID string) (DesktopCatalog, error) { + value, err := s.c.core.surfaces.ListDesktops(ctx, projectID) + return value, surfaceAPIError("Surfaces.ListDesktops", err) +} +func (s *Surfaces) CreateDesktop(ctx context.Context, request CreateSavedDesktop) (SavedDesktop, error) { + value, err := s.c.core.surfaces.CreateDesktop(ctx, request) + return value, surfaceAPIError("Surfaces.CreateDesktop", err) +} +func (s *Surfaces) EnsureProjectDesktop(ctx context.Context, projectID string) (SavedDesktop, error) { + value, err := s.c.core.surfaces.EnsureProjectDesktop(ctx, projectID) + return value, surfaceAPIError("Surfaces.EnsureProjectDesktop", err) +} +func (s *Surfaces) desktopOperation(ctx context.Context, id, operation string, update *UpdateSavedDesktop) (DesktopResult, error) { + value, err := s.c.core.surfaces.DesktopOperation(ctx, id, operation, update) + return value, surfaceAPIError("Surfaces.DesktopOperation", err) +} +func (s *Surfaces) GetDesktop(ctx context.Context, id string) (DesktopResult, error) { + return s.desktopOperation(ctx, id, "get", nil) +} +func (s *Surfaces) UpdateDesktop(ctx context.Context, id string, update UpdateSavedDesktop) (DesktopResult, error) { + return s.desktopOperation(ctx, id, "update", &update) +} +func (s *Surfaces) StartDesktop(ctx context.Context, id string) (DesktopResult, error) { + return s.desktopOperation(ctx, id, "start", nil) +} +func (s *Surfaces) StopDesktop(ctx context.Context, id string) (DesktopResult, error) { + return s.desktopOperation(ctx, id, "stop", nil) +} +func (s *Surfaces) DeleteDesktop(ctx context.Context, id string) (DesktopResult, error) { + return s.desktopOperation(ctx, id, "delete", nil) +} diff --git a/packages/sdk/src/surfaces.ts b/packages/sdk/src/surfaces.ts index f1df751..2b6dc1f 100644 --- a/packages/sdk/src/surfaces.ts +++ b/packages/sdk/src/surfaces.ts @@ -14,6 +14,7 @@ export interface SurfaceController { } export interface SurfaceSnapshot { id: string; workspaceId: string; kind: "desktop"; provider: "oblien" | "macos"; + desktopId?: string; version: number; revision: number; instanceId: string; state: string; observedAt?: string; supported: boolean; enabled: boolean; available: boolean; credentials: boolean; os?: string; capabilities: SurfaceCapabilities; @@ -26,19 +27,20 @@ export interface LocalDesktopInfo { supported: boolean; enabled: boolean; screen export interface LocalDesktopSettings { enabled: boolean; username?: string; password?: string } /** Write-only, desktop-only, expiring provider grant. Never pass a user's session JWT. */ export interface SurfaceBinding { - registryId: string; workspaceId: string; gatewayToken?: string; + registryId: string; workspaceId: string; gatewayToken?: string; desktopId?: string; connection: { - expires_at: string; + expires_at: string; session_id?: string; ssh: { host: string; port: number; username: string; password: string; host_key_fingerprint: string }; vnc: { host: string; port: number; authentication: "none" }; }; } export interface SurfaceSession { id: string; surfaceId: string; actor: "user" | "agent"; name: string; + desktopId?: string; chatId?: string; runId?: string; mode: "view" | "control"; createdAt: string; controller?: SurfaceController; } -export interface SurfaceOpenRequest { requestId: string; name?: string; mode: "view" | "control" } +export interface SurfaceOpenRequest { requestId: string; name?: string; mode: "view" | "control"; desktopId?: string } export interface SurfaceControlRequest { action: "acquire" | "takeover" | "release" | "renew"; generation?: number } export interface SurfaceAction { kind: "pointer" | "click" | "drag" | "scroll" | "key" | "text" | "clipboard_read" | "clipboard_write" | "release"; @@ -69,17 +71,82 @@ export interface ArtifactContent { } export interface SurfaceToolAction { surfaceId: string; operation: string; sessionId?: string; receiptId?: string; status?: string; + desktopId?: string; capture?: { id: string; artifactId: string; width: number; height: number }; } -/** Workspace-scoped API. One controller is shared by every harness and app client. */ +/** Private workspace runtime authorization, never an account/session credential. */ +export interface DesktopRuntimeBinding { + registryId: string; workspaceId: string; gatewayToken: string; expiresAt?: string; +} +export interface DesktopResolution { width: number; height: number } +export interface SavedDesktop { + id: string; name: string; state: string; managed: boolean; available: boolean; + resolution?: DesktopResolution; mode?: string; can_resize?: boolean; can_delete?: boolean; + deletion_pending?: boolean; created_at?: string; updated_at?: string; error?: string; +} +export interface DesktopCatalog { + success: boolean; projectId?: string; sessions: SavedDesktop[]; + capabilities: { + mode: "virtual" | "console"; max_sessions: number; can_create: boolean; + resolution?: { min: DesktopResolution; max: DesktopResolution; default: DesktopResolution }; + }; +} +export interface SavedDesktopResult { success: boolean; session: SavedDesktop } +export interface CreateSavedDesktop { + /** Reuse the same ID and body after an interrupted response. */ + requestId: string; projectId?: string; name: string; resolution?: DesktopResolution; +} +export interface UpdateSavedDesktop { name?: string; resolution?: DesktopResolution } + +function savedDesktopID(id: string): string { + if (!/^(console|ds_[a-f0-9]{16})$/.test(id)) throw new Error("Invalid saved desktop ID."); + return id; +} + +/** One controller per saved desktop, shared by every harness and app client. */ export class SurfacesApi { - constructor(private readonly mw: Mindwire) {} + constructor(private readonly mw: Mindwire, private readonly desktopId?: string) {} + /** Select a saved display for status, events, captures and input receipts. */ + desktop(id: string): SurfacesApi { return new SurfacesApi(this.mw, savedDesktopID(id)); } + private scoped(path: string): string { + return this.desktopId ? `${path}?desktopId=${encodeURIComponent(this.desktopId)}` : path; + } + bindRuntime(binding: DesktopRuntimeBinding): Promise<{ configured: boolean }> { + return this.mw.http.request("PUT", "/surfaces/desktop/runtime", { body: binding }); + } + listDesktops(projectId?: string): Promise { + return this.mw.http.request("GET", "/surfaces/desktop/catalog", { query: { projectId } }); + } + createDesktop(request: CreateSavedDesktop): Promise { + return this.mw.http.request("POST", "/surfaces/desktop/catalog", { body: request }); + } + ensureProjectDesktop(projectId: string): Promise { + return this.mw.http.request("POST", `/surfaces/desktop/projects/${encodeURIComponent(projectId)}/ensure`); + } + getDesktop(id: string): Promise { + return this.mw.http.request("GET", `/surfaces/desktop/catalog/${savedDesktopID(id)}`); + } + updateDesktop(id: string, request: UpdateSavedDesktop): Promise { + return this.mw.http.request("PATCH", `/surfaces/desktop/catalog/${savedDesktopID(id)}`, { body: request }); + } + startDesktop(id: string): Promise { + return this.mw.http.request("POST", `/surfaces/desktop/catalog/${savedDesktopID(id)}/start`); + } + /** Stops this desktop's apps; keeps its profile and files. */ + stopDesktop(id: string): Promise { + return this.mw.http.request("POST", `/surfaces/desktop/catalog/${savedDesktopID(id)}/stop`); + } + /** Deletes the desktop's private profile. Closing a viewer uses close(), never this method. */ + deleteDesktop(id: string): Promise<{ success: boolean; session?: SavedDesktop }> { + return this.mw.http.request("DELETE", `/surfaces/desktop/catalog/${savedDesktopID(id)}`); + } list(): Promise { return this.mw.http.request("GET", "/surfaces"); } status(refresh = false): Promise { - return this.mw.http.request("GET", "/surfaces/desktop", { query: { refresh } }); + return this.mw.http.request("GET", this.scoped("/surfaces/desktop"), { query: { refresh } }); } bind(binding: SurfaceBinding): Promise { + if (this.desktopId && binding.desktopId !== this.desktopId) throw new Error("The desktop grant belongs to another saved desktop."); return this.mw.http.request("PUT", "/surfaces/desktop/binding", { body: binding }); } localStatus(): Promise { return this.mw.http.request("GET", "/surfaces/desktop/local"); } @@ -91,22 +158,23 @@ export class SurfacesApi { }); } open(request: SurfaceOpenRequest): Promise { - return this.mw.http.request("POST", "/surfaces/desktop/sessions", { body: request }); + if (this.desktopId && request.desktopId && request.desktopId !== this.desktopId) throw new Error("Select the matching saved desktop."); + return this.mw.http.request("POST", this.scoped("/surfaces/desktop/sessions"), { body: request }); } control(id: string, request: SurfaceControlRequest): Promise { - return this.mw.http.request("POST", `/surfaces/desktop/sessions/${encodeURIComponent(id)}/control`, { body: request }); + return this.mw.http.request("POST", this.scoped(`/surfaces/desktop/sessions/${encodeURIComponent(id)}/control`), { body: request }); } close(id: string): Promise<{ closed: boolean }> { - return this.mw.http.request("DELETE", `/surfaces/desktop/sessions/${encodeURIComponent(id)}`); + return this.mw.http.request("DELETE", this.scoped(`/surfaces/desktop/sessions/${encodeURIComponent(id)}`)); } capture(id: string): Promise { - return this.mw.http.request("POST", `/surfaces/desktop/sessions/${encodeURIComponent(id)}/captures`); + return this.mw.http.request("POST", this.scoped(`/surfaces/desktop/sessions/${encodeURIComponent(id)}/captures`)); } action(request: SurfaceActionRequest): Promise { - return this.mw.http.request("POST", "/surfaces/desktop/actions", { body: request }); + return this.mw.http.request("POST", this.scoped("/surfaces/desktop/actions"), { body: request }); } receipt(id: string): Promise { - return this.mw.http.request("GET", `/surfaces/desktop/actions/${encodeURIComponent(id)}`); + return this.mw.http.request("GET", this.scoped(`/surfaces/desktop/actions/${encodeURIComponent(id)}`)); } artifact(id: string): Promise { return this.mw.http.request("GET", `/artifacts/${encodeURIComponent(id)}`); @@ -118,7 +186,7 @@ export class SurfacesApi { opts.signal?.addEventListener("abort", abort, { once: true }); if (opts.signal?.aborted) controller.abort(); try { - const response = await this.mw.http.open("GET", "/surfaces/desktop/events", { signal: controller.signal }); + const response = await this.mw.http.open("GET", this.scoped("/surfaces/desktop/events"), { signal: controller.signal }); let instance: string | undefined; let revision = -1; for await (const snapshot of readSSE(response.body!, controller.signal)) { diff --git a/packages/sdk/test/surfaces.test.ts b/packages/sdk/test/surfaces.test.ts index 3c9a302..7fa9a1e 100644 --- a/packages/sdk/test/surfaces.test.ts +++ b/packages/sdk/test/surfaces.test.ts @@ -44,6 +44,31 @@ test("uncertain input and controller conflicts are returned without replay", asy expect(calls).toBe(1); }); +test("saved desktop selection scopes events and receipts without changing catalog requests", async () => { + const id = "ds_0123456789abcdef"; + const calls: URL[] = []; + const mw = new Mindwire({ target: remote("http://desktop"), fetch: async (url) => { + const value = new URL(url); calls.push(value); + if (value.pathname.endsWith("/events")) { + return new Response(`data: ${JSON.stringify({ ...snapshot, id, desktopId: id })}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + } + return Response.json({ ...snapshot, id, desktopId: id }); + } }); + const desktop = mw.surfaces.desktop(id); + await desktop.status(true); + await desktop.receipt("input-1"); + await desktop.open({ requestId: "open-1", mode: "view" }); + for await (const value of desktop.watch()) { expect(value.desktopId).toBe(id); break; } + await desktop.listDesktops("project-a"); + await desktop.ensureProjectDesktop("project-a"); + expect(calls.slice(0, 4).every(url => url.searchParams.get("desktopId") === id)).toBe(true); + expect(calls[0]?.searchParams.get("refresh")).toBe("true"); + expect(calls[4]?.searchParams.get("projectId")).toBe("project-a"); + expect(calls[4]?.searchParams.has("desktopId")).toBe(false); + expect(calls[5]?.pathname).toBe("/surfaces/desktop/projects/project-a/ensure"); + expect(() => mw.surfaces.desktop("../../other")).toThrow(); +}); + test("desktop watch ignores stale frames, accepts service restarts and cancels cleanly", async () => { let cancelled = false; let signal: AbortSignal | null | undefined; @@ -65,3 +90,23 @@ test("desktop watch ignores stale frames, accepts service restarts and cancels c expect(cancelled).toBe(true); expect(signal?.aborted).toBe(true); }); + +test("selected desktop binding cannot authorize a different display", async () => { + let calls = 0; + const mw = new Mindwire({ target: remote("http://desktop"), fetch: async () => { + calls++; return Response.json(snapshot); + } }); + const id = "ds_0123456789abcdef"; + const desktop = mw.surfaces.desktop(id); + const binding = { + registryId: "registry", workspaceId: "workspace", desktopId: id, + connection: { expires_at: "2099-01-01T00:00:00Z", session_id: id, + ssh: { host: "ssh.oblien.com", port: 22, username: "desktop-fixture", password: "fixture", host_key_fingerprint: "SHA256:fixture" }, + vnc: { host: "127.0.0.1", port: 5900, authentication: "none" as const } }, + }; + expect(() => desktop.bind({ ...binding, desktopId: undefined })).toThrow("another saved desktop"); + expect(() => desktop.bind({ ...binding, desktopId: "ds_1123456789abcdef" })).toThrow("another saved desktop"); + expect(calls).toBe(0); + await desktop.bind(binding); + expect(calls).toBe(1); +});