From de1ebffe70db0f22a59d1f299fb60a0273f64e1e Mon Sep 17 00:00:00 2001 From: Hydra Date: Sun, 4 Oct 2026 13:47:52 +0300 Subject: [PATCH 1/4] 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); +}); From 2878e6fa7a50be8a583b1310e927cdca59507a68 Mon Sep 17 00:00:00 2001 From: Hydra Date: Mon, 5 Oct 2026 17:55:15 +0300 Subject: [PATCH 2/4] support presistance tunnel reconnection --- daemon/DESKTOP.md | 23 +- daemon/GIT_ACCESS.md | 19 +- daemon/README.md | 11 +- daemon/SETTINGS.md | 27 ++ daemon/WORKSPACES.md | 31 ++ daemon/cmd/daemon/main.go | 2 +- daemon/internal/agent/codex/appserver.go | 42 ++- daemon/internal/agent/codex/codex.go | 16 +- daemon/internal/agent/codex/command_test.go | 6 +- daemon/internal/agent/codex/isolation_test.go | 20 +- .../agent/codex/native_access_local_test.go | 164 +++++++++++ .../agent/codex/resume_process_test.go | 61 ++++ daemon/internal/agent/codex/settings_test.go | 18 +- daemon/internal/api/api.go | 2 + daemon/internal/api/git.go | 19 +- daemon/internal/api/git_admission_test.go | 140 +++++++++ daemon/internal/api/git_test.go | 2 + daemon/internal/api/project_library.go | 33 +++ daemon/internal/api/project_library_test.go | 57 ++++ daemon/internal/registry/library.go | 263 +++++++++++++++++ daemon/internal/registry/library_test.go | 214 ++++++++++++++ daemon/internal/registry/store.go | 19 +- daemon/internal/surface/binding.go | 2 + daemon/internal/surface/catalog.go | 1 + daemon/internal/surface/cursor.go | 132 +++++++++ daemon/internal/surface/cursor_test.go | 122 ++++++++ daemon/internal/surface/mac.go | 12 + .../internal/surface/native_fixture_test.go | 16 + daemon/internal/surface/oblien.go | 30 +- daemon/internal/surface/rfb.go | 128 +++++--- daemon/internal/surface/rfb_test.go | 152 +++++++++- daemon/internal/surface/rfb_updates.go | 112 +++++++ daemon/internal/surface/service.go | 31 +- daemon/internal/surface/service_test.go | 38 +++ .../surface/testdata/linux/display.py | 11 +- daemon/internal/surface/types.go | 1 + daemon/internal/toolchain/admission_test.go | 42 +++ daemon/internal/toolchain/catalog.go | 3 +- daemon/internal/toolchain/exec.go | 14 +- daemon/internal/toolchain/exec_test.go | 39 +++ daemon/internal/toolchain/manager.go | 26 +- daemon/openapi.json | 276 +++++++++++++++++- daemon/sdk/mindwire.go | 3 +- daemon/sdk/mindwire_test.go | 2 + daemon/sdk/surfaces.go | 1 + daemon/sdk/workspace.go | 34 ++- daemon/sdk/workspace_test.go | 31 ++ packages/sdk/src/index.ts | 3 +- packages/sdk/src/surfaces.ts | 8 +- packages/sdk/src/types.ts | 6 +- packages/sdk/src/workspace.ts | 33 +++ packages/sdk/test/workspace-live.test.ts | 12 +- packages/sdk/test/workspace.test.ts | 29 +- 53 files changed, 2410 insertions(+), 129 deletions(-) create mode 100644 daemon/internal/agent/codex/native_access_local_test.go create mode 100644 daemon/internal/agent/codex/resume_process_test.go create mode 100644 daemon/internal/api/git_admission_test.go create mode 100644 daemon/internal/api/project_library.go create mode 100644 daemon/internal/api/project_library_test.go create mode 100644 daemon/internal/registry/library.go create mode 100644 daemon/internal/registry/library_test.go create mode 100644 daemon/internal/surface/cursor.go create mode 100644 daemon/internal/surface/cursor_test.go create mode 100644 daemon/internal/surface/rfb_updates.go create mode 100644 daemon/internal/toolchain/exec_test.go diff --git a/daemon/DESKTOP.md b/daemon/DESKTOP.md index a3a7ee8..d8c038a 100644 --- a/daemon/DESKTOP.md +++ b/daemon/DESKTOP.md @@ -58,11 +58,24 @@ opening their own display connections. The Go RFB adapter handles standard RFB 3.8 None security results, explicit BGR true-color pixels, bounded block decoding, resize notifications and release of held -keys/buttons. Before pointer input it requests a one-pixel update to reconcile -display geometry. Captures request the full image and return actual PNG image +keys/buttons. Button transitions and clicks request a one-pixel update to reconcile +display geometry; continuing human motion reuses geometry observed in the last +250ms. Agent actions still validate their capture. Captures request the full image and return actual PNG image content to the harness. Repeated announcements of an unchanged size preserve the frame and pointer revision; an actual resize invalidates the old coordinates. +The controller also negotiates RichCursor. Changed cursor PNGs and hotspots are +optional `cursor` fields in surface snapshots/events; the stable image ID avoids +decoding an unchanged cursor again. This is needed because TigerVNC can render the +cursor into a separate video viewer instead of sending it local cursor images. +One incremental 1px request waits for changes with no idle polling. Explicit +geometry checks/captures share that connection and take priority. Pixel coverage +tracks partial/tiled replies; cursor-only responses cannot acknowledge a capture. +If the server consumes a request with a cursor or partial reply, remaining pixels +are requested again. Input is never sent by the observer. Cursor dimensions, +hotspots and per-update allocations are bounded; empty cursor updates retain the +last usable shape. Closing the last control/view session closes the observer too. + The initial SetPixelFormat must also use network byte order for channel maxima. The pinned go-vnc dependency encodes those fields incorrectly during Connect; `connectDesktopRFB` corrects that handshake message before sending it. Sending a @@ -155,7 +168,11 @@ cannot take over; after revocation it needs a fresh, approved session. Input is serialized across all clients. Each action has a stable request ID and a durable receipt written before dispatch. Identical retries return the receipt; -a different request with the same ID conflicts. Transport failures and a crash +a different request with the same ID conflicts. A cancelled request is checked +after acquiring the operation lock and immediately +before provider dispatch. It cannot click later when the queue drains. A provider +failure releases held input under the same lock with a separate bounded deadline. +Transport failures and a crash after dispatch produce `outcome_unknown`: observe the desktop before issuing any replacement action. `dispatched` acknowledges VNC/provider delivery, not the remote application's final state. Capture to verify the application. diff --git a/daemon/GIT_ACCESS.md b/daemon/GIT_ACCESS.md index a98cd19..c2c20d4 100644 --- a/daemon/GIT_ACCESS.md +++ b/daemon/GIT_ACCESS.md @@ -119,9 +119,17 @@ reservation until recovery and returns an observation error. Pull only fast-forwards the current branch from origin; push sets upstream to origin and never forces. A forwarded connection rejects another push destination. Concurrent managed Git mutations and agent/project operations in overlapping directories -conflict. Clients must wait for `activeOperations == 0` before replacing the -daemon, in addition to existing run/setup/project-operation checks. Native -terminal commands remain subject to Git's own locking. +conflict, except that staging and unstaging can run while an agent, ordinary command, +or terminal is active. These index-only actions still reserve the repository against +other managed Git mutations and project synchronization/removal, and retain Git's +native index lock. A competing managed Git operation returns HTTP 409 with a Git-busy +message instead of the unrelated registry revision-conflict message. Other Git +actions retain their active-work guard. + +Clients must wait for `activeOperations == 0` before replacing the daemon, in +addition to existing run/setup/project-operation checks. Native terminal commands +remain subject to Git's own locking. Observe `/workspace/git/operations/{id}/stream` +for immediate completion; a disconnected observer can read the same durable receipt. The TypeScript HTTP SDK exposes `workspace.git.start/operation/operations/watch/cancel`, account settings on `workspace.git`, `turn/resolve({gitAuth})`, and @@ -138,4 +146,7 @@ revocation, cloning and restart recovery, metadata compatibility, authenticated run/resume lifecycle, and local fetch/pull/push with divergence protection. Git operation tests also cover lost acknowledgements, idempotency across restart, interrupted recovery without replay, cancellation, persistence failure, literal -paths, unborn unstaging, and discard failures that preserve working files. +paths, unborn unstaging, and discard failures that preserve working files. HTTP +tests also stage and unstage real files during a live command, an idle terminal, +and an agent turn, while verifying that competing Git operations and project +synchronization still block the operation. diff --git a/daemon/README.md b/daemon/README.md index 529fa30..da374a3 100644 --- a/daemon/README.md +++ b/daemon/README.md @@ -30,13 +30,16 @@ curl -s -H "Authorization: Bearer $DAEMON_TOKEN" http://127.0.0.1:8790/healthz | `STATE_PATH` | `agent-state.json` | Local JSON state file. | | `WORKSPACE_DB_PATH` | `workspace.db` beside `STATE_PATH` | Authoritative SQLite registry for agent profiles, projects and chat links. | | `DAEMON_TOKEN` | *(required)* | Bearer token, also saved privately beside the state file for authorized workspace clients. | -| `MINDWIRE_ISOLATION` | `direct` | Trusted launch setting: `container` means the enclosing container provides isolation. Codex defaults to using that boundary; explicit sandbox settings and approvals are preserved. Reported by `/healthz` as `workspaceIsolation` with `workspaceIsolationVersion: 1`. | +| `MINDWIRE_ISOLATION` | `direct` | Trusted launch setting: `direct` uses the host account's normal access; `container` uses the enclosing container as its boundary. Codex adds no inner sandbox by default. Explicit sandbox settings and approvals are preserved. Reported by `/healthz` as `workspaceIsolation` with `workspaceIsolationVersion: 1`. | | `DEV_CORS` | off | `1` allows a cross-origin browser client (e.g. the preview app's dev server). | The runtime image and the SDK's Docker/SSH-container launchers set `MINDWIRE_ISOLATION=container`. -Direct host launchers leave it unset. This avoids requiring privileged nested Linux namespaces for -Codex inside Docker; it does not disable the container boundary or change approval policy. See -[Codex sandboxing](https://developers.openai.com/codex/sandboxing) for the native container guidance. +Direct host launchers leave it unset. Commands run as the account that started Mindwire, with its +normal filesystem, tools and network access (including root when started as root). Codex defaults +to `danger-full-access` in both placements; Docker still supplies the container boundary. This +also avoids requiring privileged nested Linux namespaces. Explicit Codex sandbox settings and +approval policies remain effective. See [Codex sandboxing](https://learn.chatgpt.com/docs/agent-approvals-security) +for the native controls and container guidance. Mount only the workspace data you intend to expose to its agents. This contract requires service 0.1.17 or later; older services do not advertise `workspaceIsolationVersion`. diff --git a/daemon/SETTINGS.md b/daemon/SETTINGS.md index 584f562..9fa4886 100644 --- a/daemon/SETTINGS.md +++ b/daemon/SETTINGS.md @@ -18,6 +18,33 @@ for the selected provider. Neither credentials nor native terminal UI preference cross this boundary. A managed Foundry connection uses its own deployment name and provider tuning rather than an unrelated native provider's values. +## Native command access + +Direct workspaces run commands as the account that started Mindwire, using its +normal filesystem, PATH, home and network access. This includes root on a server +started as root; Mindwire neither changes users nor adds privileges. Docker +workspaces keep their existing container boundary. + +Harness launchers restore inherited PATH entries after login-shell startup and +retain any additional login paths. An explicitly managed CLI version still takes +precedence. This keeps commands installed through Homebrew, npm or a user tool +directory available when a system login profile replaces PATH. Commands run by +the harness still honor that harness's native shell settings. + +Codex defaults to `danger-full-access` when no sandbox has been selected. A +native `sandbox_mode` in the user config or its selected profile, a managed +`sandbox` setting, or a per-turn override takes precedence. The approval policy +is independent and remains unchanged. Both app-server and exec apply this to +new and resumed chats. Explicit `workspace-write` still uses Codex's network +restrictions unless network access is enabled in its native configuration. + +To verify the installed Codex without a model account, run +`CODEX_LOCAL=1 go test ./internal/agent/codex -run '^TestLocalNativeCommandAccess$' -v` +from `daemon/`. A local Responses fixture requests real commands on new and +resumed sessions through both transports. It checks the invoking UID, inherited +tool PATH/environment, and a real HTTP connection. Codex state and credentials +are temporary. The check also runs as root in an ordinary Linux container. + ## Models and reasoning Codex reads native `model/list`, including pagination and hidden-model filtering. diff --git a/daemon/WORKSPACES.md b/daemon/WORKSPACES.md index 43460a2..d5baf26 100644 --- a/daemon/WORKSPACES.md +++ b/daemon/WORKSPACES.md @@ -21,6 +21,7 @@ release architectures remain static, with CGO disabled. | Transcripts | Harness native history, with the daemon's recorded fallback | | Active runs, stop/reconnect state and notifications | Daemon session/run store | | Project setup, clone progress, folder deletion, cancellation and recovery | Daemon project service; durable operations in workspace.db | +| Library folders, folder membership and manual project order | Workspace registry; atomic revisioned library | | List ordering by last use, collapsed UI state, navigation | Client cache | Profiles are named harness selections. They do not duplicate harness configuration, @@ -36,6 +37,8 @@ All routes require the daemon bearer credential. They are workspace-wide: |---|---| | `GET /workspace` | Full snapshot | | `GET /workspace/changes?since=N&workspaceId=ID` | Changes newer than revision N | +| `GET /workspace/project-library` | Read folders and manual order without scanning conversations | +| `PATCH /workspace/project-library` | Conditionally edit folders, placements and order together | | `POST /workspace/import` | Add missing legacy records without overwriting existing data | | `PUT /workspace/{agents,projects,chats}/{id}` | Create or replace one record | | `DELETE /workspace/{agents,projects,chats}/{id}?revision=N` | Remove membership at the expected record revision | @@ -65,6 +68,34 @@ tombstones registry membership. Existing rename/fork endpoints update the regist Registered turns use their saved profile's harness and project's directory; a conflicting explicit harness is rejected. +## Project library + +`projectLibraryVersion: 1` advertises durable folder organization. Full snapshots +include `projectLibrary`; deltas include it only when its revision changed. +The standalone read uses only that small record. There is no polling worker, +filesystem scan, transcript copy, or extra database process for this feature. + +A library contains ordered `folders` (`id`, `name`), `membership` (project ID to +folder ID), and `order` (project IDs). PATCH accepts `expectedRevision`, folder +upserts, `deleteFolders`, `placements`, `order`, and `folderOrder`. A placement +with `folderId: null` returns a project to the flat list. Reorders replace only +the requested entries' slots, retaining other projects' relative positions. + +Every edit commits in one SQLite transaction with the workspace revision. +Identical retries are no-ops. A stale differing edit returns 409; fetch the current +library and rebase only the intended fields. Deleted folder IDs return 410 and +cannot be recreated by a delayed rename. Folder deletion keeps all files, +projects and chats. Project deletion removes library references in its existing +transaction. `importIfEmpty: true` migrates a device's old local organization only +if the daemon library has never been edited; it cannot replace another phone's +saved organization. + +TypeScript exposes `workspace.library()` and `workspace.editLibrary(edit)`; +Go exposes `Workspace.Library()` and `Workspace.EditLibrary(edit)`. Views and +recent/name sort preferences remain local. Clients can cache organization and +queue edits offline, but should distinguish pending edits from acknowledged +workspace data. + ## Project icons `projectIconsVersion: 1` enables an optional project-relative `iconPath` in the diff --git a/daemon/cmd/daemon/main.go b/daemon/cmd/daemon/main.go index b1eb8e4..699d7dc 100644 --- a/daemon/cmd/daemon/main.go +++ b/daemon/cmd/daemon/main.go @@ -192,7 +192,7 @@ func main() { } health.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "agent": sup.Default(), "version": agent.Version, "historyPageVersion": api.HistoryPageVersion, "workspaceMetadataVersion": registry.Version, "projectOperationsVersion": registry.ProjectOperationsVersion, "projectIconsVersion": projecticon.Version, "projectSyncVersion": projectsync.ProtocolVersion(), "conversationBrowserVersion": conversations.BrowserVersion, "surfaceProtocolVersion": surface.Version, "localDesktopVersion": localDesktopVersion, "notificationPreferencesVersion": registry.NotificationPreferencesVersion, "gitAccessVersion": gitaccess.Version, "gitOperationsVersion": gitops.Version, "gitIdentityVersion": gitauthor.Version, "harnessPolicyVersion": toolchain.PolicyVersion, "serviceUpdateVersion": orchestrator.ServiceUpdateVersion, "workspaceIsolationVersion": agent.WorkspaceIsolationVersion, "workspaceIsolation": agent.WorkspaceIsolation(), "workspaceExecutionVersion": workspaceexec.Version, "terminalProtocolVersion": workspaceexec.TerminalVersion, "turnRequestVersion": orchestrator.TurnRequestVersion, "chatForkVersion": agent.ChatForkVersion, "imageAttachmentsVersion": agent.ImageAttachmentsVersion, "computerConnectionVersion": computerVersion}) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "agent": sup.Default(), "version": agent.Version, "historyPageVersion": api.HistoryPageVersion, "workspaceMetadataVersion": registry.Version, "projectLibraryVersion": registry.ProjectLibraryVersion, "projectOperationsVersion": registry.ProjectOperationsVersion, "projectIconsVersion": projecticon.Version, "projectSyncVersion": projectsync.ProtocolVersion(), "conversationBrowserVersion": conversations.BrowserVersion, "surfaceProtocolVersion": surface.Version, "localDesktopVersion": localDesktopVersion, "notificationPreferencesVersion": registry.NotificationPreferencesVersion, "gitAccessVersion": gitaccess.Version, "gitOperationsVersion": gitops.Version, "gitIdentityVersion": gitauthor.Version, "harnessPolicyVersion": toolchain.PolicyVersion, "serviceUpdateVersion": orchestrator.ServiceUpdateVersion, "workspaceIsolationVersion": agent.WorkspaceIsolationVersion, "workspaceIsolation": agent.WorkspaceIsolation(), "workspaceExecutionVersion": workspaceexec.Version, "terminalProtocolVersion": workspaceexec.TerminalVersion, "turnRequestVersion": orchestrator.TurnRequestVersion, "chatForkVersion": agent.ChatForkVersion, "imageAttachmentsVersion": agent.ImageAttachmentsVersion, "computerConnectionVersion": computerVersion}) }) root.Handle("/healthz", api.Auth(token, health)) diff --git a/daemon/internal/agent/codex/appserver.go b/daemon/internal/agent/codex/appserver.go index 1a3cd17..6573d03 100644 --- a/daemon/internal/agent/codex/appserver.go +++ b/daemon/internal/agent/codex/appserver.go @@ -89,7 +89,9 @@ func (a appServer) Run(ctx context.Context, in agent.TurnInput, emit agent.Emit) if err != nil { return agent.TurnResult{Text: err.Error(), IsError: true}, err } - cmd := exec.CommandContext(ctx, "bash", "-lc", toolchain.Shell(a.command)) + commandCtx, stopCommand := context.WithCancel(ctx) + defer stopCommand() + cmd := exec.CommandContext(commandCtx, "bash", "-lc", toolchain.Shell(a.command)) proc.Group(cmd) // cancel/interrupt kills the whole app-server tree, not just the bash parent cmd.Env = toolchain.Environment() for k, v := range a.env { @@ -113,6 +115,12 @@ func (a appServer) Run(ctx context.Context, in agent.TurnInput, emit agent.Emit) result, got := a.converse(ctx, stdin, stdout, in.Inbound, emit) _ = stdin.Close() // signal the server we're done so it exits and stdout hits EOF + // A rejected resume (for example, a thread owned by another Codex client) can + // leave app-server alive after EOF. This process belongs only to this turn; + // reap it before waiting so the run always becomes terminal and Stop stays usable. + if !got || result.IsError || result.Cancelled || ctx.Err() != nil { + stopCommand() + } werr := cmd.Wait() if !got { @@ -177,9 +185,21 @@ func (e *rpcErr) text() string { if detail := errorText(e.Data, ""); detail != "" && !strings.Contains(message, detail) { message = strings.TrimSpace(message + "\n" + detail) } + if conflict := sessionConflictText(message); conflict != "" { + return conflict + } return fmt.Sprintf("Codex RPC error %d: %s", e.Code, message) } +func sessionConflictText(message string) string { + lower := strings.ToLower(message) + if (strings.Contains(lower, "thread") || strings.Contains(lower, "session") || strings.Contains(lower, "conversation")) && + (strings.Contains(lower, "already in use") || strings.Contains(lower, "another client") || strings.Contains(lower, "another process")) { + return "This Codex conversation is open in another client. Close it there, then try sending again." + } + return "" +} + type pendingResp struct { result json.RawMessage err *rpcErr @@ -581,7 +601,19 @@ func (a appServer) converse(ctx context.Context, w io.Writer, r io.Reader, inbou if p.WillRetry { emit(agent.Event{Type: agent.EventStatus, Meta: map[string]any{"message": lastError, "willRetry": true}}) } else { - emit(agent.Event{Type: agent.EventError, Error: lastError}) + if conflict := sessionConflictText(lastError); conflict != "" { + lastError = conflict + emit(agent.Event{Type: agent.EventError, Error: lastError}) + terminated = true + // Session ownership errors need no turn/completed event; Codex + // may never have accepted a turn in the first place. + smu.Lock() + sid := sessionID + smu.Unlock() + emitTerminal(agent.TurnResult{Text: conflict, IsError: true, SessionID: sid}, tokenMeta()) + } else { + emit(agent.Event{Type: agent.EventError, Error: lastError}) + } } } @@ -633,6 +665,12 @@ func (a appServer) converse(ctx context.Context, w io.Writer, r io.Reader, inbou select { case resp = <-ch: default: + smu.Lock() + terminal := result + smu.Unlock() + if terminal.IsError && terminal.Text != "" { + return nil, errors.New(terminal.Text) + } return nil, errTransportClosed } case resp = <-ch: diff --git a/daemon/internal/agent/codex/codex.go b/daemon/internal/agent/codex/codex.go index 8b0facb..3c543be 100644 --- a/daemon/internal/agent/codex/codex.go +++ b/daemon/internal/agent/codex/codex.go @@ -140,7 +140,7 @@ var codexSpecs = []fieldSpec{ {key: keyApproval, label: "Approval policy", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeUnified, canon: agent.CanonPermissionMode, src: srcApproval, emptyLabel: "Never (autonomous)", help: "When Codex pauses to ask you before running a command."}, {key: keyReviewer, label: "Approval reviewer", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeUnified, canon: agent.CanonApprovalReviewer, src: srcReviewer, help: "Approve for me uses Codex's native reviewer with Ask when needed. Ask before commands uses manual review. Custom connections use the chat model for automatic review."}, {key: keyCollaboration, label: "Mode", section: "Model & reasoning", typ: agent.FieldSelect, scope: agent.ScopeCustom, canon: keyCollaboration, src: srcCollaboration, help: "Planning mode asks questions and prepares a plan before implementation. Applies on the next turn."}, - {key: keySandbox, label: "Sandbox", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeCustom, canon: keySandbox, src: srcSandbox, emptyLabel: "Default (workspace-write)", help: "Filesystem/network isolation for model-run commands — Codex's second permission axis, orthogonal to the approval policy."}, + {key: keySandbox, label: "Sandbox", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeCustom, canon: keySandbox, src: srcSandbox, emptyLabel: "Default (host access)", help: "Commands use this computer's files, tools and network as the account running Mindwire. Choose a sandbox to restrict access. Approval policy controls when Codex asks you."}, {key: keyWorkdir, label: "Working directory", section: "Workspace", typ: agent.FieldText, scope: agent.ScopeCustom, canon: keyWorkdir, src: srcNone, placeholder: "/path/to/repo", help: "Directory the agent uses as its working root. Applies to fresh sessions; a resumed session keeps its original directory."}, {key: keyAddDir, label: "Extra directory", section: "Workspace", typ: agent.FieldText, scope: agent.ScopeUnified, canon: agent.CanonExtraDirs, src: srcNone, placeholder: "/path/to/other", help: "Additional directory that should be writable alongside the primary workspace."}, @@ -305,16 +305,16 @@ func approvalPolicy(in agent.TurnInput) string { return "never" } -// sandbox preserves an explicit user choice. Otherwise an externally isolated -// container is the sandbox boundary; direct placements retain workspace-write. +// sandbox preserves explicit native/profile/turn settings. Otherwise commands +// run with the daemon account's normal access. Direct workspaces use the host; +// container workspaces already have an outer filesystem/process boundary. +// workspace-write would silently block network access (including DNS/gh), and +// require nested Linux namespaces on some hosts. Approval policy is independent. func sandbox(in agent.TurnInput) string { if v := strings.TrimSpace(in.Config[keySandbox]); v != "" { return v } - if agent.WorkspaceIsolation() == "container" { - return "danger-full-access" - } - return "workspace-write" + return "danger-full-access" } // materialized holds per-turn temp-file paths and resolved attachment references the adapter creates @@ -391,7 +391,7 @@ func buildExecCommand(in agent.TurnInput, files materialized) string { // flag, so it's a config override on both fresh and resume. cli += " -c " + agent.ShellQuote("approval_policy="+approvalPolicy(in)) - // Sandbox posture — always emitted (default workspace-write). Fresh takes -s; resume rejects it, so + // Sandbox posture — always emitted (default native access). Fresh takes -s; resume rejects it, so // it goes through a config override. sb := sandbox(in) if resuming { diff --git a/daemon/internal/agent/codex/command_test.go b/daemon/internal/agent/codex/command_test.go index 6911cf0..3bd3e1c 100644 --- a/daemon/internal/agent/codex/command_test.go +++ b/daemon/internal/agent/codex/command_test.go @@ -128,15 +128,15 @@ func TestBuildExecCommandSessionPrecedence(t *testing.T) { } } -// Defaults: an unconfigured turn is autonomous — approval never, sandbox workspace-write — and unset +// Defaults: an unconfigured turn is autonomous with native host/container access, and unset // settings never appear as flags. func TestBuildExecCommandDefaultsAndOmits(t *testing.T) { cmd := buildExecCommand(agent.TurnInput{Message: "x"}, materialized{}) if !strings.Contains(cmd, "-c 'approval_policy=never'") { t.Errorf("expected default approval_policy=never, got: %s", cmd) } - if !strings.Contains(cmd, "-s 'workspace-write'") { - t.Errorf("expected default sandbox workspace-write, got: %s", cmd) + if !strings.Contains(cmd, "-s 'danger-full-access'") { + t.Errorf("expected default native access, got: %s", cmd) } for _, bad := range []string{"-m ", "-C ", "--add-dir", "model_reasoning_effort", "resume"} { if strings.Contains(cmd, bad) { diff --git a/daemon/internal/agent/codex/isolation_test.go b/daemon/internal/agent/codex/isolation_test.go index 25da5cd..f0edcda 100644 --- a/daemon/internal/agent/codex/isolation_test.go +++ b/daemon/internal/agent/codex/isolation_test.go @@ -7,14 +7,11 @@ import ( "github.com/oblien/mindwire/daemon/internal/agent" ) -func TestPlacementControlsOnlyDefaultSandbox(t *testing.T) { +func TestNativeAccessPreservesWorkspaceBoundaryAndExplicitPermissions(t *testing.T) { for _, placement := range []string{"", "direct", "container", "unknown"} { t.Run(placement, func(t *testing.T) { t.Setenv("MINDWIRE_ISOLATION", placement) - want := "workspace-write" - if placement == "container" { - want = "danger-full-access" - } + want := "danger-full-access" for _, sessionID := range []string{"", "existing-thread"} { input := agent.TurnInput{Message: "continue", SessionID: sessionID, Config: map[string]string{keyApproval: "on-request"}, Env: map[string]string{"MINDWIRE_ISOLATION": "container"}} @@ -22,6 +19,11 @@ func TestPlacementControlsOnlyDefaultSandbox(t *testing.T) { if server.sandbox != want || server.approval != "on-request" { t.Fatalf("placement changed approvals or ignored sandbox: %s, %s", server.sandbox, server.approval) } + for _, params := range []map[string]any{server.startParams(), server.resumeParams()} { + if params["sandbox"] != want || params["approvalPolicy"] != "on-request" { + t.Fatalf("thread start/resume lost native access or approvals: %+v", params) + } + } flag := "-s '" + want + "'" if sessionID != "" { flag = "-c 'sandbox_mode=" + want + "'" @@ -30,9 +32,11 @@ func TestPlacementControlsOnlyDefaultSandbox(t *testing.T) { !strings.Contains(command, "approval_policy=on-request") { t.Fatalf("exec/resume must agree with app-server: %s", command) } - input.Config[keySandbox] = "read-only" - if sandbox(input) != "read-only" { - t.Fatal("placement replaced an explicit user restriction") + for _, explicit := range []string{"read-only", "workspace-write"} { + input.Config[keySandbox] = explicit + if sandbox(input) != explicit || newAppServer(input, materialized{}).sandbox != explicit { + t.Fatal("placement replaced an explicit user restriction") + } } } }) diff --git a/daemon/internal/agent/codex/native_access_local_test.go b/daemon/internal/agent/codex/native_access_local_test.go new file mode 100644 index 0000000..23522ad --- /dev/null +++ b/daemon/internal/agent/codex/native_access_local_test.go @@ -0,0 +1,164 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/oblien/mindwire/daemon/internal/agent" +) + +// The installed CLI executes real shell tools against a local model fixture. +// This verifies PATH, user identity, environment and network on start AND resume, +// without inference, real credentials, or changes to the user's Codex home. +// Also runnable as root in Linux to catch accidental nested sandbox requirements. +func TestLocalNativeCommandAccess(t *testing.T) { + if os.Getenv("CODEX_LOCAL") != "1" { + t.Skip("set CODEX_LOCAL=1 to test native commands with the installed Codex") + } + for _, binary := range []string{"codex", "curl"} { + if _, err := exec.LookPath(binary); err != nil { + t.Fatal(err) + } + } + t.Setenv("MINDWIRE_ISOLATION", "direct") + t.Setenv("MINDWIRE_TOOLCHAIN_DIR", t.TempDir()) + bin := filepath.Join(t.TempDir(), "native tools") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + fixture := "#!/bin/sh\nprintf 'native-user=%s native-env=%s\\n' \"$(id -u)\" \"$MINDWIRE_NATIVE_CHECK\"\n" + if err := os.WriteFile(filepath.Join(bin, "mindwire-native-probe"), []byte(fixture), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("app-server=%t", streaming), func(t *testing.T) { + codexDir, cwd := t.TempDir(), t.TempDir() + t.Setenv("CODEX_HOME", codexDir) + var requests, probes atomic.Int32 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/native-probe" { + probes.Add(1) + _, _ = fmt.Fprintln(w, "native-network-ok") + return + } + if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/responses") { + http.NotFound(w, r) + return + } + var request map[string]any + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil { + t.Error(err) + w.WriteHeader(400) + return + } + index := int(requests.Add(1)) + if index%2 == 0 { + // Inspect only this call's result, not the previous turn's history. + callID := fmt.Sprintf("call_desktop_%d", index-1) + output := localCommandOutput(request["input"], callID) + for _, want := range []string{fmt.Sprintf("native-user=%d native-env=inherited", os.Geteuid()), "native-network-ok"} { + if !strings.Contains(output, want) { + t.Errorf("native command output missing %q: %s", want, output) + } + } + writeLocalReply(w, index, "Native checks complete.") + return + } + name, namespace := localCommandTool(request["tools"], "") + command := "mindwire-native-probe && curl --fail --silent --show-error --connect-timeout 2 --max-time 4 " + agent.ShellQuote(server.URL+"/native-probe") + // Verify the environment arriving at the harness. A tool can opt into + // another login shell, whose own profile may intentionally replace PATH. + args := map[string]any{"workdir": cwd, "login": false} + switch name { + case "exec_command": + args["cmd"], args["yield_time_ms"] = command, 1000 + case "shell_command": + args["command"], args["timeout_ms"] = command, 10000 + case "shell": + args["command"], args["timeout_ms"] = []string{"bash", "-c", command}, 10000 + default: + t.Error("native CLI did not advertise a command tool") + writeLocalReply(w, index, "Missing shell.") + return + } + writeLocalToolCall(w, index, namespace, name, args) + })) + defer server.Close() + config := fmt.Sprintf("model = \"gpt-5.5\"\nmodel_provider = \"local\"\n[model_providers.local]\nname = \"Local native-access fixture\"\nbase_url = %q\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n", server.URL) + if err := os.WriteFile(filepath.Join(codexDir, "config.toml"), []byte(config), 0600); err != nil { + t.Fatal(err) + } + in := agent.TurnInput{Message: "Run the native command check.", CWD: cwd, + Config: map[string]string{keyModel: "gpt-5.5", keyEffort: "low"}, + Env: map[string]string{"CODEX_API_KEY": "fixture-api-key", "OPENAI_BASE_URL": server.URL, "MINDWIRE_NATIVE_CHECK": "inherited"}} + if streaming { + in.Inbound = make(chan agent.Inbound) + } + for turn := 1; turn <= 2; turn++ { + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + result, err := (adapter{}).RunStream(ctx, in, func(agent.Event) {}) + cancel() + if err != nil || result.IsError || result.Text != "Native checks complete." { + t.Fatalf("turn %d: %+v %v", turn, result, err) + } + if result.SessionID == "" || (in.SessionID != "" && result.SessionID != in.SessionID) { + t.Fatal("native check did not resume the same conversation") + } + in.SessionID = result.SessionID + } + if requests.Load() != 4 || probes.Load() != 2 { + t.Fatalf("start/resume requests=%d network probes=%d", requests.Load(), probes.Load()) + } + }) + } +} + +func localCommandTool(value any, namespace string) (string, string) { + switch value := value.(type) { + case map[string]any: + name, _ := value["name"].(string) + if value["type"] == "namespace" { + namespace = name + } + if name == "exec_command" || name == "shell_command" || name == "shell" { + return name, namespace + } + for _, child := range value { + if name, scope := localCommandTool(child, namespace); name != "" { + return name, scope + } + } + case []any: + for _, child := range value { + if name, scope := localCommandTool(child, namespace); name != "" { + return name, scope + } + } + } + return "", "" +} + +func localCommandOutput(value any, callID string) string { + items, _ := value.([]any) + for _, raw := range items { + item, _ := raw.(map[string]any) + if item["type"] == "function_call_output" && item["call_id"] == callID { + output, _ := json.Marshal(item["output"]) + return string(output) + } + } + return "" +} diff --git a/daemon/internal/agent/codex/resume_process_test.go b/daemon/internal/agent/codex/resume_process_test.go new file mode 100644 index 0000000..24b58fa --- /dev/null +++ b/daemon/internal/agent/codex/resume_process_test.go @@ -0,0 +1,61 @@ +package codex + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/oblien/mindwire/daemon/internal/agent" +) + +func TestRejectedResumeReapsAppServerWithoutWaitingForTurnTimeout(t *testing.T) { + for _, mode := range []string{"rpc", "notification"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + a := appServer{command: "'" + strings.ReplaceAll(os.Args[0], "'", "'\\''") + "' -test.run=^TestRejectedResumeHelper$", + env: map[string]string{"MINDWIRE_REJECTED_RESUME_HELPER": mode}, resumeID: "busy-thread"} + result, _ := a.Run(ctx, agent.TurnInput{}, func(agent.Event) {}) + if ctx.Err() != nil { + t.Fatal("resume rejection left the process and run stuck until timeout") + } + if !result.IsError || !strings.Contains(result.Text, "open in another client") { + t.Fatalf("lost native conflict: %+v", result) + } + }) + } +} + +func TestRejectedResumeHelper(t *testing.T) { + mode := os.Getenv("MINDWIRE_REJECTED_RESUME_HELPER") + if mode == "" { + return + } + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var request rpcIn + _ = json.Unmarshal(scanner.Bytes(), &request) + if request.Method == "initialize" { + fmt.Printf("{\"id\":%s,\"result\":{}}\n", request.ID) + } + if request.Method == "thread/resume" { + if mode == "rpc" { + fmt.Printf("{\"id\":%s,\"error\":{\"code\":-32000,\"message\":\"thread is already in use by another client\"}}\n", request.ID) + } else { + fmt.Printf("{\"id\":%s,\"result\":{\"thread\":{\"id\":\"busy-thread\"}}}\n", request.ID) + } + } + if request.Method == "turn/start" && mode == "notification" { + fmt.Println(`{"method":"error","params":{"threadId":"busy-thread","error":{"message":"session is already in use by another process"},"willRetry":false}}`) + } + } + // Mimic an app-server which keeps runtime tasks alive after stdin closes. + for { + time.Sleep(time.Hour) + } +} diff --git a/daemon/internal/agent/codex/settings_test.go b/daemon/internal/agent/codex/settings_test.go index 4f98dea..d397aec 100644 --- a/daemon/internal/agent/codex/settings_test.go +++ b/daemon/internal/agent/codex/settings_test.go @@ -78,9 +78,13 @@ model_provider = "azure_custom" model_reasoning_effort = "max" model_reasoning_summary = "auto" approvals_reviewer = "user" +sandbox_mode = "read-only" +approval_policy = "untrusted" profile = "focused" [profiles.focused] model_reasoning_summary = "concise" +sandbox_mode = "workspace-write" +approval_policy = "on-request" [model_providers.azure_custom] name = "Private provider" experimental_bearer_token = "fixture-secret" @@ -95,7 +99,7 @@ stream_idle_timeout_ms = 300000 } store := mapStore{} values := agent.ReadSettings(adapter{}, store) - for key, want := range map[string]string{keyModel: "gpt-6-astra", keyEffort: "max", keySummary: "concise", keyReviewer: "user", keyRequestRetries: "4", keyStreamRetries: "10", keyStreamTimeout: "300000"} { + for key, want := range map[string]string{keyModel: "gpt-6-astra", keyEffort: "max", keySummary: "concise", keyReviewer: "user", keySandbox: "workspace-write", keyApproval: "on-request", keyRequestRetries: "4", keyStreamRetries: "10", keyStreamTimeout: "300000"} { if values[key] != want { t.Errorf("%s = %q; want %q", key, values[key], want) } @@ -104,6 +108,18 @@ stream_idle_timeout_ms = 300000 if strings.Contains(string(encoded), "fixture-secret") || strings.Contains(string(encoded), "tui") { t.Fatal("non-settings metadata or credentials leaked") } + server := newAppServer(agent.TurnInput{Config: values}, materialized{}) + if server.sandbox != "workspace-write" || server.approval != "on-request" { + t.Fatal("native profile restrictions were replaced by the host-access default") + } + store[keySandbox] = "read-only" + if sandbox(agent.TurnInput{Config: agent.ReadSettings(adapter{}, store)}) != "read-only" { + t.Fatal("explicit managed sandbox was lost") + } + store[keySandbox] = "" + if sandbox(agent.TurnInput{Config: agent.ReadSettings(adapter{}, store)}) != "workspace-write" { + t.Fatal("reset did not restore the native sandbox") + } store[keyEffort] = "high" if agent.ReadSettings(adapter{}, store)[keyEffort] != "high" { t.Fatal("managed override lost") diff --git a/daemon/internal/api/api.go b/daemon/internal/api/api.go index 91c5756..29fc773 100644 --- a/daemon/internal/api/api.go +++ b/daemon/internal/api/api.go @@ -139,6 +139,8 @@ type Route struct { // Routes is the full authenticated API surface. Every agent-specific route accepts ?agent=. func (a *API) Routes() []Route { return []Route{ + {"GET", "/workspace/project-library", a.projectLibrary}, + {"PATCH", "/workspace/project-library", a.projectLibrary}, {"GET", "/workspace/host", a.workspaceHost}, {"GET", "/workspace/resources", a.workspaceResources}, {"GET", "/workspace/files", a.workspaceFiles}, diff --git a/daemon/internal/api/git.go b/daemon/internal/api/git.go index 8fbfdd8..8f05d07 100644 --- a/daemon/internal/api/git.go +++ b/daemon/internal/api/git.go @@ -259,6 +259,13 @@ type gitOperationRequest struct { Auth *gitaccess.Auth `json:"auth,omitempty"` } +// Admission conflicts are not registry revision conflicts. Keep the HTTP 409 +// classification, but tell the caller which work actually needs to finish. +type gitBusyError string + +func (e gitBusyError) Error() string { return string(e) } +func (e gitBusyError) Unwrap() error { return registry.ErrConflict } + func (a *API) startGitOperation(ctx context.Context, projectID string, req gitOperationRequest) (registry.GitOperation, error) { spec := registry.GitSpec{ID: req.ID, ProjectID: projectID, Action: req.Action, Paths: req.Paths, Message: req.Message, Branch: req.Branch, Remote: req.Remote, NewName: req.NewName, ExpectedTip: req.ExpectedTip, Force: req.Force, Identity: req.Identity, @@ -284,8 +291,16 @@ func (a *API) startGitOperation(ctx context.Context, projectID string, req gitOp if err != nil { return registry.GitOperation{}, err } - if a.BusyPath(spec.Path) { - return registry.GitOperation{}, registry.ErrConflict + if a.gitBusyPath(spec.Path) { + return registry.GitOperation{}, gitBusyError("Another Git operation is running in this repository. Wait for it to finish and try again.") + } + // Index-only actions can run while an agent edits files or a terminal is + // open. Git's own index lock still excludes competing native Git writes. + // Worktree/history mutations retain the active-work guard; every action also + // retains the durable Git/project-sync reservation below. + indexOnly := spec.Action == "stage" || spec.Action == "unstage" + if !indexOnly && a.BusyPath(spec.Path) { + return registry.GitOperation{}, gitBusyError("Close active terminals or wait for the agent or command to finish before running this Git operation.") } if err := a.registry.CheckProjectPath(spec.Path); err != nil { return registry.GitOperation{}, err diff --git a/daemon/internal/api/git_admission_test.go b/daemon/internal/api/git_admission_test.go new file mode 100644 index 0000000..f467a56 --- /dev/null +++ b/daemon/internal/api/git_admission_test.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/oblien/mindwire/daemon/internal/agent" + "github.com/oblien/mindwire/daemon/internal/orchestrator" + "github.com/oblien/mindwire/daemon/internal/registry" + "github.com/oblien/mindwire/daemon/internal/workspaceexec" +) + +type gitWorkingAdapter struct { + resolveFakeAdapter + started chan struct{} +} + +func (f *gitWorkingAdapter) RunStream(ctx context.Context, _ agent.TurnInput, _ agent.Emit) (agent.TurnResult, error) { + close(f.started) + <-ctx.Done() + return agent.TurnResult{}, ctx.Err() +} + +func TestGitHTTPStageAndUnstageDuringOrdinaryWork(t *testing.T) { + for _, work := range []string{"command", "terminal", "agent"} { + t.Run(work, func(t *testing.T) { + fake := &gitWorkingAdapter{resolveFakeAdapter: resolveFakeAdapter{id: "git-stage-active-agent"}, started: make(chan struct{})} + if work == "agent" { + agent.Register(fake) + } + h, a, dir := gitHTTPFixture(t) + path := filepath.Join(dir, "tracked") + if err := os.WriteFile(path, []byte("base\n"), 0600); err != nil { + t.Fatal(err) + } + apiGit(t, "-C", dir, "add", "tracked") + apiGit(t, "-C", dir, "-c", "user.email=fixture@example.invalid", "commit", "-m", "Base") + if err := os.WriteFile(path, []byte("working changes\n"), 0600); err != nil { + t.Fatal(err) + } + switch work { + case "command": + ctx, cancel := context.WithCancel(context.Background()) + started, done := make(chan struct{}, 1), make(chan struct{}) + go func() { + defer close(done) + _, _ = a.execution.Exec(ctx, workspaceexec.ExecRequest{ + Argv: []string{"sh", "-c", "printf ready; exec sleep 60"}, Directory: dir, + }, func(_ string, _ []byte) { + select { + case started <- struct{}{}: + default: + } + }) + }() + t.Cleanup(func() { cancel(); <-done }) + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("command did not start") + } + case "terminal": + t.Setenv("SHELL", "/bin/sh") + terminal, err := a.execution.Terminals.Open(workspaceexec.TerminalRequest{ + ID: "git-stage-terminal", Directory: dir, Columns: 80, Rows: 24, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = a.execution.Terminals.Remove(terminal.ID) }) + case "agent": + adapter, ok := a.sup.Resolve(fake.ID()) + if !ok { + t.Fatal("could not resolve test adapter") + } + run, err := a.sup.StartTurnChecked(adapter, orchestrator.StartTurnInput{ChatID: "chat", Message: "Work", CWD: dir}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { a.sup.Cancel(run.ID); a.sup.Wait() }) + select { + case <-fake.started: + case <-time.After(5 * time.Second): + t.Fatal("agent did not start") + } + } + if !a.BusyPath(dir) { + t.Fatal("fixture has no active work") + } + for _, action := range []string{"stage", "unstage"} { + body := `{"id":"` + action + `","action":"` + action + `","paths":["tracked"]}` + got := serve(t, h, "POST", "/workspace/projects/project/git/operations", body) + var operation registry.GitOperation + if got.Code != 202 || json.Unmarshal(got.Body.Bytes(), &operation) != nil { + t.Fatalf("%s during %s: %d %s", action, work, got.Code, got.Body.String()) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + result, err := a.gitJobs.Wait(ctx, operation.ID) + cancel() + if err != nil || result.Status != "succeeded" { + t.Fatalf("%s result: %+v %v", action, result, err) + } + staged := apiGit(t, "-C", dir, "diff", "--cached", "--name-only") + if (action == "stage" && staged != "tracked") || (action == "unstage" && staged != "") { + t.Fatalf("%s index: %q", action, staged) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "working changes\n" { + t.Fatalf("%s changed working files: %q %v", action, data, err) + } + if !a.BusyPath(dir) { + t.Fatalf("%s interrupted active work", action) + } + } + got := serve(t, h, "POST", "/workspace/projects/project/git/operations", `{"id":"discard","action":"discard","paths":["tracked"]}`) + if got.Code != 409 || strings.Contains(got.Body.String(), "record changed") { + t.Fatalf("discard must retain a clear active-work guard: %d %s", got.Code, got.Body.String()) + } + }) + } +} + +func TestGitHTTPIndexActionsRespectProjectSyncReservation(t *testing.T) { + h, a, dir := gitHTTPFixture(t) + if err := a.registry.ReserveSync("sync", dir); err != nil { + t.Fatal(err) + } + for _, action := range []string{"stage", "unstage"} { + body := `{"id":"` + action + `","action":"` + action + `","paths":["tracked"]}` + got := serve(t, h, "POST", "/workspace/projects/project/git/operations", body) + if got.Code != 409 || !strings.Contains(got.Body.String(), "synchronization") { + t.Fatalf("%s during sync: %d %s", action, got.Code, got.Body.String()) + } + } +} diff --git a/daemon/internal/api/git_test.go b/daemon/internal/api/git_test.go index 347b7dd..c955622 100644 --- a/daemon/internal/api/git_test.go +++ b/daemon/internal/api/git_test.go @@ -195,6 +195,8 @@ func TestGitHTTPDurableMutationsAndRecovery(t *testing.T) { body := `{"id":"competing","action":"` + action + `","paths":["tracked"]}` if got := serve(t, h, "POST", "/workspace/projects/project/git/operations", body); got.Code != 409 { t.Fatalf("%s bypassed coordination: %d %s", action, got.Code, got.Body.String()) + } else if strings.Contains(got.Body.String(), "record changed") || !strings.Contains(got.Body.String(), "Git operation") { + t.Fatalf("%s has a misleading conflict: %s", action, got.Body.String()) } } if got := serve(t, h, "GET", "/workspace/projects/project/git/operations?active=true", ""); got.Code != 200 || !strings.Contains(got.Body.String(), "blocked-commit") { diff --git a/daemon/internal/api/project_library.go b/daemon/internal/api/project_library.go new file mode 100644 index 0000000..bdc8fb7 --- /dev/null +++ b/daemon/internal/api/project_library.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + + "github.com/oblien/mindwire/daemon/internal/registry" +) + +func (a *API) projectLibrary(w http.ResponseWriter, r *http.Request) { + if !a.requireRegistry(w) { + return + } + a.registryMu.Lock() + defer a.registryMu.Unlock() + if r.Method == http.MethodPatch { + var edit registry.ProjectLibraryEdit + if err := decode(w, r, &edit); err != nil { + badRequest(w, "invalid project library edit") + return + } + if err := a.registry.EditProjectLibrary(edit); err != nil { + workspaceError(w, err) + return + } + } + // No native session discovery or filesystem reads for library-only operations. + library, err := a.registry.ProjectLibrary() + if err != nil { + workspaceError(w, err) + return + } + writeJSON(w, http.StatusOK, library) +} diff --git a/daemon/internal/api/project_library_test.go b/daemon/internal/api/project_library_test.go new file mode 100644 index 0000000..d4c8aa3 --- /dev/null +++ b/daemon/internal/api/project_library_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/oblien/mindwire/daemon/internal/registry" +) + +func TestProjectLibraryHTTPAuthenticatedTwoClients(t *testing.T) { + h, _, a := newRegistryAPIEnv(t) + for _, method := range []string{"GET", "PATCH"} { + if r := serve(t, h, method, "/workspace/project-library", "{}"); r.Code != 401 { + t.Fatalf("unauthenticated %s: %d", method, r.Code) + } + } + authorized := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Authorization", "Bearer workspace-secret") + h.ServeHTTP(w, r) + }) + if err := a.registry.Import(registry.Import{Projects: []registry.Project{{Record: registry.Record{ID: "p"}, Name: "App", Path: "/work/app"}}}); err != nil { + t.Fatal(err) + } + body := `{"expectedRevision":0,"folders":[{"id":"work","name":"Work"}],"placements":[{"projectId":"p","folderId":"work"}],"order":["p"]}` + first := serve(t, authorized, "PATCH", "/workspace/project-library", body) + if first.Code != 200 { + t.Fatal(first.Code, first.Body.String()) + } + var saved registry.ProjectLibrary + if err := json.Unmarshal(first.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + second := serve(t, authorized, "GET", "/workspace/project-library?agent=claude-code", "") + if second.Code != 200 || second.Body.String() != first.Body.String() { + t.Fatal("a second client/harness did not see the shared folder") + } + if repeat := serve(t, authorized, "PATCH", "/workspace/project-library", body); repeat.Body.String() != first.Body.String() { + t.Fatal("retry duplicated an edit") + } + if conflict := serve(t, authorized, "PATCH", "/workspace/project-library", `{"expectedRevision":0,"folders":[{"id":"work","name":"Stale"}]}`); conflict.Code != 409 { + t.Fatal("stale client overwrote folder", conflict.Code) + } + cleared := serve(t, authorized, "PATCH", "/workspace/project-library", fmt.Sprintf(`{"expectedRevision":%d,"placements":[{"projectId":"p","folderId":null}]}`, saved.Revision)) + if cleared.Code != 200 { + t.Fatal(cleared.Body.String()) + } + var library registry.ProjectLibrary + if err := json.Unmarshal(cleared.Body.Bytes(), &library); err != nil { + t.Fatal(err) + } + if len(library.Membership) != 0 || !reflect.DeepEqual(library.Folders, saved.Folders) || !reflect.DeepEqual(library.Order, saved.Order) { + t.Fatal("returning to the flat list changed folder/order", library) + } +} diff --git a/daemon/internal/registry/library.go b/daemon/internal/registry/library.go new file mode 100644 index 0000000..44768cc --- /dev/null +++ b/daemon/internal/registry/library.go @@ -0,0 +1,263 @@ +package registry + +import ( + "database/sql" + "encoding/json" + "errors" + "reflect" + "slices" + "strings" + "unicode/utf8" +) + +const ProjectLibraryVersion = 1 + +type ProjectFolder struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ProjectLibrary is presentation metadata. None of its operations move files or +// touch native harness transcripts. Order and membership use this registry's project IDs. +type ProjectLibrary struct { + Version int `json:"version"` + Revision int64 `json:"revision"` + Folders []ProjectFolder `json:"folders"` + Membership map[string]string `json:"membership"` + Order []string `json:"order"` +} + +type ProjectPlacement struct { + ProjectID string `json:"projectId"` + FolderID *string `json:"folderId"` // null returns a project to the flat list +} + +// Edits are partial, atomic and conditional. Retrying an already applied edit +// succeeds without a new revision; a stale, different intent returns ErrConflict. +type ProjectLibraryEdit struct { + ExpectedRevision int64 `json:"expectedRevision"` + Folders []ProjectFolder `json:"folders,omitempty"` + DeleteFolders []string `json:"deleteFolders,omitempty"` + Placements []ProjectPlacement `json:"placements,omitempty"` + Order []string `json:"order,omitempty"` + FolderOrder []string `json:"folderOrder,omitempty"` + ImportIfEmpty bool `json:"importIfEmpty,omitempty"` +} + +func emptyLibrary() ProjectLibrary { + return ProjectLibrary{Version: ProjectLibraryVersion, Folders: []ProjectFolder{}, Membership: map[string]string{}, Order: []string{}} +} + +func readLibrary(tx *sql.Tx) (ProjectLibrary, error) { + library := emptyLibrary() + var data []byte + err := tx.QueryRow("SELECT data FROM project_library WHERE id=1").Scan(&data) + if errors.Is(err, sql.ErrNoRows) { + return library, nil + } + if err != nil { + return library, err + } + err = json.Unmarshal(data, &library) + return library, err +} + +func saveLibrary(tx *sql.Tx, library ProjectLibrary, revision int64) error { + library.Revision = revision + data, err := json.Marshal(library) + if err != nil { + return err + } + _, err = tx.Exec("INSERT INTO project_library(id,data) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data", data) + return err +} + +// ProjectLibrary reads only organization metadata, without enumerating projects +// or native conversations. It shares the registry's transaction boundary. +func (st *Store) ProjectLibrary() (ProjectLibrary, error) { + tx, err := st.db.Begin() + if err != nil { + return ProjectLibrary{}, err + } + defer tx.Rollback() + library, err := readLibrary(tx) + if err != nil { + return library, err + } + return library, tx.Commit() +} + +func libraryOrder(base, requested []string) ([]string, error) { + seen := map[string]bool{} + for _, id := range requested { + if !validID(id) || seen[id] { + return nil, invalid("duplicate or invalid ordered ID") + } + seen[id] = true + } + base = slices.Clone(base) + for _, id := range requested { + if !slices.Contains(base, id) { + base = append(base, id) + } + } + index := 0 + for i, id := range base { + if seen[id] { + base[i] = requested[index] + index++ + } + } + return base, nil +} + +func (st *Store) EditProjectLibrary(edit ProjectLibraryEdit) error { + if edit.ExpectedRevision < 0 || len(edit.Folders)+len(edit.DeleteFolders)+len(edit.Placements)+len(edit.Order)+len(edit.FolderOrder) > 20000 { + return invalid("project library edit is too large or has an invalid revision") + } + return st.write(func(tx *sql.Tx, revision int64) (bool, error) { + before, err := readLibrary(tx) + if err != nil { + return false, err + } + if edit.ImportIfEmpty && before.Revision != 0 { + return false, nil + } + next := before + next.Folders = slices.Clone(before.Folders) + next.Membership = map[string]string{} + for k, v := range before.Membership { + next.Membership[k] = v + } + deleted := map[string]bool{} + newDeletion := false + for _, id := range edit.DeleteFolders { + if !validID(id) { + return false, invalid("folder ID") + } + deleted[id] = true + var removed int + if err := tx.QueryRow("SELECT count(*) FROM project_folder_deletions WHERE id=?", id).Scan(&removed); err != nil { + return false, err + } + newDeletion = newDeletion || removed == 0 + } + next.Folders = slices.DeleteFunc(next.Folders, func(f ProjectFolder) bool { return deleted[f.ID] }) + for id, folder := range next.Membership { + if deleted[folder] { + delete(next.Membership, id) + } + } + for _, folder := range edit.Folders { + folder.Name = strings.TrimSpace(folder.Name) + if !validID(folder.ID) || deleted[folder.ID] || folder.Name == "" || utf8.RuneCountInString(folder.Name) > 80 || strings.ContainsAny(folder.Name, "\x00\r\n") { + return false, invalid("folder name or ID") + } + index := slices.IndexFunc(next.Folders, func(f ProjectFolder) bool { return f.ID == folder.ID }) + var removed int + if err := tx.QueryRow("SELECT count(*) FROM project_folder_deletions WHERE id=?", folder.ID).Scan(&removed); err != nil { + return false, err + } + if removed > 0 { + return false, ErrDeleted + } + if index >= 0 { + next.Folders[index] = folder + } else { + next.Folders = append(next.Folders, folder) + } + } + folders := map[string]ProjectFolder{} + for i, folder := range next.Folders { + for _, other := range next.Folders[:i] { + if strings.EqualFold(folder.Name, other.Name) { + return false, invalid("a folder with this name already exists") + } + } + folders[folder.ID] = folder + } + for _, placement := range edit.Placements { + if !validID(placement.ProjectID) { + return false, invalid("project ID") + } + if placement.FolderID == nil { + delete(next.Membership, placement.ProjectID) + continue + } + if _, exists := folders[*placement.FolderID]; !exists { + return false, invalid("folder no longer exists") + } + project, _, err := record(tx, "projects", placement.ProjectID) + if err != nil { + return false, err + } + if project == nil { + return false, invalid("project no longer exists") + } + next.Membership[placement.ProjectID] = *placement.FolderID + } + for _, id := range edit.Order { + project, _, err := record(tx, "projects", id) + if err != nil { + return false, err + } + if project == nil { + return false, invalid("ordered project no longer exists") + } + } + if len(edit.Order) > 0 { + next.Order, err = libraryOrder(before.Order, edit.Order) + if err != nil { + return false, err + } + } + if len(edit.FolderOrder) > 0 { + ids := make([]string, 0, len(next.Folders)) + for _, f := range next.Folders { + ids = append(ids, f.ID) + } + for _, id := range edit.FolderOrder { + if _, ok := folders[id]; !ok { + return false, invalid("ordered folder no longer exists") + } + } + ids, err = libraryOrder(ids, edit.FolderOrder) + if err != nil { + return false, err + } + next.Folders = make([]ProjectFolder, 0, len(ids)) + for _, id := range ids { + next.Folders = append(next.Folders, folders[id]) + } + } + if len(next.Folders) > 10000 { + return false, invalid("too many project folders") + } + if reflect.DeepEqual(before, next) && !newDeletion { + return false, nil + } + if edit.ExpectedRevision != before.Revision { + return false, ErrConflict + } + for id := range deleted { + if _, err := tx.Exec("INSERT OR IGNORE INTO project_folder_deletions(id) VALUES(?)", id); err != nil { + return false, err + } + } + return true, saveLibrary(tx, next, revision) + }) +} + +func removeProjectPlacement(tx *sql.Tx, id string, revision int64) error { + library, err := readLibrary(tx) + if err != nil { + return err + } + _, member := library.Membership[id] + if !member && !slices.Contains(library.Order, id) { + return nil + } + delete(library.Membership, id) + library.Order = slices.DeleteFunc(library.Order, func(value string) bool { return value == id }) + return saveLibrary(tx, library, revision) +} diff --git a/daemon/internal/registry/library_test.go b/daemon/internal/registry/library_test.go new file mode 100644 index 0000000..38803d5 --- /dev/null +++ b/daemon/internal/registry/library_test.go @@ -0,0 +1,214 @@ +package registry + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "sync" + "sync/atomic" + "testing" +) + +func librarySnapshot(t *testing.T, st *Store) ProjectLibrary { + t.Helper() + library, err := st.ProjectLibrary() + if err != nil { + t.Fatal(err) + } + return library +} + +func TestProjectLibrarySurvivesRestartAndOnlyChangesOrganization(t *testing.T) { + path := filepath.Join(t.TempDir(), "workspace.db") + st, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { st.Close() }() + if err := st.Import(fixture()); err != nil { + t.Fatal(err) + } + before := snapshot(t, st, nil) + if before.ProjectLibrary == nil || before.ProjectLibrary.Version != 1 || before.ProjectLibrary.Revision != 0 { + t.Fatal("full snapshots must expose the empty library for feature discovery") + } + if snapshot(t, st, &before.Revision).ProjectLibrary != nil { + t.Fatal("unchanged library must not be repeated in every delta") + } + folder := "work" + edit := ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Work"}}, + Placements: []ProjectPlacement{{ProjectID: "project", FolderID: &folder}}, Order: []string{"project"}} + if err := st.EditProjectLibrary(edit); err != nil { + t.Fatal(err) + } + after := snapshot(t, st, nil) + if !reflect.DeepEqual(before.Import, after.Import) || len(after.Deleted) != 0 { + t.Fatal("organizing projects changed their records or conversations") + } + delta := snapshot(t, st, &before.Revision) + if delta.ProjectLibrary == nil || len(delta.Projects)+len(delta.Agents)+len(delta.Chats) != 0 { + t.Fatalf("library-only delta: %+v", delta) + } + if err := st.EditProjectLibrary(edit); err != nil || snapshot(t, st, nil).Revision != after.Revision { + t.Fatalf("retry after a lost acknowledgement was not idempotent: %v", err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = Open(path) + if err != nil { + t.Fatal(err) + } + if got := librarySnapshot(t, st); !reflect.DeepEqual(got, *after.ProjectLibrary) { + t.Fatalf("restart lost organization: %+v", got) + } +} + +func TestProjectLibraryConcurrentClientsAndConditionalRetry(t *testing.T) { + st := openTest(t) + var winners, conflicts atomic.Int32 + var wg sync.WaitGroup + for _, name := range []string{"First", "Second"} { + wg.Add(1) + go func(name string) { + defer wg.Done() + err := st.EditProjectLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{{ID: name, Name: name}}}) + if err == nil { + winners.Add(1) + } else if errors.Is(err, ErrConflict) { + conflicts.Add(1) + } else { + t.Errorf("edit: %v", err) + } + }(name) + } + wg.Wait() + if winners.Load() != 1 || conflicts.Load() != 1 { + t.Fatalf("winners=%d conflicts=%d", winners.Load(), conflicts.Load()) + } + before := librarySnapshot(t, st) + folder := before.Folders[0] + folder.Name = "Renamed" + if err := st.EditProjectLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{folder}}); !errors.Is(err, ErrConflict) { + t.Fatalf("stale edit overwrote another device: %v", err) + } + if err := st.EditProjectLibrary(ProjectLibraryEdit{ExpectedRevision: before.Revision, Folders: []ProjectFolder{folder}}); err != nil { + t.Fatal(err) + } +} + +func TestProjectLibraryInvalidBatchRollsBackEveryPart(t *testing.T) { + st := openTest(t) + if err := st.Import(fixture()); err != nil { + t.Fatal(err) + } + before := snapshot(t, st, nil) + id := "folder" + for _, edit := range []ProjectLibraryEdit{ + {Folders: []ProjectFolder{{ID: id, Name: "Work"}}, Placements: []ProjectPlacement{{ProjectID: "missing", FolderID: &id}}}, + {Folders: []ProjectFolder{{ID: id, Name: "Work"}}, Order: []string{"missing"}}, + {DeleteFolders: []string{id}, Folders: []ProjectFolder{{ID: id, Name: "Conflicting intent"}}}, + {Folders: []ProjectFolder{{ID: id, Name: "Work"}, {ID: "duplicate", Name: "work"}}}, + {Folders: []ProjectFolder{{ID: id, Name: "Work"}}, FolderOrder: []string{"missing"}}, + } { + if err := st.EditProjectLibrary(edit); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid batch accepted: %+v %v", edit, err) + } + if after := snapshot(t, st, nil); !reflect.DeepEqual(before, after) { + t.Fatal("an invalid batch partially committed") + } + } + // A rolled-back deletion must not leave an invisible tombstone. + if err := st.EditProjectLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{{ID: id, Name: "Work"}}}); err != nil { + t.Fatal(err) + } +} + +func TestProjectLibraryScopedOrderingPreservesOtherSlots(t *testing.T) { + st := openTest(t) + batch := Import{} + for _, id := range []string{"a", "b", "c", "d"} { + batch.Projects = append(batch.Projects, Project{Record: Record{ID: id}, Name: id, Path: "/work/" + id}) + } + if err := st.Import(batch); err != nil { + t.Fatal(err) + } + if err := st.EditProjectLibrary(ProjectLibraryEdit{Order: []string{"a", "b", "c", "d"}, + Folders: []ProjectFolder{{ID: "one", Name: "One"}, {ID: "two", Name: "Two"}, {ID: "three", Name: "Three"}}}); err != nil { + t.Fatal(err) + } + before := librarySnapshot(t, st) + if err := st.EditProjectLibrary(ProjectLibraryEdit{ExpectedRevision: before.Revision, + Order: []string{"c", "a"}, FolderOrder: []string{"three", "one"}}); err != nil { + t.Fatal(err) + } + after := librarySnapshot(t, st) + if !reflect.DeepEqual(after.Order, []string{"c", "b", "a", "d"}) || after.Folders[1].ID != "two" || after.Folders[0].ID != "three" { + t.Fatalf("scoped reorder moved unrelated projects/folders: %+v", after) + } +} + +func TestProjectFolderDeletionKeepsFilesChatsAndBlocksResurrection(t *testing.T) { + st := openTest(t) + batch := fixture() + batch.Projects[0].Path = t.TempDir() + file := filepath.Join(batch.Projects[0].Path, "keep.txt") + if err := os.WriteFile(file, []byte("keep my work"), 0600); err != nil { + t.Fatal(err) + } + if err := st.Import(batch); err != nil { + t.Fatal(err) + } + folder := "work" + create := ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Work"}}, + Placements: []ProjectPlacement{{ProjectID: "project", FolderID: &folder}}, Order: []string{"project"}} + if err := st.EditProjectLibrary(create); err != nil { + t.Fatal(err) + } + before := snapshot(t, st, nil) + remove := ProjectLibraryEdit{ExpectedRevision: before.ProjectLibrary.Revision, DeleteFolders: []string{folder}} + if err := st.EditProjectLibrary(remove); err != nil { + t.Fatal(err) + } + after := snapshot(t, st, nil) + if len(after.ProjectLibrary.Folders)+len(after.ProjectLibrary.Membership) != 0 || len(after.ProjectLibrary.Order) != 1 || !reflect.DeepEqual(after.Import, before.Import) { + t.Fatalf("folder deletion touched its projects: %+v", after) + } + if data, err := os.ReadFile(file); err != nil || string(data) != "keep my work" { + t.Fatal("folder deletion changed files") + } + if err := st.EditProjectLibrary(remove); err != nil || snapshot(t, st, nil).Revision != after.Revision { + t.Fatal("repeated deletion must be a no-op", err) + } + create.ExpectedRevision = after.Revision + if err := st.EditProjectLibrary(create); !errors.Is(err, ErrDeleted) { + t.Fatalf("offline client resurrected deleted folder: %v", err) + } + if err := st.Delete("projects", "project", &after.Projects[0].Revision, false); err != nil { + t.Fatal(err) + } + delta := snapshot(t, st, &after.Revision) + if delta.ProjectLibrary == nil || len(delta.ProjectLibrary.Order) != 0 || len(delta.Deleted) != 2 { + t.Fatalf("project deletion left library references: %+v", delta) + } +} + +func TestLibraryMigrationCannotReplaceExistingOrganizationOrDeletedFolder(t *testing.T) { + st := openTest(t) + if err := st.EditProjectLibrary(ProjectLibraryEdit{DeleteFolders: []string{"offline"}}); err != nil { + t.Fatal(err) + } + before := librarySnapshot(t, st) + if err := st.EditProjectLibrary(ProjectLibraryEdit{ExpectedRevision: before.Revision, + Folders: []ProjectFolder{{ID: "offline", Name: "Never uploaded"}}}); !errors.Is(err, ErrDeleted) { + t.Fatalf("deleting an unacknowledged folder did not suppress its delayed upload: %v", err) + } + if err := st.EditProjectLibrary(ProjectLibraryEdit{ImportIfEmpty: true, + Folders: []ProjectFolder{{ID: "stale", Name: "Local copy"}}}); err != nil { + t.Fatal(err) + } + if got := librarySnapshot(t, st); !reflect.DeepEqual(got, before) { + t.Fatal("local-only migration overwrote another client's organization") + } +} diff --git a/daemon/internal/registry/store.go b/daemon/internal/registry/store.go index bed61d8..4ae2f38 100644 --- a/daemon/internal/registry/store.go +++ b/daemon/internal/registry/store.go @@ -26,7 +26,7 @@ import ( const Version = 1 const NotificationPreferencesVersion = 1 -const schemaVersion = 6 +const schemaVersion = 7 var ( ErrConflict = errors.New("record changed on another client; refresh and try again") @@ -94,6 +94,7 @@ type Snapshot struct { Full bool `json:"full"` Deleted []Deletion `json:"deleted"` SessionDiscoveryIssues []SessionDiscoveryIssue `json:"sessionDiscoveryIssues,omitempty"` + ProjectLibrary *ProjectLibrary `json:"projectLibrary,omitempty"` } type Store struct { @@ -181,7 +182,9 @@ CREATE INDEX IF NOT EXISTS native_chat_links_chat ON native_chat_links(chat_id); CREATE TABLE IF NOT EXISTS project_sync_locks (id TEXT PRIMARY KEY, path TEXT NOT NULL); CREATE TABLE IF NOT EXISTS project_sync_records (kind TEXT NOT NULL, id TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY(kind,id)); -PRAGMA user_version=6;`); err != nil { +CREATE TABLE IF NOT EXISTS project_library (id INTEGER PRIMARY KEY CHECK(id=1), data BLOB NOT NULL); +CREATE TABLE IF NOT EXISTS project_folder_deletions (id TEXT PRIMARY KEY); +PRAGMA user_version=7;`); err != nil { return err } identity := make([]byte, 16) @@ -639,6 +642,11 @@ func deleteRecord(tx *sql.Tx, kind, id string, expected *int64, force bool, revi if _, err = tx.Exec("DELETE FROM "+kind+" WHERE id=?", id); err != nil { return false, err } + if kind == "projects" { + if err := removeProjectPlacement(tx, id, revision); err != nil { + return false, err + } + } _, err = tx.Exec("INSERT INTO deleted VALUES (?,?,?)", kind, id, revision) return true, err } @@ -721,6 +729,13 @@ func (st *Store) Snapshot(since *int64) (Snapshot, error) { if err != nil { return s, err } + library, err := readLibrary(tx) + if err != nil { + return s, err + } + if library.Revision > minimum { + s.ProjectLibrary = &library + } return s, tx.Commit() } diff --git a/daemon/internal/surface/binding.go b/daemon/internal/surface/binding.go index eb87dea..64a6a8e 100644 --- a/daemon/internal/surface/binding.go +++ b/daemon/internal/surface/binding.go @@ -99,11 +99,13 @@ func (s *Service) Bind(binding Binding, credentials Credentials) error { s.mu.Lock() s.provider = provider s.snapshot.Geometry = nil + s.snapshot.Cursor = nil s.snapshot.Error = nil s.snapshot.State = "disconnected" expires := provider.ExpiresAt() s.snapshot.AuthorizationExpiresAt = &expires s.signalLocked() s.mu.Unlock() + s.observeProviderCursor(provider) return nil } diff --git a/daemon/internal/surface/catalog.go b/daemon/internal/surface/catalog.go index 8b3b00b..24b2d5e 100644 --- a/daemon/internal/surface/catalog.go +++ b/daemon/internal/surface/catalog.go @@ -281,6 +281,7 @@ func (s *Service) ForDesktop(id string) (*Service, error) { 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"} + child.observeProviderCursor(p) expiry := p.ExpiresAt() if !expiry.IsZero() { child.snapshot.AuthorizationExpiresAt = &expiry diff --git a/daemon/internal/surface/cursor.go b/daemon/internal/surface/cursor.go new file mode 100644 index 0000000..ff42676 --- /dev/null +++ b/daemon/internal/surface/cursor.go @@ -0,0 +1,132 @@ +package surface + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "image" + "image/png" + "io" + + vnc "github.com/kward/go-vnc" + "github.com/kward/go-vnc/encodings" +) + +// Cursor is a small, immutable remote cursor image, not a screenshot. Its ID +// changes only when the pixels or hotspot change; moving it sends no new image. +type Cursor struct { + ID string `json:"id"` + Width int `json:"width"` + Height int `json:"height"` + HotspotX int `json:"hotspotX"` + HotspotY int `json:"hotspotY"` + PNG string `json:"png"` +} + +const cursorEncodingType encodings.Encoding = -239 // RFB RichCursor +const maxCursorSize = 256 + +type cursorEncoding struct { + reader *frameReader + cursor *Cursor +} + +func (*cursorEncoding) Type() encodings.Encoding { return cursorEncodingType } +func (*cursorEncoding) String() string { return "RichCursor" } +func (*cursorEncoding) Marshal() ([]byte, error) { return nil, nil } +func (e *cursorEncoding) Read(_ *vnc.ClientConn, rect *vnc.Rectangle) (vnc.Encoding, error) { + w, h := int(rect.Width), int(rect.Height) + if w > maxCursorSize || h > maxCursorSize || (w > 0 && int(rect.X) >= w) || (h > 0 && int(rect.Y) >= h) { + return nil, errors.New("RFB cursor exceeds limit") + } + if w == 0 || h == 0 { + // TigerVNC sends an empty cursor when another connection moves the mouse + // and starts rendering it into that viewer's pixels. Keep our last shape. + return &cursorEncoding{}, nil + } + if w*h > e.reader.remainingPixels { + return nil, errors.New("RFB cursor exceeds frame limit") + } + e.reader.remainingPixels -= w * h + rowBytes := (w + 7) / 8 + data := make([]byte, w*h*4+rowBytes*h) + if _, err := io.ReadFull(e.reader.conn, data); err != nil { + return nil, err + } + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + mask := data[w*h*4:] + visible := false + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if mask[y*rowBytes+x/8]&(0x80>>uint(x%8)) == 0 { + continue + } + i := (y*w + x) * 4 + img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = data[i+2], data[i+1], data[i], 255 + visible = true + } + } + if !visible { + return &cursorEncoding{}, nil + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, img); err != nil { + return nil, err + } + hash := sha256.New() + hash.Write(encoded.Bytes()) + hash.Write([]byte{byte(rect.X), byte(rect.Y)}) + return &cursorEncoding{cursor: &Cursor{ + ID: hex.EncodeToString(hash.Sum(nil)), Width: w, Height: h, + HotspotX: int(rect.X), HotspotY: int(rect.Y), PNG: base64.StdEncoding.EncodeToString(encoded.Bytes()), + }}, nil +} + +func (r *rfbClient) setCursorObserver(fn func(*Cursor)) { + r.mu.Lock() + r.onCursor = fn + cursor := r.cursor + r.mu.Unlock() + if fn != nil && cursor != nil { + fn(cursor) + } + r.wakeUpdates() +} + +// The input connection is authoritative: TigerVNC can suppress cursor images +// on a separate video-only connection after the controller moves the pointer. +func (p *Oblien) SetCursorObserver(fn func(*Cursor)) { + p.mu.Lock() + p.onCursor = fn + client := p.client + p.mu.Unlock() + if client != nil { + client.setCursorObserver(fn) + } +} + +func (p *MacDesktop) SetCursorObserver(fn func(*Cursor)) { + p.mu.Lock() + p.onCursor = fn + client := p.client + p.mu.Unlock() + if client != nil { + client.setCursorObserver(fn) + } +} + +func (s *Service) observeProviderCursor(p Provider) { + if source, ok := p.(interface{ SetCursorObserver(func(*Cursor)) }); ok { + source.SetCursorObserver(func(cursor *Cursor) { + s.mu.Lock() + defer s.mu.Unlock() + if s.provider != p || cursor == nil || (s.snapshot.Cursor != nil && s.snapshot.Cursor.ID == cursor.ID) { + return + } + s.snapshot.Cursor = cursor + s.signalLocked() + }) + } +} diff --git a/daemon/internal/surface/cursor_test.go b/daemon/internal/surface/cursor_test.go new file mode 100644 index 0000000..acad3f8 --- /dev/null +++ b/daemon/internal/surface/cursor_test.go @@ -0,0 +1,122 @@ +package surface + +import ( + "bytes" + "encoding/base64" + "image" + "image/png" + "net" + "testing" + + vnc "github.com/kward/go-vnc" +) + +func TestRichCursorPreservesColorTransparencyAndHotspot(t *testing.T) { + local, remote := net.Pipe() + defer local.Close() + defer remote.Close() + go func() { + // Two BGRA pixels, followed by a mask exposing only the first. + _, _ = remote.Write([]byte{40, 30, 200, 0, 2, 3, 4, 0, 0x80}) + }() + reader := &cursorEncoding{reader: &frameReader{conn: local, remainingPixels: 100}} + decoded, err := reader.Read(nil, &vnc.Rectangle{X: 1, Width: 2, Height: 1}) + if err != nil { + t.Fatal(err) + } + cursor := decoded.(*cursorEncoding).cursor + if cursor == nil || cursor.Width != 2 || cursor.Height != 1 || cursor.HotspotX != 1 || cursor.HotspotY != 0 { + t.Fatalf("missing cursor geometry: %+v", cursor) + } + data, err := base64.StdEncoding.DecodeString(cursor.PNG) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + r, g, b, a := img.At(0, 0).RGBA() + if r != 200*257 || g != 30*257 || b != 40*257 || a != 65535 { + t.Fatal("cursor colors did not follow the negotiated wire format") + } + _, _, _, a = img.At(1, 0).RGBA() + if a != 0 { + t.Fatal("cursor transparency mask was lost") + } + for _, rect := range []vnc.Rectangle{{Width: 257, Height: 1}, {Width: 2, Height: 1, X: 2}, {Width: 256, Height: 256}} { + if _, err := reader.Read(nil, &rect); err == nil { + t.Fatal("unsafe cursor dimensions or frame allocation accepted") + } + } +} + +func TestCursorReplyCannotAcknowledgeCaptureAndDuplicateShapesDoNotEmit(t *testing.T) { + o := &frameObservation{full: true} + o.reset(Geometry{130, 3, 1}) + o.sent = true + client := &rfbClient{frame: image.NewRGBA(image.Rect(0, 0, 130, 3)), size: o.size, + observation: o, changed: make(chan struct{}), done: make(chan struct{})} + updates := 0 + client.onCursor = func(*Cursor) { updates++ } + for range 2 { + messages := make(chan vnc.ServerMessage, 1) + messages <- &vnc.FramebufferUpdate{Rects: []vnc.Rectangle{{Enc: &cursorEncoding{cursor: &Cursor{ID: "text"}}}}} + close(messages) + client.receive(messages) + } + if o.remaining != 390 || !client.observedAt.IsZero() { + t.Fatal("a cursor update acknowledged screenshot pixels or fresh geometry") + } + if updates != 1 { + t.Fatalf("unchanged cursor emitted %d updates", updates) + } + if o.sent { + t.Fatal("cursor-only reply did not rearm the missing screenshot request") + } + o.sent = true + for _, rect := range []image.Rectangle{image.Rect(0, 0, 65, 3), image.Rect(64, 0, 130, 2), image.Rect(0, 0, 100, 3)} { + o.cover(rect) + } + if o.remaining != 30 { + t.Fatalf("overlapping tiles falsely acknowledged missing pixels: %d", o.remaining) + } + o.cover(image.Rect(100, 2, 130, 3)) + if o.remaining != 0 { + t.Fatal("complete tiled image was not acknowledged") + } +} + +type cursorProvider struct { + Provider + onCursor func(*Cursor) +} + +func (p *cursorProvider) SetCursorObserver(fn func(*Cursor)) { p.onCursor = fn } + +func TestCursorObserverRejectsReplacedProviderAndSnapshotsAreImmutable(t *testing.T) { + s, p, _ := testService(t) + defer s.Close() + first := &cursorProvider{Provider: p} + if err := s.Configure(first); err != nil { + t.Fatal(err) + } + first.onCursor(&Cursor{ID: "first"}) + copy := s.Snapshot() + copy.Cursor.ID = "mutated copy" + if s.Snapshot().Cursor.ID != "first" { + t.Fatal("a caller mutated the shared cursor") + } + second := &cursorProvider{Provider: p} + if err := s.Configure(second); err != nil { + t.Fatal(err) + } + first.onCursor(&Cursor{ID: "late"}) + if s.Snapshot().Cursor != nil { + t.Fatal("old desktop supplied the replacement's cursor") + } + second.onCursor(&Cursor{ID: "current"}) + if s.Snapshot().Cursor.ID != "current" { + t.Fatal("current desktop cursor was not forwarded") + } +} diff --git a/daemon/internal/surface/mac.go b/daemon/internal/surface/mac.go index 5df9703..8388aaa 100644 --- a/daemon/internal/surface/mac.go +++ b/daemon/internal/surface/mac.go @@ -48,6 +48,7 @@ type MacDesktop struct { settings LocalDesktopSettings username string client *rfbClient + onCursor func(*Cursor) inputDelay time.Duration inputReadyAt time.Time dial func(context.Context) (net.Conn, error) @@ -178,6 +179,7 @@ func (p *MacDesktop) Connect(ctx context.Context) (Geometry, error) { return Geometry{}, macConnectionError(ctx, err) } p.client = client + client.setCursorObserver(p.onCursor) // Screen Sharing can deliver frames before its input session is ready. macOS // silently discards early events, so delay only initial input, not the video. p.inputReadyAt = time.Now().Add(p.inputDelay) @@ -221,6 +223,16 @@ func (p *MacDesktop) Capture(ctx context.Context) (image.Image, Geometry, error) return client.capture(ctx) } +func (p *MacDesktop) InputGeometry(ctx context.Context, action Action) (Geometry, error) { + p.mu.Lock() + client := p.client + p.mu.Unlock() + if client == nil || !client.alive() { + return p.Connect(ctx) + } + return client.inputGeometry(ctx, action) +} + func (p *MacDesktop) PrepareControl(ctx context.Context) error { p.mu.Lock() client, readyAt := p.client, p.inputReadyAt diff --git a/daemon/internal/surface/native_fixture_test.go b/daemon/internal/surface/native_fixture_test.go index 9bd183a..e75e0b5 100644 --- a/daemon/internal/surface/native_fixture_test.go +++ b/daemon/internal/surface/native_fixture_test.go @@ -29,6 +29,22 @@ func registerDesktopFixtureRoutes(mux *http.ServeMux, s *Service) { respond(w, s.Snapshot(), nil) } }) + mux.HandleFunc("GET /surfaces/desktop/events", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + for { + changed := s.Changes() + data, _ := json.Marshal(s.Snapshot()) + if _, err := w.Write(append(append([]byte("event: surface\ndata: "), data...), []byte("\n\n")...)); err != nil { + return + } + w.(http.Flusher).Flush() + select { + case <-r.Context().Done(): + return + case <-changed: + } + } + }) mux.HandleFunc("POST /surfaces/desktop/sessions", func(w http.ResponseWriter, r *http.Request) { var req OpenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/daemon/internal/surface/oblien.go b/daemon/internal/surface/oblien.go index e0b6e8c..7a0cead 100644 --- a/daemon/internal/surface/oblien.go +++ b/daemon/internal/surface/oblien.go @@ -47,13 +47,14 @@ type VNCSettings struct { } type Oblien struct { - mu sync.Mutex - binding Binding - http *http.Client - base string - client *rfbClient - status ProviderStatus - target string + mu sync.Mutex + binding Binding + http *http.Client + base string + client *rfbClient + onCursor func(*Cursor) + status ProviderStatus + target string } func NewOblien(binding Binding) (*Oblien, error) { @@ -253,7 +254,9 @@ func (p *Oblien) Connect(ctx context.Context) (Geometry, error) { } p.mu.Lock() p.client = rfb + onCursor := p.onCursor p.mu.Unlock() + rfb.setCursorObserver(onCursor) return rfb.geometry(), nil } func (p *Oblien) Capture(ctx context.Context) (image.Image, Geometry, error) { @@ -265,6 +268,19 @@ func (p *Oblien) Capture(ctx context.Context) (image.Image, Geometry, error) { p.mu.Unlock() return client.capture(ctx) } + +func (p *Oblien) InputGeometry(ctx context.Context, action Action) (Geometry, error) { + if err := p.expired(); err != nil { + return Geometry{}, err + } + p.mu.Lock() + client := p.client + p.mu.Unlock() + if client == nil || !client.alive() { + return p.Connect(ctx) + } + return client.inputGeometry(ctx, action) +} func (p *Oblien) Apply(ctx context.Context, a Action) (string, error) { // Service.Apply refreshes geometry before spatial validation. Reusing the live // connection here avoids a second frame round-trip for every pointer move, and diff --git a/daemon/internal/surface/rfb.go b/daemon/internal/surface/rfb.go index 20172a3..13a65df 100644 --- a/daemon/internal/surface/rfb.go +++ b/daemon/internal/surface/rfb.go @@ -99,19 +99,26 @@ func (m *boundedClipboard) Read(*vnc.ClientConn) (vnc.ServerMessage, error) { } type rfbClient struct { - write sync.Mutex - mu sync.Mutex - conn net.Conn - client *vnc.ClientConn - frame *image.RGBA - size Geometry - sequence int64 - changed chan struct{} - done chan struct{} - closeOnce sync.Once - pressed map[keys.Key]bool - x, y uint16 - mask buttons.Button + observeLock sync.Mutex + write sync.Mutex + mu sync.Mutex + conn net.Conn + client *vnc.ClientConn + frame *image.RGBA + size Geometry + sequence int64 + observedAt time.Time + changed chan struct{} + observation *frameObservation + updateWake chan struct{} + cursorPending bool + cursor *Cursor + onCursor func(*Cursor) + done chan struct{} + closeOnce sync.Once + pressed map[keys.Key]bool + x, y uint16 + mask buttons.Button } func newRFB(ctx context.Context, conn net.Conn) (*rfbClient, error) { @@ -157,14 +164,15 @@ func newRFBWithAuth(ctx context.Context, conn net.Conn, auth []vnc.ClientAuth) ( conn.Close() return nil, err } - if err := client.SetEncodings(vnc.Encodings{&boundedRaw{reader: reader}, &vnc.DesktopSizePseudoEncoding{}}); err != nil { + if err := client.SetEncodings(vnc.Encodings{&boundedRaw{reader: reader}, &vnc.DesktopSizePseudoEncoding{}, &cursorEncoding{reader: reader}}); err != nil { conn.Close() return nil, err } r := &rfbClient{conn: conn, client: client, frame: image.NewRGBA(image.Rect(0, 0, width, height)), size: Geometry{width, height, time.Now().UnixMilli()}, - changed: make(chan struct{}), done: make(chan struct{}), pressed: map[keys.Key]bool{}} + changed: make(chan struct{}), done: make(chan struct{}), updateWake: make(chan struct{}, 1), pressed: map[keys.Key]bool{}} go func() { _ = client.ListenAndHandle(); r.close(); close(cfg.ServerMessageCh) }() go r.receive(cfg.ServerMessageCh) + go r.requestUpdates() return r, nil } func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) { @@ -179,7 +187,17 @@ func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) { } r.mu.Lock() invalid := false + var cursorChanged *Cursor + pixelsChanged := false + r.cursorPending = false for _, rect := range update.Rects { + if shape, ok := rect.Enc.(*cursorEncoding); ok { + if shape.cursor != nil && (r.cursor == nil || r.cursor.ID != shape.cursor.ID) { + r.cursor = shape.cursor + cursorChanged = shape.cursor + } + continue + } if rect.Enc.Type() == encodings.DesktopSizePseudo { w, h := int(rect.Width), int(rect.Height) if w < 1 || h < 1 || w*h > 16<<20 { @@ -191,6 +209,9 @@ func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) { if w != r.size.Width || h != r.size.Height { r.size = Geometry{w, h, r.size.Revision + 1} r.frame = image.NewRGBA(image.Rect(0, 0, w, h)) + if r.observation != nil { + r.observation.reset(r.size) + } } continue } @@ -211,13 +232,32 @@ func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) { r.frame.Pix[offset+2] = byte(c.B) r.frame.Pix[offset+3] = 255 } + pixelsChanged = true + if r.observation != nil { + r.observation.cover(image.Rect(x, y, x+w, y+h)) + } } r.sequence++ + if r.observation != nil && r.observation.remaining > 0 { + // RFB may satisfy/merge a pending update with only a cursor or a + // partial region. Request the still-missing pixels again; waiting + // for an unsolicited second reply would stall a static desktop. + r.observation.sent = false + } + if pixelsChanged { + r.observedAt = time.Now() + } close(r.changed) r.changed = make(chan struct{}) + onCursor := r.onCursor r.mu.Unlock() if invalid { r.close() + } else { + if cursorChanged != nil && onCursor != nil { + onCursor(cursorChanged) + } + r.wakeUpdates() } } } @@ -240,44 +280,48 @@ func (r *rfbClient) refreshGeometry(ctx context.Context) (Geometry, error) { return geometry, err } -// A one-pixel nonincremental request also elicits pending DesktopSize updates. -// Validate pointer geometry without transferring a second full viewer stream. -func (r *rfbClient) requestFrame(size Geometry, full bool) error { - width, height := uint16(1), uint16(1) - if full { - width, height = uint16(size.Width), uint16(size.Height) - } +// Repeated human pointer motion uses the geometry already observed on this +// connection. Button transitions and clicks still reconcile synchronously; +// motion rechecks at most every 250ms, with no timer/work while idle. Agent +// actions continue to validate every observation through Connect/Capture. +func (r *rfbClient) inputGeometry(ctx context.Context, action Action) (Geometry, error) { r.write.Lock() - defer r.write.Unlock() - return r.client.FramebufferUpdateRequest(false, 0, 0, width, height) + continuing := action.Kind == "pointer" && int(r.mask) == action.Buttons + r.write.Unlock() + r.mu.Lock() + size, observedAt := r.size, r.observedAt + r.mu.Unlock() + if continuing && r.alive() && !observedAt.IsZero() && time.Since(observedAt) < 250*time.Millisecond { + return size, nil + } + return r.refreshGeometry(ctx) } + func (r *rfbClient) observe(ctx context.Context, full bool) (image.Image, Geometry, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() + r.observeLock.Lock() + defer r.observeLock.Unlock() + if err := ctx.Err(); err != nil { + return nil, Geometry{}, err + } stop := context.AfterFunc(ctx, r.close) defer stop() + o := &frameObservation{full: full} r.mu.Lock() - sequence := r.sequence - size := r.size + o.reset(r.size) + r.observation = o r.mu.Unlock() - err := r.requestFrame(size, full) - if err != nil { - r.close() - return nil, Geometry{}, err - } + r.wakeUpdates() + defer func() { + r.mu.Lock() + r.observation = nil + r.mu.Unlock() + r.wakeUpdates() + }() for { r.mu.Lock() - if r.size.Revision != size.Revision { - size = r.size - sequence = r.sequence - r.mu.Unlock() - err := r.requestFrame(size, full) - if err != nil { - return nil, Geometry{}, err - } - continue - } - if r.sequence > sequence { + if o.remaining == 0 && r.alive() { if !full { g := r.size r.mu.Unlock() diff --git a/daemon/internal/surface/rfb_test.go b/daemon/internal/surface/rfb_test.go index 11bb894..c28db4f 100644 --- a/daemon/internal/surface/rfb_test.go +++ b/daemon/internal/surface/rfb_test.go @@ -25,16 +25,19 @@ type wireInput struct { data []byte } type rfbFixture struct { - mu sync.Mutex - inputs []wireInput - resize bool - largeFrame bool - tiledFrame bool - debug bool - frameRequests int - formats16 int - formats24 int - authentication func(io.ReadWriteCloser) bool + mu sync.Mutex + inputs []wireInput + resize bool + largeFrame bool + tiledFrame bool + debug bool + frameRequests int + formats16 int + formats24 int + authentication func(io.ReadWriteCloser) bool + cursorBeforeFrame bool + cursorSent chan struct{} + continueFrame <-chan struct{} } func (f *rfbFixture) serve(conn io.ReadWriteCloser) { @@ -77,6 +80,7 @@ func (f *rfbFixture) serve(conn io.ReadWriteCloser) { } resized := false sentPixels := false + sentCursor := false pixel := []byte{40, 30, 200, 0} debugMessages := 0 for { @@ -126,6 +130,27 @@ func (f *rfbFixture) serve(conn io.ReadWriteCloser) { if err != nil { return } + if f.cursorBeforeFrame { + if request[0] != 0 { + continue // An idle incremental cursor request stays pending. + } + if !sentCursor { + var cursor bytes.Buffer + cursor.Write([]byte{0, 0, 0, 1}) + for _, value := range []uint16{1, 0, 2, 2} { + _ = binary.Write(&cursor, binary.BigEndian, value) + } + _ = binary.Write(&cursor, binary.BigEndian, int32(cursorEncodingType)) + cursor.Write(bytes.Repeat([]byte{255, 255, 255, 0}, 4)) + cursor.Write([]byte{0xc0, 0xc0}) + if _, err := conn.Write(cursor.Bytes()); err != nil { + return + } + close(f.cursorSent) + <-f.continueFrame + sentCursor = true + } + } if f.largeFrame && sentPixels && request[0] != 0 { // Keep the synthetic desktop still after its first complete frame. time.Sleep(50 * time.Millisecond) @@ -207,6 +232,58 @@ func (f *rfbFixture) serve(conn io.ReadWriteCloser) { } } +func TestRFBCursorWatchSharesConnectionWithoutAcknowledgingAnOldFrame(t *testing.T) { + allowFrame := make(chan struct{}) + fixture := &rfbFixture{cursorBeforeFrame: true, cursorSent: make(chan struct{}), continueFrame: allowFrame} + local, remote := net.Pipe() + defer remote.Close() + go fixture.serve(remote) + client, err := newRFB(t.Context(), local) + if err != nil { + t.Fatal(err) + } + defer client.close() + cursors := make(chan *Cursor, 1) + client.setCursorObserver(func(cursor *Cursor) { cursors <- cursor }) + completed := make(chan error, 1) + go func() { + img, _, err := client.capture(t.Context()) + if err == nil && img.Bounds() != image.Rect(0, 0, 2, 2) { + err = fmt.Errorf("incomplete screenshot") + } + completed <- err + }() + select { + case <-cursors: + case <-time.After(2 * time.Second): + close(allowFrame) + t.Fatal("cursor was not forwarded while awaiting screenshot pixels") + } + select { + case err := <-completed: + close(allowFrame) + t.Fatalf("cursor-only reply falsely completed screenshot: %v", err) + case <-time.After(30 * time.Millisecond): + } + close(allowFrame) + if err := <-completed; err != nil { + t.Fatal(err) + } + time.Sleep(120 * time.Millisecond) + fixture.mu.Lock() + requests := fixture.frameRequests + fixture.mu.Unlock() + time.Sleep(120 * time.Millisecond) + fixture.mu.Lock() + defer fixture.mu.Unlock() + if fixture.frameRequests != requests { + t.Fatal("idle cursor watcher polled for unchanged frames") + } + if len(fixture.inputs) != 0 { + t.Fatal("cursor observer sent mouse or keyboard input") + } +} + func TestDesktopPixelFormatUsesNetworkByteOrder(t *testing.T) { var wire bytes.Buffer if err := setDesktopPixelFormat(&wire); err != nil { @@ -350,6 +427,61 @@ func TestLiveKeystrokesDoNotWaitForGeometryOrReplaceClipboard(t *testing.T) { } } +func TestHumanMotionReusesRecentGeometryButReconcilesButtonChangesAndIdle(t *testing.T) { + local, remote := net.Pipe() + fixture := &rfbFixture{} + go fixture.serve(remote) + client, err := newRFB(t.Context(), local) + if err != nil { + t.Fatal(err) + } + defer client.close() + if _, err := client.refreshGeometry(t.Context()); err != nil { + t.Fatal(err) + } + requests := func() int { fixture.mu.Lock(); defer fixture.mu.Unlock(); return fixture.frameRequests } + before := requests() + x, y := 1, 1 + for range 20 { + if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y}); err != nil { + t.Fatal(err) + } + } + if requests() != before { + t.Fatal("ordinary motion waited for an extra video round trip") + } + if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil { + t.Fatal(err) + } + if requests() != before+1 { + t.Fatal("mouse down did not reconcile current geometry") + } + if err := client.apply(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil { + t.Fatal(err) + } + if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil { + t.Fatal(err) + } + if requests() != before+1 { + t.Fatal("continuing a drag waited for another frame") + } + if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 0}); err != nil { + t.Fatal(err) + } + if requests() != before+2 { + t.Fatal("mouse up lost its geometry boundary") + } + client.mu.Lock() + client.observedAt = time.Now().Add(-time.Second) + client.mu.Unlock() + if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil { + t.Fatal(err) + } + if requests() != before+3 { + t.Fatal("stale geometry was reused after idle") + } +} + func TestRFBCaptureResizeColorsAndReleasedInput(t *testing.T) { local, remote := net.Pipe() fixture := &rfbFixture{resize: true} diff --git a/daemon/internal/surface/rfb_updates.go b/daemon/internal/surface/rfb_updates.go new file mode 100644 index 0000000..b3612dd --- /dev/null +++ b/daemon/internal/surface/rfb_updates.go @@ -0,0 +1,112 @@ +package surface + +import ( + "context" + "image" + "math/bits" + "time" +) + +// One explicit observation shares the controller connection with a pending +// incremental 1px request for cursor changes. A cursor-only reply cannot satisfy +// a screenshot or geometry check. Coverage also handles tiled framebuffer replies. +type frameObservation struct { + full bool + size Geometry + sent bool + remaining int + covered []uint64 +} + +func (o *frameObservation) reset(size Geometry) { + o.size = size + o.sent = false + o.remaining = 1 + if o.full { + o.remaining = size.Width * size.Height + } + o.covered = make([]uint64, (o.remaining+63)/64) +} + +func (o *frameObservation) cover(rect image.Rectangle) { + if !o.sent || o.remaining == 0 { + return + } + width, height := 1, 1 + if o.full { + width, height = o.size.Width, o.size.Height + } + rect = rect.Intersect(image.Rect(0, 0, width, height)) + for y := rect.Min.Y; y < rect.Max.Y; y++ { + start, end := y*width+rect.Min.X, y*width+rect.Max.X + for start < end { + word, offset := start/64, start%64 + n := min(64-offset, end-start) + mask := (^uint64(0) >> uint(64-n)) << uint(offset) + o.remaining -= bits.OnesCount64(mask &^ o.covered[word]) + o.covered[word] |= mask + start += n + } + } +} + +func (r *rfbClient) wakeUpdates() { + select { + case r.updateWake <- struct{}{}: + default: + } +} + +// A pending incremental request sleeps at the VNC server until its cursor (or +// that single pixel) changes. No idle polling, full second video stream, or extra +// pointer events. Explicit captures take priority and wake a held request. +func (r *rfbClient) requestUpdates() { + var nextCursorAt time.Time + for { + select { + case <-r.done: + return + case <-r.updateWake: + } + r.mu.Lock() + o := r.observation + watch := o == nil && r.onCursor != nil && !r.cursorPending + if watch && time.Now().Before(nextCursorAt) { + r.mu.Unlock() + timer := time.NewTimer(time.Until(nextCursorAt)) + select { + case <-r.done: + timer.Stop() + return + case <-r.updateWake: + timer.Stop() + case <-timer.C: + } + r.wakeUpdates() + continue + } + width, height := uint16(1), uint16(1) + send := watch + if o != nil && !o.sent { + o.sent = true + send = true + if o.full { + width, height = uint16(o.size.Width), uint16(o.size.Height) + } + } + if watch { + r.cursorPending = true + nextCursorAt = time.Now().Add(50 * time.Millisecond) + } + r.mu.Unlock() + if send { + err := r.withWrite(context.Background(), func() error { + return r.client.FramebufferUpdateRequest(watch, 0, 0, width, height) + }) + if err != nil { + r.close() + return + } + } + } +} diff --git a/daemon/internal/surface/service.go b/daemon/internal/surface/service.go index 87ee18c..546be06 100644 --- a/daemon/internal/surface/service.go +++ b/daemon/internal/surface/service.go @@ -140,6 +140,10 @@ func (s *Service) Snapshot() Snapshot { v := *out.Geometry out.Geometry = &v } + if out.Cursor != nil { + v := *out.Cursor + out.Cursor = &v + } return out } func (s *Service) Configure(p Provider) error { @@ -163,6 +167,7 @@ func (s *Service) configureProvider(p Provider) { } s.snapshot.Controller = nil s.snapshot.Geometry = nil + s.snapshot.Cursor = nil s.snapshot.State = "disconnected" s.snapshot.Error = nil expires := p.ExpiresAt() @@ -183,6 +188,7 @@ func (s *Service) configureProvider(p Provider) { cancel() _ = old.Close() } + s.observeProviderCursor(p) } func (s *Service) Refresh(ctx context.Context) (Snapshot, error) { @@ -650,6 +656,9 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re } s.operation.Lock() defer s.operation.Unlock() + if err := ctx.Err(); err != nil { + return Receipt{}, err + } s.mu.Lock() session, err := s.sessionLocked(req.SessionID, actor) if err != nil { @@ -688,7 +697,15 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re session.lastSeen = s.now() s.mu.Unlock() if spatial(req.Action.Kind) { - current, err := p.Connect(ctx) + var current Geometry + var err error + if live, ok := p.(interface { + InputGeometry(context.Context, Action) (Geometry, error) + }); ok && actor.Kind == "user" { + current, err = live.InputGeometry(ctx, req.Action) + } else { + current, err = p.Connect(ctx) + } if err != nil { return Receipt{}, err } @@ -728,10 +745,22 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re if err := s.db.SurfacePut("surface_receipt", rec.ID, rec); err != nil { return Receipt{}, err } + // A cancelled request waiting for geometry/storage must never later click. + if err := ctx.Err(); err != nil { + rec.Status = "cancelled" + rec.Error = asError(err) + _ = s.db.SurfacePut("surface_receipt", rec.ID, rec) + return rec.Receipt, nil + } text, err := p.Apply(ctx, req.Action) if err != nil { rec.Status = "outcome_unknown" rec.Error = asError(err) + // A cancelled drag can have reached the desktop even if its response did + // not. Release within the same serial operation using a fresh deadline. + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = p.Release(releaseCtx) + cancel() } else { rec.Status = "dispatched" } diff --git a/daemon/internal/surface/service_test.go b/daemon/internal/surface/service_test.go index 8a49819..6168278 100644 --- a/daemon/internal/surface/service_test.go +++ b/daemon/internal/surface/service_test.go @@ -343,6 +343,44 @@ func TestDesktopUnknownOutcomeIsNeverReplayed(t *testing.T) { } } +func TestCancelledDesktopActionDoesNotDispatchAndUnknownDragReleases(t *testing.T) { + s, p, _ := testService(t) + actor := Actor{Kind: "user", Name: "Test"} + opened := mustOpen(t, s, actor, "control") + x, y := 1, 1 + request := ActionRequest{RequestID: newID(), SessionID: opened.ID, ControlGeneration: opened.Controller.Generation, + GeometryRevision: s.Snapshot().Geometry.Revision, Action: Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := s.Apply(ctx, actor, request); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled input: %v", err) + } + p.mu.Lock() + if p.applied != 0 { + t.Fatal("a cancelled queued action reached the provider") + } + p.failure = context.DeadlineExceeded + before := p.released + p.mu.Unlock() + receipt, err := s.Apply(t.Context(), actor, request) + if err != nil || receipt.Status != "outcome_unknown" { + t.Fatalf("unknown drag outcome: %v %v", receipt, err) + } + p.mu.Lock() + if p.released != before+1 { + t.Fatal("uncertain drag left buttons held") + } + p.mu.Unlock() + if _, err := s.Apply(t.Context(), actor, request); err != nil { + t.Fatal(err) + } + p.mu.Lock() + defer p.mu.Unlock() + if p.applied != 1 { + t.Fatal("the uncertain drag was replayed") + } +} + func TestDesktopInputReceiptsOnlyBroadcastChangedStatus(t *testing.T) { s, p, _ := testService(t) user := Actor{Kind: "user"} diff --git a/daemon/internal/surface/testdata/linux/display.py b/daemon/internal/surface/testdata/linux/display.py index 11fce30..04d0f82 100644 --- a/daemon/internal/surface/testdata/linux/display.py +++ b/daemon/internal/surface/testdata/linux/display.py @@ -21,8 +21,12 @@ text = tk.Text(root, font=("monospace", 20), background="#252525", foreground="white") text.place(x=100, y=100, width=1200, height=700) text.focus_force() +for x, label, cursor in [(100, "Clickable", "hand2"), (500, "Busy", "watch"), (900, "Resize", "sb_h_double_arrow")]: + target = tk.Label(root, text=label, cursor=cursor, font=("monospace", 20), background="#323232", foreground="white") + target.place(x=x, y=840, width=280, height=100) lock = threading.Lock() -state = {"fixture": "mindwire-linux-desktop-v1", "ready": False, "pointer": [0, 0], "presses": [], "keys": [], "text": ""} +state = {"fixture": "mindwire-linux-desktop-v1", "ready": False, "pointer": [0, 0], "presses": [], "releases": [], + "pressPositions": [], "releasePositions": [], "keys": [], "text": ""} def pointer(event): @@ -30,6 +34,10 @@ def pointer(event): state["pointer"] = [event.x_root, event.y_root] if event.type == tk.EventType.ButtonPress: state["presses"].append(event.num) + state["pressPositions"].append([event.num, event.x_root, event.y_root]) + elif event.type == tk.EventType.ButtonRelease: + state["releases"].append(event.num) + state["releasePositions"].append([event.num, event.x_root, event.y_root]) def key(event): @@ -46,6 +54,7 @@ def update(): root.bind_all("", pointer) root.bind_all("", pointer) +root.bind_all("", pointer) root.bind_all("", key) root.after(100, update) diff --git a/daemon/internal/surface/types.go b/daemon/internal/surface/types.go index b2ae2d7..6b463a9 100644 --- a/daemon/internal/surface/types.go +++ b/daemon/internal/surface/types.go @@ -81,6 +81,7 @@ type Snapshot struct { ObservedAt *time.Time `json:"observedAt,omitempty"` ProviderStatus Geometry *Geometry `json:"geometry,omitempty"` + Cursor *Cursor `json:"cursor,omitempty"` Controller *Controller `json:"controller,omitempty"` AuthorizationExpiresAt *time.Time `json:"authorizationExpiresAt,omitempty"` Error *Error `json:"error,omitempty"` diff --git a/daemon/internal/toolchain/admission_test.go b/daemon/internal/toolchain/admission_test.go index aaac5ed..7c21cb2 100644 --- a/daemon/internal/toolchain/admission_test.go +++ b/daemon/internal/toolchain/admission_test.go @@ -3,6 +3,7 @@ package toolchain import ( "context" "os" + "strings" "testing" ) @@ -24,3 +25,44 @@ func TestAdmissionDetectsBinaryChangedOutsideDaemonDespiteCachedReadiness(t *tes t.Fatal("admission reused readiness from a different binary") } } + +func TestCancelledVersionProbeIsNotCachedAsAVersionMismatch(t *testing.T) { + m, spec := fixtureManager(t) + m.install = fakeInstall + if _, err := m.Install(context.Background(), spec, false); err != nil { + t.Fatal(err) + } + m.invalidate(spec) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + failed := m.Software(ctx, spec) + if failed.Compatibility != "unavailable" || strings.Contains(failed.Message, "installation changed") { + t.Fatalf("a cancelled probe was presented as corruption: %+v", failed) + } + if ready := m.Software(context.Background(), spec); ready.Compatibility != "supported" { + t.Fatalf("a transient probe poisoned readiness: %+v", ready) + } +} + +func TestExplicitRepairCanReplaceDamagedManagedVersionWithoutTouchingExternalCLI(t *testing.T) { + m, spec := fixtureManager(t) + m.install = fakeInstall + version, err := m.Install(context.Background(), spec, false) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(versionBinary(m.root, spec.Binary, version), []byte("#!/bin/sh\necho 9.0.0\n"), 0700); err != nil { + t.Fatal(err) + } + m.invalidate(spec) + software := m.Software(context.Background(), spec) + if !software.RepairAvailable || !software.UpdateAvailable { + t.Fatalf("repair is inaccessible: %+v", software) + } + if _, err := m.Install(context.Background(), spec, true); err != nil { + t.Fatal(err) + } + if err := m.CheckAdmission(context.Background(), spec); err != nil { + t.Fatal(err) + } +} diff --git a/daemon/internal/toolchain/catalog.go b/daemon/internal/toolchain/catalog.go index d9ace1c..09dd62d 100644 --- a/daemon/internal/toolchain/catalog.go +++ b/daemon/internal/toolchain/catalog.go @@ -120,9 +120,10 @@ type Software struct { InstalledVersion string `json:"installedVersion"` RecommendedVersion string `json:"recommendedVersion,omitempty"` LatestVersion string `json:"latestVersion,omitempty"` // latest approved catalog entry, not npm latest - Compatibility string `json:"compatibility"` // supported | untested | incompatible | not_installed + Compatibility string `json:"compatibility"` // supported | untested | incompatible | unavailable | not_installed Managed bool `json:"managed"` UpdateAvailable bool `json:"updateAvailable"` + RepairAvailable bool `json:"repairAvailable,omitempty"` RequiresDaemonUpdate bool `json:"requiresDaemonUpdate"` RequiredDaemonVersion string `json:"requiredDaemonVersion,omitempty"` Message string `json:"message,omitempty"` diff --git a/daemon/internal/toolchain/exec.go b/daemon/internal/toolchain/exec.go index 95c6da3..7804ccf 100644 --- a/daemon/internal/toolchain/exec.go +++ b/daemon/internal/toolchain/exec.go @@ -109,9 +109,15 @@ func validEnvKey(key string) bool { return true } -// Shell restores managed paths AFTER the login shell has sourced its startup files. Merely -// setting cmd.Env's PATH lets macOS path_helper choose a different, globally installed CLI. +// Shell restores the launching account's PATH after login startup. Linux's +// /etc/profile can replace it entirely; macOS path_helper can reorder it. Keep +// native tool discovery consistent with CommandContext, retain additional login +// paths, then put explicit managed selections ahead of both. func Shell(script string) string { + prefix := "" + if inherited := os.Getenv("PATH"); inherited != "" { + prefix = "export PATH=" + quote(inherited) + ":\"$PATH\"; " + } var bins []string var env []string var pins []string @@ -126,12 +132,12 @@ func Shell(script string) string { } } if len(bins) == 0 { - return script + return prefix + script } sort.Strings(bins) sort.Strings(env) sort.Strings(pins) - prefix := "export PATH=" + quote(strings.Join(bins, string(os.PathListSeparator))) + ":\"$PATH\"; " + prefix += "export PATH=" + quote(strings.Join(bins, string(os.PathListSeparator))) + ":\"$PATH\"; " prefix += "shopt -u checkhash; " + strings.Join(pins, "; ") + "; " if len(env) > 0 { prefix += strings.Join(env, "; ") + "; " diff --git a/daemon/internal/toolchain/exec_test.go b/daemon/internal/toolchain/exec_test.go new file mode 100644 index 0000000..caf8284 --- /dev/null +++ b/daemon/internal/toolchain/exec_test.go @@ -0,0 +1,39 @@ +package toolchain + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestShellPreservesNativeToolsWhenLoginReplacesPath(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("requires bash") + } + t.Setenv("MINDWIRE_TOOLCHAIN_DIR", t.TempDir()) + native := filepath.Join(t.TempDir(), "native tools ' $literal") + login := filepath.Join(t.TempDir(), "login tools") + for dir, tools := range map[string]map[string]string{ + native: {"mindwire-path-probe": "native"}, + login: {"mindwire-path-probe": "wrong-version", "mindwire-login-probe": "login"}, + } { + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + for name, output := range tools { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\nprintf '%s\\n' "+quote(output)+"\n"), 0700); err != nil { + t.Fatal(err) + } + } + } + t.Setenv("PATH", native+string(os.PathListSeparator)+os.Getenv("PATH")) + // Simulate /etc/profile replacing PATH before the generated command runs. + script := "export PATH=" + quote(login) + ":/usr/bin:/bin; " + Shell("mindwire-path-probe && mindwire-login-probe") + output, err := exec.Command(bash, "-c", script).CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != "native\nlogin" { + t.Fatalf("native/login tools changed or disappeared: %q %v", output, err) + } +} diff --git a/daemon/internal/toolchain/manager.go b/daemon/internal/toolchain/manager.go index 629a386..3c08889 100644 --- a/daemon/internal/toolchain/manager.go +++ b/daemon/internal/toolchain/manager.go @@ -38,6 +38,7 @@ type probe struct { raw string version string selected string + err error at time.Time } @@ -63,10 +64,17 @@ func (m *Manager) Software(ctx context.Context, spec Spec) Software { s := c.Evaluate(spec.ID, m.version, installed) s.Managed = p.selected != "" s.CatalogSource, s.CatalogStale = source, stale - if s.Managed && p.version != p.selected { + if p.err != nil && s.Managed { + s.Compatibility = "unavailable" + s.Message = fmt.Sprintf("Could not start the managed %s CLI to check its version. Try checking again, or repair the CLI in agent settings.", spec.Name) + s.RepairAvailable = s.RecommendedVersion != "" + } else if s.Managed && p.version != p.selected { s.Compatibility = "incompatible" - s.Message = "The managed CLI no longer matches its selected version. Reinstall the supported version." + s.Message = fmt.Sprintf("The managed %s installation changed (selected %s, found %s). Repair the CLI in agent settings.", spec.Name, p.selected, installed) + s.RepairAvailable = s.RecommendedVersion != "" } + // Older clients can still offer the existing explicit update action for repairs. + s.UpdateAvailable = s.UpdateAvailable || s.RepairAvailable return s } @@ -84,7 +92,7 @@ func (m *Manager) RefreshSoftware(ctx context.Context, spec Spec, force bool) So func (m *Manager) CheckAdmission(ctx context.Context, spec Spec) error { m.invalidate(spec) info := m.Software(ctx, spec) - if info.Compatibility == "incompatible" { + if info.Compatibility == "incompatible" || info.Compatibility == "unavailable" { return fmt.Errorf("%s", info.Message) } return nil @@ -100,15 +108,15 @@ func (m *Manager) probe(ctx context.Context, spec Spec) probe { selectedVersion := selected(m.root, spec.Binary) m.mu.Lock() defer m.mu.Unlock() - if p, ok := m.probes[spec.Binary]; ok && p.selected == selectedVersion && time.Since(p.at) < 30*time.Second { + if p, ok := m.probes[spec.Binary]; ok && p.err == nil && p.selected == selectedVersion && time.Since(p.at) < 30*time.Second { return p } path := spec.Binary if selectedVersion != "" { path = versionBinary(m.root, spec.Binary, selectedVersion) } - raw, _ := versionOutput(ctx, spec, path) - p := probe{raw: raw, version: ParseVersion(raw), selected: selectedVersion, at: time.Now()} + raw, err := versionOutput(ctx, spec, path) + p := probe{raw: raw, version: ParseVersion(raw), selected: selectedVersion, err: err, at: time.Now()} m.probes[spec.Binary] = p return p } @@ -138,7 +146,7 @@ func versionOutput(ctx context.Context, spec Spec, path string) (string, error) // A known incompatible version is rejected before it can start another turn. func (m *Manager) Check(ctx context.Context, spec Spec) (string, error) { s := m.Software(ctx, spec) - if s.Compatibility == "incompatible" { + if s.Compatibility == "incompatible" || s.Compatibility == "unavailable" { return "", fmt.Errorf("%s", s.Message) } if s.InstalledVersion == "" { @@ -157,7 +165,7 @@ func (m *Manager) Install(ctx context.Context, spec Spec, update bool) (string, m.invalidate(spec) s := m.RefreshSoftware(ctx, spec, update) if !update && s.InstalledVersion != "" { - if s.Compatibility == "incompatible" { + if s.Compatibility == "incompatible" || s.Compatibility == "unavailable" { return "", fmt.Errorf("%s", s.Message) } return s.InstalledVersion, nil @@ -169,7 +177,7 @@ func (m *Manager) Install(ctx context.Context, spec Spec, update bool) (string, } return "", fmt.Errorf("no tested %s version is available for this Mindwire service", spec.Name) } - if s.InstalledVersion != "" && (!validVersion(s.InstalledVersion) || compare(s.InstalledVersion, target) > 0) { + if s.InstalledVersion != "" && !s.RepairAvailable && (!validVersion(s.InstalledVersion) || compare(s.InstalledVersion, target) > 0) { return "", fmt.Errorf("installed %s is newer than the tested version %s; it was left unchanged", spec.Name, target) } if s.InstalledVersion == target && s.Compatibility == "supported" { diff --git a/daemon/openapi.json b/daemon/openapi.json index 8371d0b..eb15798 100644 --- a/daemon/openapi.json +++ b/daemon/openapi.json @@ -287,6 +287,10 @@ "localDesktopVersion": { "type": "integer", "description": "Opt-in Mac desktop through paired SSH; 1 on supported personal computers." + }, + "projectLibraryVersion": { + "type": "integer", + "description": "1 supports durable project folders, membership and custom ordering." } }, "required": [ @@ -3019,6 +3023,10 @@ } } } + }, + "projectLibrary": { + "$ref": "#/components/schemas/ProjectLibrary", + "description": "Present in full snapshots and when organization changed since the requested revision." } } }, @@ -4076,7 +4084,7 @@ }, "compatibility": { "type": "string", - "description": "supported, untested, incompatible, or not_installed. Unknown versions are untested, never implicitly approved for installation." + "description": "supported, untested, incompatible, unavailable, or not_installed. Unavailable means a managed CLI version probe failed; retry or explicitly repair it. Unknown versions are untested, never implicitly approved for installation." }, "requiredDaemonVersion": { "type": "string" @@ -4105,6 +4113,10 @@ "type": "string" }, "description": "Missing shared tools. Run setup to repair them even when the CLI is already installed." + }, + "repairAvailable": { + "type": "boolean", + "description": "An explicit update can repair a changed or unreadable managed CLI. Also sets updateAvailable for older clients." } } }, @@ -6197,6 +6209,133 @@ }, "required": [], "additionalProperties": false + }, + "ProjectFolder": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + } + }, + "ProjectLibrary": { + "type": "object", + "required": [ + "version", + "revision", + "folders", + "membership", + "order" + ], + "properties": { + "version": { + "type": "integer", + "enum": [ + 1 + ] + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectFolder" + }, + "description": "Folders in their display order." + }, + "membership": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Project ID to folder ID. Only registered projects are included." + }, + "order": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Custom project order, using registry project IDs." + } + } + }, + "ProjectPlacement": { + "type": "object", + "required": [ + "projectId", + "folderId" + ], + "properties": { + "projectId": { + "type": "string" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "Null removes the project from its folder without changing its files." + } + } + }, + "ProjectLibraryEdit": { + "type": "object", + "required": [ + "expectedRevision" + ], + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "The current project library revision, not a project record revision." + }, + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectFolder" + }, + "description": "Create or rename folders. Deleted IDs cannot be reused." + }, + "deleteFolders": { + "type": "array", + "items": { + "type": "string" + } + }, + "placements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectPlacement" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reorder these projects while retaining other projects in their existing slots." + }, + "folderOrder": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reorder these folders while retaining the others." + }, + "importIfEmpty": { + "type": "boolean", + "description": "One-time migration: ignored once this workspace has any library revision." + } + } } } }, @@ -17010,6 +17149,141 @@ } ] } + }, + "/workspace/project-library": { + "get": { + "tags": [ + "Workspace" + ], + "summary": "Read project folders and order", + "description": "Reads only organization metadata. No native history scan or filesystem move.", + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "Authoritative project library.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectLibrary" + } + } + } + }, + "401": { + "description": "Workspace authentication required.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "patch": { + "tags": [ + "Workspace" + ], + "summary": "Edit project organization atomically", + "description": "Partial conditional edit in a single SQLite transaction. Identical retries do not advance the revision. Deleting folders keeps all projects, files and conversations. Requires projectLibraryVersion >= 1.", + "security": [ + { + "bearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectLibraryEdit" + } + } + } + }, + "responses": { + "200": { + "description": "Authoritative project library.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectLibrary" + } + } + } + }, + "400": { + "description": "Invalid folder, name or project reference. Nothing was changed.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Workspace authentication required.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "409": { + "description": "Organization changed on another client. Fetch it and rebase the intended edit.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "410": { + "description": "A folder was deleted. Do not recreate that ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } } } } diff --git a/daemon/sdk/mindwire.go b/daemon/sdk/mindwire.go index 2c7f2de..6da0b43 100644 --- a/daemon/sdk/mindwire.go +++ b/daemon/sdk/mindwire.go @@ -261,6 +261,7 @@ type Health struct { Agent string `json:"agent"` Version string `json:"version"` WorkspaceMetadataVersion int `json:"workspaceMetadataVersion"` + ProjectLibraryVersion int `json:"projectLibraryVersion"` ProjectOperationsVersion int `json:"projectOperationsVersion"` ProjectIconsVersion int `json:"projectIconsVersion"` ProjectSyncVersion int `json:"projectSyncVersion"` @@ -284,7 +285,7 @@ func (c *Client) Health() Health { if runtime.GOOS == "darwin" && os.Geteuid() != 0 { localDesktopVersion = surface.LocalDesktopVersion } - return Health{OK: true, Agent: c.core.sup.Default(), Version: agent.Version, WorkspaceMetadataVersion: registry.Version, ProjectOperationsVersion: registry.ProjectOperationsVersion, ProjectIconsVersion: projecticon.Version, ProjectSyncVersion: projectsync.ProtocolVersion(), ConversationBrowserVersion: conversations.BrowserVersion, SurfaceProtocolVersion: surface.Version, LocalDesktopVersion: localDesktopVersion, NotificationPreferencesVersion: registry.NotificationPreferencesVersion, HarnessPolicyVersion: toolchain.PolicyVersion, WorkspaceIsolationVersion: agent.WorkspaceIsolationVersion, WorkspaceIsolation: agent.WorkspaceIsolation(), WorkspaceExecutionVersion: workspaceexec.Version, TerminalProtocolVersion: workspaceexec.TerminalVersion, TurnRequestVersion: orchestrator.TurnRequestVersion, ChatForkVersion: agent.ChatForkVersion, ImageAttachmentsVersion: agent.ImageAttachmentsVersion} + return Health{OK: true, Agent: c.core.sup.Default(), Version: agent.Version, WorkspaceMetadataVersion: registry.Version, ProjectLibraryVersion: registry.ProjectLibraryVersion, ProjectOperationsVersion: registry.ProjectOperationsVersion, ProjectIconsVersion: projecticon.Version, ProjectSyncVersion: projectsync.ProtocolVersion(), ConversationBrowserVersion: conversations.BrowserVersion, SurfaceProtocolVersion: surface.Version, LocalDesktopVersion: localDesktopVersion, NotificationPreferencesVersion: registry.NotificationPreferencesVersion, HarnessPolicyVersion: toolchain.PolicyVersion, WorkspaceIsolationVersion: agent.WorkspaceIsolationVersion, WorkspaceIsolation: agent.WorkspaceIsolation(), WorkspaceExecutionVersion: workspaceexec.Version, TerminalProtocolVersion: workspaceexec.TerminalVersion, TurnRequestVersion: orchestrator.TurnRequestVersion, ChatForkVersion: agent.ChatForkVersion, ImageAttachmentsVersion: agent.ImageAttachmentsVersion} } // processStarted anchors the daemon-process uptime the /stats snapshot reports; set once at package diff --git a/daemon/sdk/mindwire_test.go b/daemon/sdk/mindwire_test.go index 6d804ce..fe26fe7 100644 --- a/daemon/sdk/mindwire_test.go +++ b/daemon/sdk/mindwire_test.go @@ -695,6 +695,8 @@ func TestSDKRouteParity(t *testing.T) { "GET /surfaces/desktop/actions/{id}": "Surfaces.Receipt", "GET /artifacts/{id}": "Surfaces.Artifact", "GET /workspace": "Workspace.Snapshot", + "GET /workspace/project-library": "Workspace.Library", + "PATCH /workspace/project-library": "Workspace.EditLibrary", "GET /workspace/changes": "Workspace.Changes", "GET /workspace/conversations": "Workspace.Conversations", "POST /workspace/conversations/open": "Workspace.OpenConversation", diff --git a/daemon/sdk/surfaces.go b/daemon/sdk/surfaces.go index d279478..6f4d909 100644 --- a/daemon/sdk/surfaces.go +++ b/daemon/sdk/surfaces.go @@ -10,6 +10,7 @@ type SurfaceSnapshot = surface.Snapshot type SurfaceSession = surface.Session type SurfaceController = surface.Controller type SurfaceGeometry = surface.Geometry +type SurfaceCursor = surface.Cursor type SurfaceCapabilities = surface.Capabilities type SurfaceBinding = surface.Binding type LocalDesktopInfo = surface.LocalDesktopInfo diff --git a/daemon/sdk/workspace.go b/daemon/sdk/workspace.go index fd376db..3c80363 100644 --- a/daemon/sdk/workspace.go +++ b/daemon/sdk/workspace.go @@ -14,14 +14,18 @@ import ( // Aliases keep the same records, revisions and deletion protocol across Go, HTTP, TypeScript and iOS. type ( - WorkspaceRecord = registry.Record - WorkspaceAgent = registry.Agent - WorkspaceProject = registry.Project - WorkspaceChat = registry.Chat - WorkspaceDeletion = registry.Deletion - WorkspaceImport = registry.Import - WorkspaceSnapshot = registry.Snapshot - ProjectIcon = projecticon.Image + WorkspaceRecord = registry.Record + WorkspaceAgent = registry.Agent + WorkspaceProject = registry.Project + WorkspaceChat = registry.Chat + WorkspaceDeletion = registry.Deletion + WorkspaceImport = registry.Import + WorkspaceSnapshot = registry.Snapshot + ProjectIcon = projecticon.Image + ProjectFolder = registry.ProjectFolder + ProjectLibrary = registry.ProjectLibrary + ProjectLibraryEdit = registry.ProjectLibraryEdit + ProjectPlacement = registry.ProjectPlacement ) // Workspace is workspace-wide metadata; selecting a different harness never changes its scope. @@ -69,6 +73,20 @@ func workspaceError(op string, err error) error { type WorkspaceSyncOptions struct{ Refresh bool } +func (w *Workspace) Library() (ProjectLibrary, error) { + library, err := w.c.core.registry.ProjectLibrary() + return library, workspaceError("Workspace.Library", err) +} + +func (w *Workspace) EditLibrary(edit ProjectLibraryEdit) (ProjectLibrary, error) { + w.c.core.registryMu.Lock() + defer w.c.core.registryMu.Unlock() + if err := w.c.core.registry.EditProjectLibrary(edit); err != nil { + return ProjectLibrary{}, workspaceError("Workspace.EditLibrary", err) + } + return w.Library() +} + // ProjectIcon reads a small image confined to the project's directory. An empty // path uses the saved icon; an explicit relative path previews a candidate. func (w *Workspace) ProjectIcon(projectID, path string) (ProjectIcon, error) { diff --git a/daemon/sdk/workspace_test.go b/daemon/sdk/workspace_test.go index 7299228..c31de57 100644 --- a/daemon/sdk/workspace_test.go +++ b/daemon/sdk/workspace_test.go @@ -13,6 +13,37 @@ import ( "time" ) +func TestWorkspaceProjectLibrarySharesOneRegistryAcrossHarnesses(t *testing.T) { + c := newFakeClient(t, nil) + if c.Health().ProjectLibraryVersion != 1 { + t.Fatal("missing library capability") + } + created, err := c.Workspace.Projects.Put("project", WorkspaceProject{Name: "App", Path: t.TempDir()}, nil) + if err != nil { + t.Fatal(err) + } + folder := "work" + edit := ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Work"}}, + Placements: []ProjectPlacement{{ProjectID: "project", FolderID: &folder}}, Order: []string{"project"}} + saved, err := c.Workspace.EditLibrary(edit) + if err != nil { + t.Fatal(err) + } + other, err := c.WithAgent("codex").Workspace.Library() + if err != nil || !reflect.DeepEqual(other, saved) { + t.Fatalf("harness changed the workspace library: %+v %v", other, err) + } + delta, err := c.Workspace.Changes(created.Revision, created.WorkspaceID) + if err != nil || delta.ProjectLibrary == nil || !reflect.DeepEqual(*delta.ProjectLibrary, saved) { + t.Fatalf("SDK delta lost folders: %+v %v", delta, err) + } + _, err = c.Workspace.EditLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Stale edit"}}}) + var conflict *APIError + if !errors.As(err, &conflict) || conflict.Status != 409 { + t.Fatal("SDK did not expose a conditional-edit conflict", err) + } +} + func TestWorkspaceDiscoversAndReadsNativeCLIConversations(t *testing.T) { home, cwd := t.TempDir(), t.TempDir() t.Setenv("CLAUDE_CONFIG_DIR", home) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7d248f9..d7953cc 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -65,7 +65,8 @@ export type { ProjectRequest, ProjectAuth, ProjectOperation, ProjectRemoveReques GitConnection, GitAccessState, ProjectGitState, GitAction, GitIdentity, GitIdentityScope, GitIdentitySettings, GitIdentityUpdate, GitOperationRequest, GitOperation } from "./workspace.js"; export type { WorkspaceRecord, WorkspaceAgent, WorkspaceProject, WorkspaceChat, WorkspaceKind, - WorkspaceInput, WorkspaceImport, WorkspaceSnapshot, ProjectIcon } from "./workspace.js"; + WorkspaceInput, WorkspaceImport, WorkspaceSnapshot, ProjectIcon, + ProjectFolder, ProjectLibrary, ProjectLibraryEdit } from "./workspace.js"; export * from "./surfaces.js"; export * from "./execution.js"; diff --git a/packages/sdk/src/surfaces.ts b/packages/sdk/src/surfaces.ts index 2b6dc1f..64dd290 100644 --- a/packages/sdk/src/surfaces.ts +++ b/packages/sdk/src/surfaces.ts @@ -2,6 +2,10 @@ import type { Mindwire } from "./client.js"; import { readSSE } from "./sse.js"; export interface SurfaceGeometry { width: number; height: number; revision: number } +/** Actual remote cursor. The base64 PNG changes only with its shape, not its position. */ +export interface SurfaceCursor { + id: string; width: number; height: number; hotspotX: number; hotspotY: number; png: string; +} export interface SurfaceProblem { code: string; message: string } export interface SurfaceCapabilities { view: boolean; capture: boolean; pointer: boolean; keyboard: boolean; text: boolean; @@ -18,7 +22,7 @@ export interface SurfaceSnapshot { version: number; revision: number; instanceId: string; state: string; observedAt?: string; supported: boolean; enabled: boolean; available: boolean; credentials: boolean; os?: string; capabilities: SurfaceCapabilities; - geometry?: SurfaceGeometry; controller?: SurfaceController; + geometry?: SurfaceGeometry; cursor?: SurfaceCursor; controller?: SurfaceController; authorizationExpiresAt?: string; error?: SurfaceProblem; setup?: { reason: string; command: string }; } @@ -57,7 +61,7 @@ export interface SurfaceActionRequest { } export interface SurfaceReceipt { id: string; sessionId: string; surfaceId: string; kind: string; - status: "dispatching" | "dispatched" | "outcome_unknown"; createdAt: string; error?: SurfaceProblem; + status: "dispatching" | "dispatched" | "outcome_unknown" | "cancelled"; createdAt: string; error?: SurfaceProblem; /** Transient clipboard output, not stored in the receipt. */ text?: string; } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 43653bc..8f1e0f3 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -954,10 +954,12 @@ export interface HarnessSoftware { installedVersion: string; recommendedVersion?: string; latestVersion?: string; - compatibility: "supported" | "untested" | "incompatible" | "not_installed" | (string & {}); + compatibility: "supported" | "untested" | "incompatible" | "unavailable" | "not_installed" | (string & {}); managed: boolean; updateAvailable: boolean; requiresDaemonUpdate: boolean; + /** An explicitly requested update can repair this damaged managed installation. */ + repairAvailable?: boolean; requiredDaemonVersion?: string; message?: string; catalogRevision: number; @@ -1133,6 +1135,8 @@ export interface Health { localDesktopVersion?: number; /** Workspace registry protocol version; absent on daemons predating workspace metadata. */ workspaceMetadataVersion?: number; + /** Durable project folders, membership and ordering, with conditional edits. */ + projectLibraryVersion?: number; /** Durable project creation/clone operations; absent on older daemons. */ projectOperationsVersion?: number; /** Project-relative icons and authenticated image previews. */ diff --git a/packages/sdk/src/workspace.ts b/packages/sdk/src/workspace.ts index df51bdf..4acbe4c 100644 --- a/packages/sdk/src/workspace.ts +++ b/packages/sdk/src/workspace.ts @@ -246,6 +246,29 @@ export interface WorkspaceImport { chats?: (WorkspaceInput & { id: string })[]; } +export interface ProjectFolder { id: string; name: string } + +/** Daemon-owned grouping and manual order; all IDs refer to this workspace. */ +export interface ProjectLibrary { + version: number; + revision: number; + folders: ProjectFolder[]; + membership: Record; + order: string[]; +} + +/** Partial atomic edit. On 409, read the current library before deciding how to rebase. */ +export interface ProjectLibraryEdit { + expectedRevision: number; + folders?: ProjectFolder[]; + deleteFolders?: string[]; + placements?: { projectId: string; folderId: string | null }[]; + order?: string[]; + folderOrder?: string[]; + /** Migration only: existing daemon organization always wins. */ + importIfEmpty?: boolean; +} + export interface WorkspaceSnapshot { version: number; workspaceId: string; @@ -258,6 +281,8 @@ export interface WorkspaceSnapshot { deleted: { kind: WorkspaceKind; id: string; revision: number }[]; /** Cached chats are retained when a harness's native list could not be refreshed. */ sessionDiscoveryIssues?: { projectId: string; agent: string; message: string }[]; + /** Present on full snapshots, or when organization changed in a delta. */ + projectLibrary?: ProjectLibrary; } export class WorkspaceCollection { @@ -304,6 +329,14 @@ export class WorkspaceApi { return this.mw.http.request("GET", "/workspace", { query: options }); } + library(): Promise { + return this.mw.http.request("GET", "/workspace/project-library"); + } + + editLibrary(edit: ProjectLibraryEdit): Promise { + return this.mw.http.request("PATCH", "/workspace/project-library", { body: edit }); + } + /** Read the saved icon, or preview a candidate relative path within the project. Requires projectIconsVersion >= 1. */ projectIcon(projectId: string, path?: string): Promise { return this.mw.http.request("GET", `/workspace/projects/${encodeURIComponent(projectId)}/icon`, { query: { path } }); diff --git a/packages/sdk/test/workspace-live.test.ts b/packages/sdk/test/workspace-live.test.ts index 8ad6355..7ab5e9c 100644 --- a/packages/sdk/test/workspace-live.test.ts +++ b/packages/sdk/test/workspace-live.test.ts @@ -53,10 +53,17 @@ test.skipIf(!process.env.MINDWIRE_TEST_DAEMON)("workspace SDK survives daemon re expect(exitCode).not.toBe(0); expect(readFileSync(join(directory, "daemon.token"), "utf8")).toBe(token); expect(statSync(join(directory, "daemon.token")).mode & 0o777).toBe(0o600); + expect((await a.health()).projectLibraryVersion).toBe(1); + const libraryEdit = { expectedRevision: 0, folders: [{ id: "work", name: "Work" }], + placements: [{ projectId: "project", folderId: "work" }], order: ["project"] }; + const library = await a.workspace.editLibrary(libraryEdit); + expect(await b.workspace.library()).toEqual(library); + expect(await a.workspace.editLibrary(libraryEdit)).toEqual(library); await stop(child); child = start(); await ready(); - expect(await b.workspace.snapshot()).toEqual(created); + expect(await b.workspace.snapshot()).toEqual({ ...created, revision: library.revision, projectLibrary: library }); + expect(await b.workspace.library()).toEqual(library); const original = created.projects[0]!; const edits = await Promise.allSettled([ @@ -71,6 +78,9 @@ test.skipIf(!process.env.MINDWIRE_TEST_DAEMON)("workspace SDK survives daemon re expect(changed.full).toBe(false); expect(changed.projects).toHaveLength(1); const deleted = await a.workspace.projects.delete("project", changed.projects[0]!.revision); + expect(deleted.projectLibrary?.membership).toEqual({}); + expect(deleted.projectLibrary?.order).toEqual([]); + expect(deleted.projectLibrary?.folders).toEqual(library.folders); expect(deleted.chats).toHaveLength(0); expect(deleted.deleted.map(item => item.kind).sort()).toEqual(["chats", "projects"]); const afterStaleImport = await b.workspace.import(created); diff --git a/packages/sdk/test/workspace.test.ts b/packages/sdk/test/workspace.test.ts index d742246..1eebb6b 100644 --- a/packages/sdk/test/workspace.test.ts +++ b/packages/sdk/test/workspace.test.ts @@ -1,11 +1,38 @@ import { test, expect } from "bun:test"; -import { Mindwire, ApiError, remote, ensureDaemon, type WorkspaceSnapshot, type SandboxHost } from "../src/index.js"; +import { Mindwire, ApiError, remote, ensureDaemon, type WorkspaceSnapshot, type ProjectLibrary, type ProjectLibraryEdit, type SandboxHost } from "../src/index.js"; const snapshot: WorkspaceSnapshot = { version: 1, workspaceId: "workspace-identity", revision: 4, full: true, agents: [], projects: [], chats: [], deleted: [], }; +test("project folders use the workspace API with conditional edits and explicit null membership", async () => { + const library: ProjectLibrary = { version: 1, revision: 7, folders: [{ id: "work", name: "Work" }], membership: {}, order: ["project"] }; + const edit: ProjectLibraryEdit = { expectedRevision: 6, placements: [{ projectId: "project", folderId: null }] }; + const calls: { path: string; method: string; body: unknown }[] = []; + const mw = new Mindwire({ target: remote("http://registry"), fetch: async (input, init) => { + calls.push({ path: new URL(input).pathname, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : null }); + return Response.json(new URL(input).pathname === "/workspace" ? { ...snapshot, projectLibrary: library } : library); + } }); + expect(await mw.workspace.library()).toEqual(library); + expect(await mw.workspace.editLibrary(edit)).toEqual(library); + expect((await mw.workspace.snapshot()).projectLibrary).toEqual(library); + expect(calls.slice(0, 2)).toEqual([ + { path: "/workspace/project-library", method: "GET", body: null }, + { path: "/workspace/project-library", method: "PATCH", body: edit }, + ]); +}); + +test("a stale library edit exposes its conflict without silently replaying it", async () => { + let writes = 0; + const mw = new Mindwire({ target: remote("http://registry"), fetch: async () => { + writes++; + return Response.json({ error: "record changed on another client" }, { status: 409 }); + } }); + await expect(mw.workspace.editLibrary({ expectedRevision: 1, order: ["b", "a"] })).rejects.toMatchObject({ status: 409 }); + expect(writes).toBe(1); +}); + test("global conversations browse without a cwd and adopt the original reference through the shared workspace API", async () => { const calls: { url: URL; method: string; body: unknown }[] = []; const row = { id: "native:reference", agentType: "codex", agentName: "Codex", cwd: "/work/original folder", From 35f7e25f1cd1d428c693654c521df8dbc8ebb270 Mon Sep 17 00:00:00 2001 From: Hydra Date: Mon, 5 Oct 2026 18:22:00 +0300 Subject: [PATCH 3/4] Document native workspace access and synchronized project organization --- .../web/content/docs/guides/configuration.mdx | 25 +++++++--- apps/web/content/docs/guides/meta.json | 1 + .../content/docs/guides/project-library.mdx | 50 +++++++++++++++++++ apps/web/content/docs/guides/setup.mdx | 5 ++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 apps/web/content/docs/guides/project-library.mdx diff --git a/apps/web/content/docs/guides/configuration.mdx b/apps/web/content/docs/guides/configuration.mdx index 9841147..2dd7500 100644 --- a/apps/web/content/docs/guides/configuration.mdx +++ b/apps/web/content/docs/guides/configuration.mdx @@ -15,6 +15,22 @@ Both are filtered server-side to the agent's **declared, non-secret** keys — a ignored, and a setting can never introduce a credential. Discover what a given agent declares with `GET /agent` (`mw.agent()` / `client.Agent(ctx)`). +## Native command access + +Direct workspaces use the computer account running MindWire, including root when the service was +started as root. Commands have that account's normal access to files, installed tools and the +network. The launcher preserves inherited PATH entries after login-shell startup; explicitly +managed CLI versions still take precedence. Docker workspaces keep their container boundary. + +Codex defaults to `danger-full-access` unless a sandbox was explicitly selected. A native +`sandbox_mode` in the user config or its selected profile, a sticky `sandbox` setting, or a per-turn +override remains effective. Approval policy is independent. These settings apply to new and resumed +sessions through both the CLI and app-server transports. + +To restrict commands, select `workspace-write` or `read-only`. Codex's `workspace-write` network +restrictions still apply unless enabled in its native configuration. A blocked GitHub command can +report a DNS error even when the host itself is online. + ## Sticky config Read and merge persisted settings. `setConfig` merges recognized keys; unknown keys are dropped. @@ -130,12 +146,9 @@ per-turn prompt uses. A per-turn `options.systemPrompt` still wins for that one await mw.setConfig({ systemPrompt: "You are a terse senior engineer." }); ``` - -**Codex approval mode.** Codex only carries a system prompt on its autonomous `exec` transport. With -any `permissionMode` other than `never`, a turn carrying a system prompt (sticky or per-turn) is -rejected with a `400` rather than silently dropped — the interactive-approval (app-server) transport -can't take the overlay. Claude has no such constraint. - +Codex sends system instructions through the private app-server connection for interactive turns. +Headless `exec` turns use a temporary native config overlay. Both honor the same prompt and approval +settings without placing private instructions on the command line. The sticky system prompt is one of the three persistent layers beneath a turn; the full picture — memory files (`CLAUDE.md` / `AGENTS.md`) and saved prompt templates — is in diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json index 5a54cb7..ea783ef 100644 --- a/apps/web/content/docs/guides/meta.json +++ b/apps/web/content/docs/guides/meta.json @@ -19,6 +19,7 @@ "destinations", "personal-computers", "desktops", + "project-library", "project-sync" ] } diff --git a/apps/web/content/docs/guides/project-library.mdx b/apps/web/content/docs/guides/project-library.mdx new file mode 100644 index 0000000..e515839 --- /dev/null +++ b/apps/web/content/docs/guides/project-library.mdx @@ -0,0 +1,50 @@ +--- +title: Project folders & ordering +description: Organize projects in the workspace registry, with atomic edits and offline synchronization. +--- + +The workspace registry stores project folders, membership, and manual order. Folders organize +project references; moving or deleting a folder does not move files, run Git, or delete projects +and conversations. Clients can keep their own flat or folder-based view preferences. + +Read `health.projectLibraryVersion` before using the library API. Version `1` supports +`GET /workspace/project-library` and `PATCH /workspace/project-library`, plus the matching +TypeScript and Go SDK methods. + +## Read and edit + +```ts +const library = await mw.workspace.library(); +const folderId = crypto.randomUUID(); +const saved = await mw.workspace.editLibrary({ + expectedRevision: library.revision, + folders: [{ id: folderId, name: "Client work" }], + placements: [{ projectId, folderId }], + order: [projectId], +}); +``` + +The project ID must belong to this workspace. The Go equivalent is +`client.Workspace.Library()` and `client.Workspace.EditLibrary(edit)`. +The returned library contains `version`, `revision`, `folders`, `membership`, and `order`. + +An edit can upsert folders, delete folder IDs, place projects into a folder or set their folder to +`null`, and reorder selected projects or folders. Reordering a subset preserves the other entries' +positions. The daemon validates and saves the complete edit in one transaction; invalid edits +change nothing. + +## Offline clients and conflicts + +Cache the library and save outgoing edits durably with local UI changes. Send edits through one +writer per workspace. The API requires the last observed `expectedRevision`; a `409` means the +library changed and must be read again before rebasing. An identical retry is a no-op. + +Full workspace snapshots include `projectLibrary`. Incremental snapshots include it only when +organization changes, so an idle client needs no additional polling. Keep disconnected workspace +data until the daemon confirms a deletion. When a folder is deleted remotely, remove dependent +stale edits instead of recreating that folder; its deletion is recorded in the registry. + +Use `importIfEmpty: true` only to migrate existing local organization into an untouched registry. +Existing daemon organization wins. Older services can keep the client's organization local until +upgraded. [Project switching](/docs/guides/project-sync) can retain logical membership when a project +has copies on multiple workspaces. diff --git a/apps/web/content/docs/guides/setup.mdx b/apps/web/content/docs/guides/setup.mdx index 4d5bdbb..1eb22e3 100644 --- a/apps/web/content/docs/guides/setup.mdx +++ b/apps/web/content/docs/guides/setup.mdx @@ -57,6 +57,11 @@ configured, call [`agent()`](/docs/guides/agents#discover-an-agent) — its `ins already satisfied is skipped, so calling it on a healthy agent is a no-op that just reports "satisfied." `update()` is the same flow aimed at upgrading an already-installed agent. +For a managed CLI, a failed version probe reports `compatibility: "unavailable"`; it does not +claim the selected version changed. A real mismatch reports both versions. When +`repairAvailable` is true, an explicit `update()` repairs the managed installation using the +recommended supported version. Externally installed CLIs are not silently downgraded or replaced. + From 16bc5684e4fc30309eaf8068b20a782b88b7d50b Mon Sep 17 00:00:00 2001 From: Hydra Date: Mon, 5 Oct 2026 18:39:59 +0300 Subject: [PATCH 4/4] Advance stable container aliases from the release workflow --- .github/workflows/docker-images.yml | 7 ++++++- .github/workflows/release.yml | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 4e79636..ce5b1a9 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -23,6 +23,11 @@ on: description: "Git tag (for example v0.1.2)" required: true type: string + publish-latest: + description: "Advance stable image aliases after a checked release" + required: false + type: boolean + default: false workflow_dispatch: inputs: tag: @@ -59,7 +64,7 @@ jobs: - id: tags env: REQUESTED_TAG: ${{ inputs.tag || github.event.inputs.tag }} - CALLED_FROM_RELEASE: ${{ github.event_name == 'workflow_call' }} + CALLED_FROM_RELEASE: ${{ inputs.publish-latest == true }} run: | set -euo pipefail tag="${REQUESTED_TAG#v}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 110f2b6..704277f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,6 +206,7 @@ jobs: uses: ./.github/workflows/docker-images.yml with: tag: ${{ github.ref_name }} + publish-latest: true verify-runtime-image: name: "E2E: released runtime image"