From 4b96f462752ea0fa1f5e17b00375acb76c5c19b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 14 Aug 2026 19:56:47 +0200 Subject: [PATCH] fix(v2): complete native runtime parity Restore reliable V2 catalogs, agent identifiers, cursor pagination, prompts, variants, attachments, forms, native event reconciliation, Yolo persistence, and location-aware PTY controls. Prevent completed control records from appearing as streaming placeholders and keep live output refreshing during sustained token streams. Harden the shared service and workspace proxy with explicit OPENCODE_DB isolation, launch-signature validation, traversal protection, WSL namespace translation, ownership-scoped routes, bounded SSE backpressure, reconnect recovery, and safe process-proof transfer and shutdown semantics. Align provider, VCS, LSP, update, documentation, and CI behavior with the experimental next-17353 contract. Validation includes server/UI/Electron typechecks, 252 workflow UI tests, focused server suites, production UI/server builds, 68 Tauri tests, and a real V2 workspace/session/prompt smoke. --- .github/workflows/pr-build.yml | 8 + .../codenomad-architecture-guide/SKILL.md | 18 +- .../references/architecture-overview.md | 12 +- .../references/feature-traces.md | 8 +- .../references/sdk-api-reference.md | 8 +- .../references/sdk-critical-behaviors.md | 17 +- .../references/sdk-integration-patterns.md | 14 +- .../references/server-conventions.md | 4 +- AGENTS.md | 2 +- CONTRIBUTING.md | 9 +- MIGRATION_V2.md | 35 +- dev-docs/SUMMARY.md | 21 +- dev-docs/architecture.md | 19 +- dev-docs/technical-implementation.md | 24 +- packages/server/README.md | 23 +- .../permissions/auto-accept-manager.test.ts | 73 +++- .../src/permissions/auto-accept-manager.ts | 83 ++-- .../src/permissions/auto-accept-store.test.ts | 12 +- .../src/permissions/auto-accept-store.ts | 18 +- .../src/permissions/opencode-replier.test.ts | 29 +- .../src/permissions/opencode-replier.ts | 5 + .../opencode-yolo-metadata.test.ts | 69 +++- .../src/permissions/opencode-yolo-metadata.ts | 38 +- .../server/__tests__/instance-proxy.test.ts | 207 +++++++++- packages/server/src/server/http-server.ts | 215 +++++++++- .../server/src/server/routes/events.test.ts | 117 ++++++ packages/server/src/server/routes/events.ts | 112 +++-- packages/server/src/settings/migrate.test.ts | 29 ++ packages/server/src/settings/migrate.ts | 2 +- .../src/workspaces/__tests__/spawn.test.ts | 85 +++- .../src/workspaces/instance-events.test.ts | 145 ++++++- .../server/src/workspaces/instance-events.ts | 69 +++- .../server/src/workspaces/manager.test.ts | 69 +++- packages/server/src/workspaces/manager.ts | 145 +++++-- .../src/workspaces/opencode-service.test.ts | 121 +++++- .../server/src/workspaces/opencode-service.ts | 189 ++++++++- packages/server/src/workspaces/spawn.ts | 60 ++- packages/ui/src/App.tsx | 4 + packages/ui/src/components/agent-selector.tsx | 21 +- .../ui/src/components/form-request.test.ts | 26 ++ packages/ui/src/components/form-request.tsx | 235 +++++++++++ .../components/instance-service-status.tsx | 24 +- .../components/instance/instance-shell2.tsx | 5 +- .../shell/right-panel/core-plugin.tsx | 4 +- .../shell/right-panel/plugin-manifest.test.ts | 4 +- .../shell/right-panel/tabs/StatusTab.tsx | 113 ++++- .../shell/right-panel/tabs/files-runtime.tsx | 18 +- .../shell/right-panel/tabs/status-sections.ts | 14 +- .../shell/right-panel/useGitChanges.ts | 23 +- packages/ui/src/components/message-item.tsx | 14 +- .../ui/src/components/message-section.tsx | 2 +- .../components/permission-approval-modal.tsx | 166 ++++---- .../permission-notification-banner.tsx | 12 +- .../provider-auth/provider-manager-modal.tsx | 98 ++--- .../provider-auth/provider-options.test.ts | 34 ++ .../provider-auth/provider-options.ts | 62 +++ packages/ui/src/components/session-list.tsx | 2 +- .../src/components/session/session-view.tsx | 2 +- .../settings/opencode-update-card.tsx | 77 +--- packages/ui/src/lib/filesystem-events.test.ts | 44 ++ packages/ui/src/lib/filesystem-events.ts | 44 ++ .../lib/hooks/use-instance-metadata.test.ts | 24 ++ .../ui/src/lib/hooks/use-instance-metadata.ts | 15 +- .../ui/src/lib/i18n/messages/de/instance.ts | 17 + .../ui/src/lib/i18n/messages/de/settings.ts | 11 +- .../ui/src/lib/i18n/messages/de/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/en/instance.ts | 17 + .../ui/src/lib/i18n/messages/en/settings.ts | 11 +- .../ui/src/lib/i18n/messages/en/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/es/instance.ts | 17 + .../ui/src/lib/i18n/messages/es/settings.ts | 11 +- .../ui/src/lib/i18n/messages/es/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/fr/instance.ts | 17 + .../ui/src/lib/i18n/messages/fr/settings.ts | 11 +- .../ui/src/lib/i18n/messages/fr/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/he/instance.ts | 17 + .../ui/src/lib/i18n/messages/he/settings.ts | 11 +- .../ui/src/lib/i18n/messages/he/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/ja/instance.ts | 17 + .../ui/src/lib/i18n/messages/ja/settings.ts | 11 +- .../ui/src/lib/i18n/messages/ja/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/ne/instance.ts | 17 + .../ui/src/lib/i18n/messages/ne/settings.ts | 11 +- .../ui/src/lib/i18n/messages/ne/toolCall.ts | 10 + .../ui/src/lib/i18n/messages/ru/instance.ts | 17 + .../ui/src/lib/i18n/messages/ru/settings.ts | 11 +- .../ui/src/lib/i18n/messages/ru/toolCall.ts | 10 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 17 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 11 +- .../src/lib/i18n/messages/zh-Hans/toolCall.ts | 10 + packages/ui/src/lib/sse-manager.ts | 53 +-- packages/ui/src/stores/commands.ts | 21 +- packages/ui/src/stores/delta-buffer.test.ts | 102 ----- packages/ui/src/stores/delta-buffer.ts | 89 ---- packages/ui/src/stores/forms.test.ts | 57 +++ packages/ui/src/stores/forms.ts | 46 +++ packages/ui/src/stores/instances.ts | 227 +++++++++- .../stores/message-v2/message-status.test.ts | 10 +- .../src/stores/message-v2/message-status.ts | 8 + .../src/stores/message-v2/normalizers.test.ts | 63 +++ .../ui/src/stores/message-v2/normalizers.ts | 26 +- .../src/stores/permission-lifecycle.test.ts | 9 +- packages/ui/src/stores/pty-store.test.ts | 93 +++++ packages/ui/src/stores/pty-store.ts | 108 +++++ packages/ui/src/stores/ptys.ts | 17 + .../ui/src/stores/session-actions.test.ts | 109 ++++- packages/ui/src/stores/session-actions.ts | 35 +- packages/ui/src/stores/session-api.ts | 165 ++++++-- packages/ui/src/stores/session-events.ts | 388 ++++++++---------- .../ui/src/stores/session-list-options.ts | 2 + packages/ui/src/stores/session-models.ts | 34 +- .../src/stores/session-native-events.test.ts | 93 ++++- .../ui/src/stores/session-pagination.test.ts | 10 +- .../src/stores/session-pending-state.test.ts | 16 +- .../ui/src/stores/session-pending-state.ts | 11 +- .../stores/session-request-authority.test.ts | 235 ++++++++++- .../src/stores/session-send-lifecycle.test.ts | 138 +++---- packages/ui/src/stores/session-state.ts | 23 +- packages/ui/src/stores/session-status.test.ts | 11 +- packages/ui/src/stores/session-status.ts | 2 +- packages/ui/src/stores/sessions.ts | 15 +- .../ui/src/stores/wake-lock-eligibility.ts | 4 +- .../ui/src/styles/components/form-request.css | 81 ++++ packages/ui/src/styles/controls.css | 1 + packages/ui/src/types/instance.ts | 1 - packages/ui/src/types/message.ts | 34 -- packages/ui/src/types/session.test.ts | 30 +- packages/ui/src/types/session.ts | 19 +- 128 files changed, 4584 insertions(+), 1345 deletions(-) create mode 100644 packages/server/src/server/routes/events.test.ts create mode 100644 packages/server/src/settings/migrate.test.ts create mode 100644 packages/ui/src/components/form-request.test.ts create mode 100644 packages/ui/src/components/form-request.tsx create mode 100644 packages/ui/src/components/provider-auth/provider-options.test.ts create mode 100644 packages/ui/src/components/provider-auth/provider-options.ts create mode 100644 packages/ui/src/lib/filesystem-events.test.ts create mode 100644 packages/ui/src/lib/filesystem-events.ts delete mode 100644 packages/ui/src/stores/delta-buffer.test.ts delete mode 100644 packages/ui/src/stores/delta-buffer.ts create mode 100644 packages/ui/src/stores/forms.test.ts create mode 100644 packages/ui/src/stores/forms.ts create mode 100644 packages/ui/src/stores/pty-store.test.ts create mode 100644 packages/ui/src/stores/pty-store.ts create mode 100644 packages/ui/src/stores/ptys.ts create mode 100644 packages/ui/src/styles/components/form-request.css diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index a87e1674..d44c31ea 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -104,8 +104,11 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/provider-auth/provider-options.test.ts packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/components/unified-picker-path.test.ts + packages/ui/src/lib/filesystem-events.test.ts + packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts @@ -127,18 +130,23 @@ jobs: packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-pending-state.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts - name: Test restore ownership integration run: >- node --conditions=browser --import tsx --test --test-force-exit + packages/ui/src/components/form-request.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts + packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/session-actions.test.ts + packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts + packages/ui/src/stores/session-status.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 721d187d..3d64eefc 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -15,13 +15,14 @@ description: | ## Native OpenCode V2 Baseline -- The only OpenCode client dependency is exact version `@opencode-ai/client@0.0.0-next-17353` in server and UI. -- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`. +- The only OpenCode client dependency is the experimental `@opencode-ai/client` protocol at exact version `0.0.0-next-17353` in server and UI. Current public `@opencode-ai/sdk` docs describe a different contract. +- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode-ai/client` declarations. - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. -- The server owns one shared OpenCode service through `OpenCodeSharedService` and upstream `Service.ensure`; workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. +- The server owns one shared OpenCode service through `OpenCodeSharedService` and its custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. -- OpenCode owns session APIs, native Shell (`client.session.shell`) and session instructions (`client.session.instructions.entry`). +- OpenCode owns session APIs, native Shell (`client.session.shell`) and session instructions (`client.session.instructions.entry`). PTY/background-process parity is not integrated. - CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. +- V2 service startup requires a user-configured `OPENCODE_DB`. There is no default path; never share a V1 database with V2. Environment changes apply at service start/restart. ## Package Map @@ -48,7 +49,8 @@ description: | - Inspect installed declarations under `node_modules/@opencode-ai/client/dist/promise/`; generated names are the source of truth. - Preserve `LocationRef` and explicit directory routing. Never infer workspace ownership from a client-provided path. - Send CodeNomad operations through `/api/*`; send OpenCode operations through `/workspaces/:id/instance/api/*`. -- Consume the multiplexed CodeNomad SSE stream at `/api/events`; do not create one OpenCode process or event stream per workspace. +- Consume the multiplexed CodeNomad SSE stream at `/api/events`; do not create one OpenCode process or event stream per workspace. Native events are volatile, so reconnect must reconcile authoritative state. +- Treat the instance proxy allowlist as an integration boundary. Upstream routes are not exposed automatically. - Keep Git mutations and Yolo in CodeNomad. They are policy/security boundaries, not upstream client features. - Check `packages/server/src/api-types.ts` and UI consumers together when changing CodeNomad events or responses. @@ -56,10 +58,10 @@ description: | | Avoid | Use | |---|---| -| `@opencode-ai/sdk` | `@opencode-ai/client@0.0.0-next-17353` | -| One `opencode serve` per workspace | One `Service.ensure` shared service | +| Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client@0.0.0-next-17353` declarations | +| One `opencode serve` per workspace | One CodeNomad-managed shared service | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | -| Reintroducing `packages/opencode-plugin` | Native OpenCode Shell/instructions | +| Reintroducing `packages/opencode-plugin` | Native OpenCode Shell/instructions; no PTY/background parity claim | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index de87d124..0a4e76af 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -10,25 +10,25 @@ Electron/Tauri -> CodeNomad Fastify server -> one shared OpenCode service SolidJS UI <- /api/events <- event bridge ``` -The server calls `Service.ensure` once through `packages/server/src/workspaces/opencode-service.ts`. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; a workspace is a logical location owner, not an OpenCode child process. +The server uses `packages/server/src/workspaces/opencode-service.ts` for a custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle; production does not call `Service.ensure` or `Service.stop` directly. Transferable lease proof binds the registration and endpoint credentials to the daemon PID/process-start identity, host or WSL namespace, and launch signature. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; a workspace is a logical location owner, not an OpenCode child process. ## Boundaries | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | `@opencode-ai/client@0.0.0-next-17353` | +| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | experimental `@opencode-ai/client@0.0.0-next-17353` protocol | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | -`packages/opencode-plugin/` and the server plugin/background-process integration were deleted. Do not use those paths as extension points. +`packages/opencode-plugin/` and the server plugin/background-process integration were deleted. Native Shell is integrated; PTY/background-process parity is not. Do not use deleted paths as extension points. ## HTTP And Events - CodeNomad control endpoints live under `/api/*`, including `/api/workspaces`, Git routes and `/api/events`. -- OpenCode requests use `/workspaces/:id/instance/api/*`. The proxy injects service auth, validates supplied `location`/`directory` values, checks session ownership, and defaults safe requests to the workspace directory. +- OpenCode requests use `/workspaces/:id/instance/api/*`. The explicit method/path allowlist injects service auth, validates supplied paths and `location`/`directory` values, checks session ownership, and defaults safe requests to the workspace directory. New upstream routes require review and are not exposed automatically. - Yolo state endpoints currently use `/workspaces/:id/yolo/sessions/:sessionId`; state changes and auto-accept confirmations travel over `/api/events`. -- `InstanceEventBridge` subscribes once to the shared OpenCode event stream and publishes typed `instance.event` records on CodeNomad's event bus. +- `InstanceEventBridge` subscribes once to the volatile shared OpenCode event stream and publishes typed `instance.event` records on CodeNomad's event bus. Reconnect must refetch authoritative state because missed events are not replayed reliably. ## Persistence @@ -36,6 +36,8 @@ The server calls `Service.ensure` once through `packages/server/src/workspaces/o OpenCode location/workspace identity is upstream state. CodeNomad persists only its own preferences and policy metadata, including Yolo state. +OpenCode V2 additionally requires a user-supplied `OPENCODE_DB`. CodeNomad supplies no path; V1 and V2 databases must remain separate, and environment changes apply when the shared service starts/restarts. + ## Entry Points - Server: `packages/server/src/index.ts` diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 93743051..ad0aada3 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -4,16 +4,16 @@ 1. UI posts a folder to `/api/workspaces`. 2. `WorkspaceManager` resolves the binary launch spec and calls the single `OpenCodeSharedService`. -3. `Service.ensure` discovers or starts one shared `opencode serve --service` endpoint. +3. The CodeNomad adapter discovers or launches one shared `opencode serve --service` endpoint under a lifecycle lock and records transferable registration, endpoint, launch-signature, PID, process-start, and namespace proof. 4. `client.location.get` validates the directory and returns native location/workspace identity. 5. CodeNomad publishes workspace events on `/api/events` and exposes `/workspaces/:id/instance` as the authorized native API proxy. -6. On final owner deletion, CodeNomad calls `client.debug.location.evict`; server shutdown stops only its owned shared endpoint. +6. Final-owner deletion queues location eviction. Proven final shared-service shutdown flushes queued evictions and sends an authenticated stop only if no live CodeNomad peer remains and the exact daemon identity still matches. ## Prompt, Shell And Instructions 1. UI obtains `getRootClient(instanceId)`. 2. Conversation mode updates `client.session.instructions.entry` for the voice instruction. -3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. +3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. There is no integrated PTY/background-process parity. 4. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. 5. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. @@ -41,3 +41,5 @@ Do not replace mutation routes with OpenCode file/status calls; CodeNomad owns t - OpenCode events: shared `client.event.subscribe()` -> `InstanceEventBridge` -> `EventBus`. - CodeNomad events: workspace/Git-adjacent policy/Yolo producers -> `EventBus`. - Browser transport: `GET /api/events` with heartbeat/pong via `/api/client-connections/pong`. +- The native stream is volatile. On reconnect, refetch authoritative session/pending-request/file/config state rather than expecting replay. +- Current invalidations use `filesystem.changed` and `config.updated`; session lifecycle/output uses `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index bf28822c..6de7e9cc 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -2,20 +2,20 @@ ## Package -CodeNomad pins `@opencode-ai/client@0.0.0-next-17353` exactly in both `packages/server/package.json` and `packages/ui/package.json`. +CodeNomad pins the experimental `@opencode-ai/client` protocol to exact version `0.0.0-next-17353` in both `packages/server/package.json` and `packages/ui/package.json`. This is distinct from the current public `@opencode-ai/sdk` documentation. - Promise client: `import { OpenCode } from "@opencode-ai/client"` - Service lifecycle: `import { Service } from "@opencode-ai/client/service"` - Client construction: `OpenCode.make({ baseUrl, headers?, fetch? })` - Declarations: `node_modules/@opencode-ai/client/dist/promise/` -Do not import `@opencode-ai/sdk`; its V1/V2 wrapper shapes, `{ data, error }` conventions, and `createOpencodeClient()` do not apply. +Do not import `@opencode-ai/sdk`; its wrapper shapes, `{ data, error }` conventions, and `createOpencodeClient()` do not apply to this pinned experimental protocol build. ## Used Native APIs | Area | Calls | CodeNomad caller | |---|---|---| -| Service | `Service.discover/ensure/headers/stop` | `packages/server/src/workspaces/opencode-service.ts` | +| Service | `Service.discover/headers`; custom launch and authenticated stop | `packages/server/src/workspaces/opencode-service.ts` | | Location | `client.location.get`, `client.debug.location.evict` | shared service wrapper | | Events | `client.event.subscribe()` | `packages/server/src/workspaces/instance-events.ts` | | Sessions | `list/get/create/fork/remove/rename/prompt/command/shell/interrupt` | UI session stores | @@ -42,3 +42,5 @@ Do not look for these in the OpenCode client: - Multiplexed browser SSE at `/api/events` These use `packages/ui/src/lib/api-client.ts` and server routes. + +The instance proxy is method/path allowlisted. Adding an upstream client method does not make its route available through CodeNomad. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 6c267c93..d5d5ce19 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -2,29 +2,32 @@ ## Contract -- Version is pinned to `@opencode-ai/client@0.0.0-next-17353`; update server, UI, and the required `opencode2` CLI together. -- The package root is the generated zero-Effect Promise client. Use installed declarations, not old SDK examples. +- The experimental protocol version is pinned to `@opencode-ai/client@0.0.0-next-17353`; update server, UI, and the required `opencode2` CLI together. +- The package root is the generated zero-Effect Promise client. Use installed declarations, not current public `@opencode-ai/sdk` examples. - Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy. +- That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically. ## Location Is Authority - A CodeNomad workspace must validate through `client.location.get` before becoming ready. - Directory-bearing proxy input is untrusted and must resolve to the workspace root or one of its Git worktrees. - Session ID alone is insufficient: the proxy fetches the session and verifies `session.location.directory`. -- Evict an upstream location only after its final logical owner is deleted. +- Queue eviction after the final logical owner is deleted; flush it only during proven final shared-service shutdown. ## Shared Lifecycle -- There is one shared `Service.ensure`, client and upstream event subscription. +- There is one shared service, client and upstream event subscription. Production uses CodeNomad's custom lease-locked launcher and authenticated stop, not direct `Service.ensure`/`Service.stop`. - A workspace stop removes location ownership; it does not stop a dedicated OpenCode process. -- Shutdown stops the service only when CodeNomad started and still owns the discovered endpoint. +- Transferable proof records registration/credentials, daemon PID and process-start identity, host/WSL namespace, and launch signature. Shutdown stops only after no live peer remains and the proof still identifies the exact daemon. +- A user-configured `OPENCODE_DB` is required at service startup. There is no default, V1/V2 schemas must not share a database, and environment changes apply on service start/restart. +- The native event stream is volatile. Reconnect must reconcile authoritative state; use current `session.*`, `filesystem.changed`, and `config.updated` names rather than obsolete event aliases. ## Ownership Matrix | Concern | Owner | |---|---| -| Session/message/Shell/instructions | OpenCode native API | -| Service discovery/start/stop | OpenCode `Service`, wrapped by CodeNomad | +| Session/message/Shell/instructions | OpenCode native API; PTY/background parity not integrated | +| Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index f903aa1a..b369aa1f 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -2,15 +2,17 @@ ## Shared Service -`WorkspaceManager` owns one `OpenCodeSharedService`. Its first workspace calls upstream `Service.ensure`; later workspaces reuse/discover the same endpoint. The wrapper creates one server-side Promise client, performs health checks, owns shutdown only when CodeNomad started the endpoint, and invalidates failed connections. +`WorkspaceManager` owns one `OpenCodeSharedService`. Production discovers an existing endpoint or launches one with CodeNomad's own detached launcher; it does not call direct `Service.ensure`/`Service.stop`. The wrapper creates one server-side Promise client, performs health checks, and invalidates failed connections. -`Service.ensure` has no environment option. The wrapper temporarily overlays the configured environment only around the single launch call; do not create per-workspace services to avoid that limitation. +Lifecycle leases serialize processes and carry transferable proof: registration and endpoint credentials, daemon PID/process-start identity, host/WSL namespace, and launch signature. A peer can inherit proof, but only the final verified process may send the authenticated stop and wait for that daemon to exit. + +V2 requires a user-configured `OPENCODE_DB`; CodeNomad has no default. Never point V1 and V2 at the same database. The configured/inherited environment is part of the launch signature and applies when the service starts/restarts. ## Locations And Directories -Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory/workspace ID. Deletion calls `client.debug.location.evict` only after the final CodeNomad owner is gone. +Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory/workspace ID. Final-owner deletion queues `client.debug.location.evict`; proven final shared-service shutdown flushes it after excluding live peers. -The instance proxy rejects unowned `directory`, `location.directory`, and `location[directory]` values. It also resolves session IDs and verifies the session location before forwarding. Keep this check at the server trust boundary. +The instance proxy is method/path allowlisted, rejects unowned paths, `directory`, `location.directory`, and `location[directory]` values, and verifies session location before forwarding. Keep this check at the server trust boundary; new upstream routes require explicit review. ## UI Client @@ -24,7 +26,7 @@ Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts` - Shell mode calls `client.session.shell({ sessionID, command })`. - Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`. -- Do not recreate plugin-backed shell, voice instructions, or background-process routes. +- PTY and background-process feature parity are not integrated. ## Event Flow @@ -33,7 +35,7 @@ Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts` 3. `EventBus` also carries CodeNomad events such as workspace and Yolo changes. 4. `/api/events` multiplexes those records to the UI; `packages/ui/src/lib/sse-manager.ts` reconnects and dispatches them. -Optimistic UI updates must still reconcile with native events or a refetch after reconnect. +The native stream is volatile and does not guarantee replay. Reconnect must refetch authoritative sessions and pending requests; file/config consumers must also refresh after gaps. Current invalidations are `filesystem.changed` and `config.updated`, alongside native `session.*` lifecycle/output events. ## CodeNomad Policy Boundaries diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index c887bdcf..5fb03242 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -10,14 +10,16 @@ ## OpenCode Service - Use `OpenCodeSharedService` in `packages/server/src/workspaces/opencode-service.ts`. -- Keep one `Service.ensure` lifecycle and one event subscription for all workspaces. +- Keep one CodeNomad-managed shared-service lifecycle and one event subscription for all workspaces. Production lifecycle is custom and process-proofed; do not replace it with direct `Service.ensure`/`Service.stop`. - Model workspaces with native `LocationRef`/directories in `packages/server/src/workspaces/manager.ts`. - Never spawn or stop OpenCode per workspace and never add plugin installation/packaging. +- Require user-supplied `OPENCODE_DB` for V2 startup, never share it with V1, and remember environment changes apply only at service start/restart. ## Trust Boundaries - Validate every client-supplied directory before proxying. - Verify session location ownership for session routes. +- Keep the OpenCode proxy method/path allowlist explicit; upstream functionality is not inherited automatically. - Resolve worktree slugs server-side before filesystem or Git operations. - Keep Git path traversal checks and commit validation in CodeNomad. - Keep Yolo persistence and automatic permission replies server-side. diff --git a/AGENTS.md b/AGENTS.md index 395089a7..0917718b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ The UI uses a small custom i18n layer (no ICU/messageformat). When building feat - **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`. - **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code. - **Adding a new language:** add a new `messages//` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`. -- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`). +- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.yaml`; `config.json` is migration input only). - **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply. ## File Length Guidelines (Highlight Only) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index beae9213..5cfa93eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,10 +111,13 @@ Then open a pull request on GitHub targeting the `dev` branch. ### OpenCode V2 Boundaries -- Server and UI pin `@opencode-ai/client@0.0.0-next-17353`; do not add `@opencode-ai/sdk`. -- `packages/server/src/workspaces/opencode-service.ts` owns the single shared `Service.ensure` lifecycle. Workspaces are native OpenCode locations/directories, not separate server processes. +- Server and UI pin the experimental `@opencode-ai/client` protocol exactly to `0.0.0-next-17353`. It is not the current public `@opencode-ai/sdk` contract; use the installed declarations. +- `packages/server/src/workspaces/opencode-service.ts` owns a custom lease-locked discovery, launch, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode locations/directories, not separate server processes. +- V2 startup requires a user-configured, non-empty `OPENCODE_DB`; there is no CodeNomad default. Never reuse a V1 database for V2. Environment changes take effect when the shared service starts or restarts. - OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. -- Native `client.session.shell` and `client.session.instructions.entry` cover Shell and prompt instructions. There is no `packages/opencode-plugin` integration. +- The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. +- Native `client.session.shell` and `client.session.instructions.entry` cover Shell and prompt instructions. PTY/background-process parity is not integrated, and there is no `packages/opencode-plugin` integration. +- Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay. - Git mutations and Yolo policy remain CodeNomad-owned server boundaries. ### Key UI Files diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 601fe9a7..3b980131 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -8,14 +8,14 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Main Changes -- Replace `@opencode-ai/sdk` with the pinned native V2 client, `@opencode-ai/client@0.0.0-next-17353`. +- Replace `@opencode-ai/sdk` with the experimental `@opencode-ai/client` protocol, pinned exactly to `0.0.0-next-17353`. This contract is distinct from the current public `@opencode-ai/sdk` documentation. - Use one shared OpenCode V2 service instead of one runtime per workspace. - Represent CodeNomad workspaces as logical instances associated with absolute directories. - Use native `Location` and `SessionInfo.location` data to associate sessions, files, events, and Git worktrees. - Migrate sessions, messages, streaming events, permissions, questions, files, VCS, commands, MCP, providers, models, and agents to native V2 APIs. -- Handle native text, reasoning, tool, status, and terminal session events. +- Handle native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. - Use browser `EventSource` as the single desktop and web event transport; the duplicate Rust-native Tauri transport was removed. -- Reconcile session state through `session.active()` after reconnecting so missed events do not leave stale working states. +- Treat the native event stream as volatile. Reconnect has no replay guarantee, so clients must reconcile authoritative session and pending-request state after reconnect; file/config consumers must also refetch rather than assume every `filesystem.changed` or `config.updated` event was observed. - Route events from owned Git worktrees to their corresponding logical CodeNomad workspace. - Query native sessions for every known root and worktree directory instead of relying on an unsupported project-scope parameter. - Resolve locationless session events through native session ownership so prompt status and output reach the correct logical workspace. @@ -24,7 +24,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Remove the custom `packages/opencode-plugin` package. - Remove V1 plugin communication channels and per-workspace runtime management. -- Replace the custom background-process implementation with native V2 Shell and PTY APIs. +- Replace interactive shell-mode requests with native V2 Shell and expose native V2 PTYs in the Status panel. - Replace per-workspace OpenCode binary selection with one global `opencode2` binary. - Migrate the persisted V1 default command `opencode` to `opencode2` during workspace launch. - Remove message and part deletion controls because V2 currently has no equivalent API. @@ -39,38 +39,37 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Security and Service Lifecycle -- Restrict Shell and PTY working directories to workspace-owned roots and Git worktrees. +- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; PTY controls also verify the native PTY `cwd` before forwarding ID-scoped requests. - Remove CodeNomad authentication cookies before forwarding requests to OpenCode. - Prevent OpenCode `Set-Cookie` headers from being relayed to the browser. - Avoid logging unredacted secret-bearing proxy request bodies. +- Expose only an explicit method/path allowlist through the OpenCode proxy. New upstream APIs require an intentional proxy and ownership review; future OpenCode functionality is not automatic. - Share a consistent service registration location between Windows and WSL. - Require the shared service to match the pinned `0.0.0-next-17353` version. -- Stop a shared service only when CodeNomad can prove that its own process started it. +- Use CodeNomad's hardened discovery/launch lifecycle in production rather than direct `Service.ensure`/`Service.stop` calls. A lifecycle lease records the registration, authenticated endpoint, daemon PID plus process-start identity and host/WSL namespace, and a hash of the launch command/environment/version. Proof can transfer between live CodeNomad processes through peer leases; the final process stops the daemon only after proving there are no live peers and every recorded identity still matches. +- Queue location eviction when its final logical owner is removed, then perform it only during proven final shared-service shutdown so another CodeNomad process cannot lose active upstream state. +- Require the user to configure a non-empty `OPENCODE_DB` before V2 service startup. CodeNomad supplies no default path, and V1 and V2 must never point at the same database because their schemas are incompatible. Environment changes apply when the shared service next starts or restarts, not to an already-running daemon. - Isolate V2 restore state under `~/.codenomad/client-state/v2` and copy V1 state non-destructively on first launch, preserving downgrade history. ## Expected Benefits - Less custom integration code and fewer long-running processes. - Closer alignment with the supported OpenCode V2 architecture. -- Native access to future OpenCode functionality without maintaining V1 compatibility code. +- A smaller native integration surface without maintaining V1 compatibility code. - Consistent behavior between root workspaces and Git worktrees. - Simpler service startup, event handling, and client-side API access. ## Current Status -- Server and UI typechecks pass. -- The complete UI suite passes with 433 tests. -- Focused proxy, service ownership, worktree event routing, provider authentication, voice instruction, Windows, WSL, and Tauri tests pass. -- The pinned client and installed `opencode2` CLI are aligned on `0.0.0-next-17353`. -- The final critical/high security gate has no unresolved proxy, authentication, event-isolation, or process-ownership finding. -- A real `opencode2@0.0.0-next-17353` lifecycle smoke test passed: authenticated discovery, workspace location validation, and confirmed service shutdown. -- Startup restore now renders its loading state immediately instead of leaving a blank renderer while saved workspaces launch. -- The migration remains a Draft until the GitHub build matrix completes. +- The server/UI client dependency and required `opencode2` protocol baseline are pinned to `0.0.0-next-17353`. +- The current working tree includes hardened lifecycle proof, launch-configuration matching, required `OPENCODE_DB` validation, deferred location eviction, proxy path/location validation, and reconnect reconciliation changes. +- Native PTY V1 parity is limited by `@opencode-ai/client@0.0.0-next-17353`: it provides list/get/title-or-size update/remove and PTY lifecycle events, but no output/read/stream API and no separate stop API. Removing a running PTY is therefore the only native stop action, and PTY output is not displayed. +- The migration remains a Draft. The full validation matrix and a real current-tree OpenCode V2 startup/session/event/Shell/shutdown smoke test are not yet recorded complete. ## Remaining Work - Run the complete test and build matrix after the fixes. -- Run the GitHub build matrix by marking the PR ready for review. +- Run the real-service smoke test before marking the PR ready for review. ## Validation @@ -85,6 +84,6 @@ The final validation should include: ## Review Notes -- The OpenCode V2 client is still a beta contract and may change. +- The pinned OpenCode V2 protocol client is experimental and may change; public `@opencode-ai/sdk` examples are not authoritative for this build. - This branch intentionally provides no OpenCode V1 fallback. -- The branch should remain a Draft Pull Request until the security findings and real-service smoke test are complete. +- The branch should remain a Draft Pull Request until gatekeeper review, the validation matrix, and the real-service smoke test are complete. diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index cf775585..b6cd7907 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -80,7 +80,7 @@ tasks/ Task tracking - Complete project structure - TypeScript interfaces -- `Service.ensure` and location lifecycle +- Hardened shared-service proof and location lifecycle - Native Promise client management - Message rendering implementation - Build and packaging config @@ -127,9 +127,9 @@ tasks/ Task tracking **003 - Shared Service Manager** (4-5 hours) -- Discover or start one OpenCode service with `Service.ensure` +- Discover or launch one OpenCode service through CodeNomad's lease-locked process-proof lifecycle - Validate workspace locations/directories -- Stop only the shared endpoint CodeNomad owns +- Transfer proof to a live peer or stop only the exact proven daemon on final shutdown - Handle errors and timeouts - Auto-cleanup on app quit @@ -159,14 +159,14 @@ tasks/ Task tracking ### 2. Shared Service Management -- CodeNomad server discovers or starts one service with `Service.ensure` +- CodeNomad server discovers or launches one service through its hardened lifecycle; production does not call `Service.ensure`/`Service.stop` directly - Workspace folders become validated native locations - UI traffic stays behind the CodeNomad proxy -- Shutdown stops only the endpoint CodeNomad owns +- Shutdown transfers proof to a live peer or stops only the exact proven daemon when no peer remains ### 3. One Shared Service, Location-Scoped Clients -- One `Service.ensure` endpoint serves all workspace locations +- One proven shared endpoint serves all workspace locations - UI clients route through `/workspaces/:id/instance/api/*` - Server-side directory and session ownership prevents cross-contamination @@ -297,10 +297,13 @@ tasks/ Task tracking ## Current OpenCode Baseline -- Native client and required CLI: `@opencode-ai/client@0.0.0-next-17353` / `opencode2@0.0.0-next-17353` -- Service: one shared `Service.ensure` +- Experimental protocol client and required CLI: exact `@opencode-ai/client@0.0.0-next-17353` / `opencode2@0.0.0-next-17353`; public `@opencode-ai/sdk` docs do not describe this contract +- Service: one shared endpoint managed by CodeNomad's lease-locked process-proof lifecycle - Workspaces: native locations/directories -- Shell and instructions: native session APIs +- Database: user-supplied `OPENCODE_DB` is required for V2 and must never be shared with V1; changes apply at service start/restart +- Events: volatile native stream with authoritative reconnect reconciliation +- Proxy: explicit method/path allowlist; upstream additions are not automatic +- Shell and instructions: native session APIs; PTY/background-process parity is not integrated - Git mutations and Yolo: CodeNomad-owned ## Estimated Timeline diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index b4cc0c44..fe634b67 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates directly with native OpenCode V2 through exact dependency `@opencode-ai/client@0.0.0-next-17353`. +CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates with the experimental `@opencode-ai/client` protocol pinned exactly to `0.0.0-next-17353`, not the current public `@opencode-ai/sdk` contract. ```text Desktop host -> CodeNomad server -> one shared OpenCode service @@ -15,15 +15,17 @@ There is no `@opencode-ai/sdk` integration and no `packages/opencode-plugin` pac ## Shared Service And Locations -`packages/server/src/workspaces/opencode-service.ts` wraps native `Service.discover`, `Service.ensure`, `Service.headers` and `Service.stop`. The first workspace ensures one `opencode serve --service`; all workspaces share that endpoint, client and event stream. +`packages/server/src/workspaces/opencode-service.ts` uses native discovery and headers, but production startup/shutdown does not call `Service.ensure` or `Service.stop` directly. Its custom launcher serializes lifecycle changes with cross-process leases, records the registration and authenticated endpoint, proves daemon and CodeNomad PIDs with process-start identity in the host or WSL namespace, and binds that proof to a launch command/environment/version hash. Live peer leases can inherit that proof; only the final verified CodeNomad process may request authenticated shutdown and wait for the exact daemon to exit. + +`OPENCODE_DB` is a required user configuration for V2 startup. CodeNomad does not choose a path. V1 and V2 must use separate databases because their schemas are incompatible, and environment changes apply only when the shared service starts or restarts. `packages/server/src/workspaces/manager.ts` treats selected folders as native OpenCode locations: 1. Validate the directory with `client.location.get`. 2. Store the returned `LocationRef` and publish the logical workspace. 3. Reuse the shared service for every additional directory. -4. Evict a location only after its final CodeNomad owner is deleted. -5. Stop the shared service at CodeNomad shutdown only if CodeNomad started it. +4. Queue eviction after the final logical owner is deleted. +5. Flush queued evictions only during proven final shared-service shutdown, then stop only the exact daemon covered by transferable CodeNomad process proof. Workspaces are not OpenCode processes and do not own ports or PIDs. @@ -36,7 +38,7 @@ CodeNomad control APIs live under `/api/*`. Important routes include: - `/api/events` and `/api/client-connections/pong` - `/api/storage`, `/api/settings`, `/api/filesystem`, `/api/speech` -Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy adds shared-service authorization and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. +Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy exposes an explicit method/path allowlist, adds shared-service authorization, and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. Upstream additions require an explicit proxy review and are not available automatically. Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId`; Yolo notifications use `/api/events`. @@ -44,7 +46,9 @@ Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId `packages/ui/src/lib/sdk-manager.ts` uses `OpenCode.make()` and caches generated Promise clients by instance proxy path. `packages/ui/src/stores/opencode-client.ts` is the root-client authority; native directory/location fields replace old per-worktree SDK clients. -The server holds one `client.event.subscribe()` stream. `InstanceEventBridge` maps native location events to CodeNomad `instance.event` records, and `/api/events` multiplexes them with workspace and Yolo events for the browser. +The server holds one `client.event.subscribe()` stream. `InstanceEventBridge` maps native location events to CodeNomad `instance.event` records, and `/api/events` multiplexes them with workspace and Yolo events for the browser. The stream is volatile and has no replay guarantee: reconnect must refetch authoritative state. + +Current native events include session lifecycle/output events (`session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, `session.tool.*`), file invalidation via `filesystem.changed`, and configuration invalidation via `config.updated`. ## Feature Ownership @@ -53,12 +57,13 @@ The server holds one `client.event.subscribe()` stream. `InstanceEventBridge` ma | Sessions, messages, permission/question APIs | OpenCode V2 | | Shell mode | `client.session.shell` | | Conversation instructions | `client.session.instructions.entry` | +| PTY/background-process parity | Not integrated | | Workspace lifecycle and directory authorization | CodeNomad | | Git status/diff/stage/unstage/commit | CodeNomad server | | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | -Native Shell and session instructions replace the deleted plugin-backed integrations. Do not restore plugin background-process, voice-mode, channel or packaging paths. +Native Shell and session instructions replace the corresponding deleted plugin-backed integrations. Do not claim PTY/background-process parity or restore deleted plugin routes. ## Persistence diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 10a41425..f5813827 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -2,23 +2,17 @@ ## OpenCode Dependency -Server and UI pin `@opencode-ai/client@0.0.0-next-17353`. Import the generated Promise client from `@opencode-ai/client`; CodeNomad owns the safe shared-service launch lifecycle because the beta service helper does not expose sufficient process-identity guarantees. +Server and UI pin the experimental `@opencode-ai/client` protocol exactly to `0.0.0-next-17353`. Import the generated Promise client from `@opencode-ai/client`. This differs from the current public `@opencode-ai/sdk` documentation; verify signatures in the installed package. Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpencodeClient()`, or a `packages/opencode-plugin` package. Verify method signatures in `node_modules/@opencode-ai/client/dist/promise/`. ## Server Integration -`OpenCodeSharedService` is the sole service adapter: +`OpenCodeSharedService` is the sole service adapter. Production uses `Service.discover` and `Service.headers`, then a custom launcher and authenticated stop request; direct `Service.ensure` and `Service.stop` are not the production lifecycle. -```ts -const endpoint = await Service.ensure(options) -const client = OpenCode.make({ - baseUrl: endpoint.url, - headers: Service.headers(endpoint), -}) -``` +Startup and shutdown are serialized by filesystem leases. Each CodeNomad process proves its own PID/start identity and launch signature; service proof contains the registration contents, endpoint credentials, daemon PID/start identity, and host/WSL namespace. On exit, an owner transfers that proof to an elected live peer and releases its lease; a replacement can also inherit matching proof from a stale peer under the lifecycle lock. The final process stops only after all peers are proven stale/absent and the registration, endpoint, process identity, and launch signature still match; uncertainty retains the lease and leaks safely rather than signaling a PID. -It caches one connection, checks discovery before reuse, invalidates failures, subscribes to one native event stream, and stops only an endpoint it started. `Service.ensure` has no environment option, so the adapter temporarily overlays configured variables only during the shared launch. +`OPENCODE_DB` must be supplied by the user through the server environment configuration or inherited process environment. It has no hardcoded default. V1 and V2 schemas must never share a database. The complete environment is part of the launch signature and takes effect on service start/restart, not on an already-running daemon. Workspace creation passes a native location: @@ -26,7 +20,7 @@ Workspace creation passes a native location: await client.location.get({ location: { directory } }) ``` -`WorkspaceManager` records the returned directory/workspace ID and uses `client.debug.location.evict` after the final owner is removed. +`WorkspaceManager` records the returned directory/workspace ID. After the final logical owner is removed, eviction is queued and is sent only during proven final shared-service shutdown, after cross-process peer and daemon identity checks. ## UI Integration @@ -40,14 +34,14 @@ await client.session.shell({ sessionID, command }) await client.session.instructions.entry.put({ sessionID, key, value }) ``` -Shell mode and conversation instructions are upstream features. They do not require a CodeNomad plugin. +Shell mode and conversation instructions are upstream features. They do not require a CodeNomad plugin. CodeNomad does not currently integrate PTY or background-process parity. ## Routing And Security - CodeNomad operations: `packages/ui/src/lib/api-client.ts` -> `/api/*`. - OpenCode operations: generated client -> `/workspaces/:id/instance/api/*`. - Browser events: `GET /api/events`; heartbeat response: `POST /api/client-connections/pong`. -- The proxy checks client-provided directories, defaults safe requests to the workspace location, and verifies session ownership before forwarding. +- The proxy exposes only an explicit method/path allowlist, checks client-provided directories and prompt files, defaults safe requests to the workspace location, and verifies session ownership before forwarding. New OpenCode routes are unavailable until reviewed and allowlisted. Never trust a browser-supplied worktree path. Resolve workspace/worktree ownership server-side. @@ -59,7 +53,9 @@ Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, ob ## Events -`InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. The UI's `sse-manager.ts` handles the multiplexed stream and reconnects; stores reconcile optimistic state with events or refetches. +`InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. This stream is volatile: reconnection does not replay a guaranteed history, so UI stores refetch sessions and pending requests and other consumers must re-read authoritative file/config state. + +Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; file and config invalidations are `filesystem.changed` and `config.updated`. ## Current Structure diff --git a/packages/server/README.md b/packages/server/README.md index 3f19b5a1..5c633bfe 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -20,7 +20,8 @@ ## Prerequisites -- **OpenCode**: `opencode` must be installed and configured on your system. +- **OpenCode V2**: `opencode2` must be installed and configured on your system. This build requires protocol version `0.0.0-next-17353`. +- **OpenCode database**: Set a non-empty `OPENCODE_DB` path. V1 and V2 must use separate databases because their schemas are incompatible. - Node.js 18+ and npm (for running or building from source). - A workspace folder on disk you want to serve. - Optional: a Chromium-based browser if you want `--launch` to open the UI automatically. @@ -96,8 +97,9 @@ You can configure the server using flags or environment variables: | `--ui-dir ` | `CLI_UI_DIR` | Directory containing the built UI bundle | | `--ui-dev-server ` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | -| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` | +| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | +| - | `OPENCODE_DB` | Required OpenCode V2 database path. CodeNomad provides no default; do not reuse a V1 database. | ### Dev Releases (Advanced) @@ -215,8 +217,21 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo ### Data Storage -- **Config**: `~/.config/codenomad/config.json` -- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) +- **Stable server configuration**: `~/.config/codenomad/config.yaml` +- **Mutable server state**: `~/.config/codenomad/state.yaml` +- **Legacy migration input**: `~/.config/codenomad/config.json` is migrated to the YAML files above. +- **CodeNomad instance data**: `~/.config/codenomad/instances/` +- **OpenCode V2 sessions and messages**: the user-selected `OPENCODE_DB`; CodeNomad does not choose a default path. +- **Shared-service coordination state**: `~/.codenomad/state/opencode-v2/` +- **Desktop restore state**: `~/.codenomad/client-state/v2/` + +Changing the OpenCode binary or its environment, including `OPENCODE_DB`, takes effect when the shared service next starts or restarts. It does not reconfigure an already-running service. + +### Event Delivery + +CodeNomad holds one shared OpenCode V2 `client.event.subscribe()` stream. It routes native location-scoped events to logical workspaces and multiplexes them with CodeNomad events over `GET /api/events` for browser `EventSource` clients. + +The stream is volatile and has no replay guarantee. After reconnecting, clients must refetch authoritative sessions and pending permission, question, and form requests; file and config consumers must also refetch after `filesystem.changed` and `config.updated` invalidations. ### Provider Plan Usage diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index 432a70c2..a045d2cf 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -21,28 +21,36 @@ const noopLogger: Logger = { } as unknown as Logger function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { - bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) + const type = event.type === "permission.v2.asked" + ? "permission.asked" + : event.type === "permission.v2.replied" + ? "permission.replied" + : event.type + bus.publish({ type: "instance.event", instanceId, event: { ...event, type } as InstanceStreamEvent }) } -/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */ +/** Publish session creation using the compatibility shape produced by InstanceEventBridge. */ function publishSession( bus: EventBus, instanceId: string, eventType: "session.updated" | "session.created" | "session.deleted", info: Record, ) { - publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } }) + publishInstanceEvent(bus, instanceId, { + type: eventType === "session.updated" ? "session.created" : eventType, + properties: { info: { ...info } }, + }) } describe("AutoAcceptManager session tree", () => { - it("ingests session.updated to build the parent chain", () => { + it("ingests session.created to build the parent chain", () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) manager.start() - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + publishSession(bus, "inst", "session.created", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.created", { id: "child", parentID: "master" }) assert.equal(manager.isEnabled("inst", "master"), false) manager.toggle("inst", "child") @@ -52,32 +60,37 @@ describe("AutoAcceptManager session tree", () => { manager.stop() }) - it("treats a session with revert as a fork root", () => { + it("uses the exact V2 session.forked payload as the family boundary", () => { const bus = new EventBus(noopLogger) const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) manager.start() - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - publishSession(bus, "inst", "session.updated", { - id: "fork", - parentID: "master", - revert: { messageID: "m", partID: "p" }, + publishSession(bus, "inst", "session.created", { id: "master", parentID: null }) + manager.toggle("inst", "master") + publishInstanceEvent(bus, "inst", { + type: "session.forked", + properties: { + sessionID: "fork", + parentID: "master", + boundary: { type: "through", messageID: "m" }, + }, }) + assert.equal(manager.isEnabled("inst", "fork"), false) + assert.equal(manager.isEnabled("inst", "master"), true) manager.toggle("inst", "fork") assert.equal(manager.isEnabled("inst", "fork"), true) - assert.equal(manager.isEnabled("inst", "master"), false) manager.stop() }) - it("updates family boundaries from native revert events", async () => { + it("does not change the family boundary for revert events", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) manager.start() - publishSession(bus, "inst", "session.updated", { id: "root", parentID: null }) - publishSession(bus, "inst", "session.updated", { id: "child", parentID: "root" }) + publishSession(bus, "inst", "session.created", { id: "root", parentID: null }) + publishSession(bus, "inst", "session.created", { id: "child", parentID: "root" }) manager.toggle("inst", "root") publishInstanceEvent(bus, "inst", { @@ -89,7 +102,7 @@ describe("AutoAcceptManager session tree", () => { properties: { id: "staged", sessionID: "child" }, }) await flushMicrotasks() - assert.equal(replier.calls.length, 0) + assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged"]) publishInstanceEvent(bus, "inst", { type: "session.revert.cleared", @@ -134,6 +147,28 @@ describe("AutoAcceptManager session tree", () => { manager.stop() }) + it("does not auto-reply when API hydration rejects cross-workspace ownership", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: true }] }, + async loadSession() { return null }, + async persist() {}, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence }) + manager.start() + await manager.hydrateInstance("inst") + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "foreign-permission", sessionID: "foreign-session" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + it("session.deleted removes the tree entry but keeps the toggle", () => { const bus = new EventBus(noopLogger) const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) @@ -434,7 +469,7 @@ describe("AutoAcceptManager permission interception", () => { manager.stop() }) - it("auto-replies to a legacy permission.asked event", async () => { + it("auto-replies to the native permission.asked event", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) @@ -451,7 +486,7 @@ describe("AutoAcceptManager permission interception", () => { await flushMicrotasks() assert.equal(replier.calls.length, 1) - assert.equal(replier.calls[0].source, "legacy") + assert.equal(replier.calls[0].source, "v2") assert.equal(replier.calls[0].permissionId, "perm-2") manager.stop() diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index 1de933b7..c1f566f4 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -16,7 +16,7 @@ import { AutoAcceptStore, type AutoAcceptSessionInfo } from "./auto-accept-store * so the UI stays a pure view */ -export type PermissionSource = "v2" | "legacy" +export type PermissionSource = "v2" export type PermissionReplyValue = "once" export interface AutoAcceptReply { @@ -53,11 +53,10 @@ export interface AutoAcceptPersistence { persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise } -const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) -const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) -const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) +const PERMISSION_ASK_TYPES = new Set(["permission.asked"]) +const PERMISSION_REPLIED_TYPES = new Set(["permission.replied"]) +const SESSION_UPSERT_TYPES = new Set(["session.created"]) const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) -const SESSION_REVERT_TYPES = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"]) export class AutoAcceptManager { private static readonly MAX_REPLY_ATTEMPTS = 3 @@ -262,6 +261,10 @@ export class AutoAcceptManager { this.ingestSession(instanceId, event.properties) return } + if (event.type === "session.forked") { + this.ingestSessionForked(instanceId, event.properties) + return + } if (SESSION_REMOVE_TYPES.has(event.type)) { const info = (event.properties as { info?: SessionProperties } | undefined)?.info const id = readString(info?.id) ?? readString(event.properties?.id) @@ -271,16 +274,12 @@ export class AutoAcceptManager { } return } - if (SESSION_REVERT_TYPES.has(event.type)) { - this.ingestSessionRevert(instanceId, event.type, event.properties) - return - } if (PERMISSION_REPLIED_TYPES.has(event.type)) { this.handlePermissionReplied(instanceId, event.properties) return } if (PERMISSION_ASK_TYPES.has(event.type)) { - this.handlePermissionRequest(instanceId, event.type, event.properties) + this.handlePermissionRequest(instanceId, event.properties) } } @@ -294,28 +293,32 @@ export class AutoAcceptManager { | undefined if (!session || typeof session.id !== "string") return const parentId = session.parentID ?? session.parentId ?? null - const revert = session.revert ?? undefined const enabledBefore = this.store.enabledRoots(instanceId) - this.store.upsertSession(instanceId, { id: session.id, parentId, revert }) + this.store.upsertSession(instanceId, { id: session.id, parentId, fork: session.fork }) if (typeof session.workspaceID === "string" && session.workspaceID) { const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map() workspaces.set(session.id, session.workspaceID) this.sessionWorkspaces.set(instanceId, workspaces) } this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId)) - // Session ancestry may have changed (parent discovered, revert toggled). + // Session ancestry may have changed as parents are discovered. // Re-drain pending permissions whose family root may have migrated into // an enabled family — mirrors the old UI's drainAutoAcceptPermissions- - // ForInstance trigger on session.updated (#497). + // ForInstance trigger from the previous UI implementation (#497). this.drainPending(instanceId, session.id) } - private ingestSessionRevert(instanceId: string, eventType: string, properties: unknown): void { - const value = properties as { sessionID?: unknown; sessionId?: unknown; revert?: unknown } | undefined - const sessionId = readString(value?.sessionID) ?? readString(value?.sessionId) - if (!sessionId || !this.store.hasSession(instanceId, sessionId)) return + private ingestSessionForked(instanceId: string, properties: unknown): void { + const value = properties as { sessionID?: unknown; parentID?: unknown; boundary?: unknown } | undefined + const sessionId = readString(value?.sessionID) + const parentId = readString(value?.parentID) + if (!sessionId || !parentId || !value?.boundary) return const enabledBefore = this.store.enabledRoots(instanceId) - this.store.setSessionRevert(instanceId, sessionId, eventType === "session.revert.staged" ? value?.revert : undefined) + this.store.upsertSession(instanceId, { + id: sessionId, + parentId, + fork: { sessionID: parentId, boundary: value.boundary }, + }) this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId)) this.drainPending(instanceId, sessionId) } @@ -353,31 +356,43 @@ export class AutoAcceptManager { }) } - private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void { + private handlePermissionRequest(instanceId: string, permission: unknown): void { const request = permission as PermissionProperties | undefined if (!request) return const permissionId = readString(request.id) const sessionId = readString(request.sessionID) ?? readString(request.sessionId) if (!permissionId || !sessionId) return - // Infer source from the event type, but prefer the already-tracked source - // for permission.updated (which may belong to a v2 permission). - const existing = this.pending.get(instanceId)?.get(permissionId) - const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy") - - // `permission.updated` represents a detail change for a permission that - // is *already* pending. If it is no longer in our pending set it was - // already replied to (by us or the user) — skip to avoid a duplicate reply. - if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) { - return - } - + const source: PermissionSource = "v2" this.addPending(instanceId, { permissionId, sessionId, source }) - if (!this.store.hasSession(instanceId, sessionId) || !this.store.isEnabled(instanceId, sessionId)) return + if (!this.store.hasSession(instanceId, sessionId)) { + void this.hydrateSession(instanceId, sessionId) + return + } + if (!this.store.isEnabled(instanceId, sessionId)) return this.tryAutoAccept(instanceId, permissionId, sessionId, source) } + private async hydrateSession(instanceId: string, sessionId: string): Promise { + try { + const session = await this.deps.persistence?.loadSession?.(instanceId, sessionId) + if (!session || !Array.from(this.pending.get(instanceId)?.values() ?? []).some((entry) => entry.sessionId === sessionId)) return + this.ingestPersistedSession(instanceId, session) + this.drainPending(instanceId, sessionId) + } catch (error) { + this.deps.logger.warn({ instanceId, sessionId, err: error }, "Failed to hydrate Yolo permission session") + } + } + + private ingestPersistedSession(instanceId: string, session: PersistedAutoAcceptSession): void { + this.store.upsertSession(instanceId, session) + if (!session.workspaceId) return + const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map() + workspaces.set(session.id, session.workspaceId) + this.sessionWorkspaces.set(instanceId, workspaces) + } + private handlePermissionReplied(instanceId: string, properties: unknown): void { const request = (properties as PermissionRepliedProperties | undefined) ?? {} const permissionId = @@ -476,7 +491,7 @@ interface SessionProperties { id?: string parentID?: string | null parentId?: string | null - revert?: unknown + fork?: unknown workspaceID?: string } diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts index 5ccfd540..14a1026f 100644 --- a/packages/server/src/permissions/auto-accept-store.test.ts +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -25,10 +25,10 @@ describe("resolveFamilyRoot", () => { assert.equal(root, "master") }) - it("keeps a fork session (with revert) as its own root", () => { + it("keeps a session with native fork metadata as its own root", () => { const root = resolveFamilyRoot("fork", (id) => { if (id === "fork") - return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } } + return { id: "fork", parentId: "master", fork: { sessionID: "master", boundary: { type: "through", messageID: "msg" } } } if (id === "master") return { id: "master", parentId: null } return undefined }) @@ -84,7 +84,7 @@ describe("AutoAcceptStore inheritance", () => { store.upsertSession("inst", { id: "fork", parentId: "master", - revert: { messageID: "msg", partID: "part" }, + fork: { sessionID: "master", boundary: { type: "through", messageID: "msg" } }, }) store.setEnabled("inst", "fork", true) @@ -160,7 +160,7 @@ describe("AutoAcceptStore session tree maintenance", () => { assert.equal(store.isEnabled("inst", "master"), false) }) - it("changing revert status re-roots a session as a fork", () => { + it("discovering native fork metadata re-roots the session", () => { const store = new AutoAcceptStore() store.upsertSession("inst", { id: "master", parentId: null }) store.upsertSession("inst", { id: "child", parentId: "master" }) @@ -168,11 +168,11 @@ describe("AutoAcceptStore session tree maintenance", () => { // parent family enabled assert.equal(store.isEnabled("inst", "master"), true) - // child becomes a fork + // The exact session.forked event adds the native fork marker. store.upsertSession("inst", { id: "child", parentId: "master", - revert: { messageID: "m", partID: "p" }, + fork: { sessionID: "master", boundary: { type: "before", messageID: "m" } }, }) // now child resolves to itself; the family setting was on "master" so still on for master assert.equal(store.isEnabled("inst", "master"), true) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts index e2cdafe5..500b86c4 100644 --- a/packages/server/src/permissions/auto-accept-store.ts +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -5,7 +5,7 @@ * (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance * semantics are preserved exactly: * - state is keyed by the resolved *family root* session id - * - a session with a `revert` snapshot is treated as its own root (fork) + * - a session with native `fork` metadata is treated as its own root * - enabling any session enables its whole family root and vice-versa * * This store remains in-memory; AutoAcceptManager hydrates and persists it @@ -15,8 +15,7 @@ export interface AutoAcceptSessionInfo { id: string parentId?: string | null - /** Truthy value marks the session as a fork that roots at itself. */ - revert?: unknown + fork?: unknown } type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined @@ -36,7 +35,7 @@ export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): const session = getSession(currentId) if (!session) return lastKnownId lastKnownId = session.id - if (session.revert) return session.id + if (session.fork) return session.id if (!session.parentId) return session.id currentId = session.parentId } @@ -88,7 +87,7 @@ export class AutoAcceptStore { tree.set(info.id, { id: info.id, parentId: info.parentId ?? null, - revert: info.revert, + fork: info.fork, }) this.migrateEnabledRoots(instanceId) } @@ -101,13 +100,6 @@ export class AutoAcceptStore { return this.sessions.get(instanceId)?.has(sessionId) ?? false } - setSessionRevert(instanceId: string, sessionId: string, revert: unknown): void { - const session = this.sessions.get(instanceId)?.get(sessionId) - if (!session) return - session.revert = revert - this.migrateEnabledRoots(instanceId) - } - clearInstance(instanceId: string): void { this.sessions.delete(instanceId) this.enabled.delete(instanceId) @@ -125,7 +117,7 @@ export class AutoAcceptStore { /** * Re-resolves every enabled family root for an instance after the session - * tree changes (new session, updated parent/revert). If a root now resolves + * tree changes (new session or discovered fork). If a root now resolves * to a different id, the enabled entry is migrated so toggles survive late * ancestry discovery. */ diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts index 4371d0ba..c7722a0f 100644 --- a/packages/server/src/permissions/opencode-replier.test.ts +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -10,11 +10,15 @@ describe("createOpencodePermissionReplier", () => { it("uses the native permission reply input", async () => { const calls: Array> = [] const client = { + session: { + get: async () => ({ location: { directory: "/repo" } }), + }, permission: { reply: async (input: Record) => { calls.push(input) } }, } as unknown as OpenCodeClient const workspaceManager = { get: () => ({ path: "/repo" }), getSharedServiceClient: async () => client, + ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo", } as unknown as WorkspaceManager const replier = createOpencodePermissionReplier({ workspaceManager, logger: {} as Logger }) @@ -22,10 +26,33 @@ describe("createOpencodePermissionReplier", () => { instanceId: "instance", sessionId: "session", permissionId: "permission", - source: "legacy", + source: "v2", reply: "once", }) assert.deepEqual(calls, [{ sessionID: "session", requestID: "permission", reply: "once" }]) }) + + it("does not reply across logical workspace ownership", async () => { + const calls: Array> = [] + const client = { + session: { get: async () => ({ location: { directory: "/other" } }) }, + permission: { reply: async (input: Record) => { calls.push(input) } }, + } as unknown as OpenCodeClient + const workspaceManager = { + get: () => ({ path: "/repo" }), + getSharedServiceClient: async () => client, + ownsDirectory: async () => false, + } as unknown as WorkspaceManager + const replier = createOpencodePermissionReplier({ workspaceManager, logger: {} as Logger }) + + await assert.rejects(replier({ + instanceId: "instance", + sessionId: "foreign-session", + permissionId: "permission", + source: "v2", + reply: "once", + }), /does not belong/) + assert.deepEqual(calls, []) + }) }) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 0beb03e9..2e986d80 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -19,6 +19,11 @@ export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): Perm throw new Error(`Yolo: instance ${reply.instanceId} is not ready`) } + const session = await client.session.get({ sessionID: reply.sessionId }) + if (!(await deps.workspaceManager.ownsDirectory(reply.instanceId, session.location.directory))) { + throw new Error(`Yolo: session ${reply.sessionId} does not belong to workspace ${reply.instanceId}`) + } + await client.permission.reply({ sessionID: reply.sessionId, requestID: reply.permissionId, diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index 248c05f2..3d8854e0 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -6,7 +6,7 @@ import type { SettingsService } from "../settings/service" import type { WorkspaceManager } from "../workspaces/manager" import { createOpencodeYoloPersistence } from "./opencode-yolo-metadata" -function createHarness() { +function createHarness(serviceDirectory = "/repo") { let owner: Record = {} const settings = { getOwner: () => owner, @@ -18,32 +18,48 @@ function createHarness() { return owner }, } as unknown as SettingsService + const listInputs: Record[] = [] const workspaceManager = { get: () => ({ path: "/repo" }), - ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo", + getServiceDirectory: () => serviceDirectory, + ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo" || directory === "/worktree", } as unknown as WorkspaceManager const client = { session: { async list(input: Record) { - assert.deepEqual(input, { directory: "/repo", limit: 10_000 }) + listInputs.push(input) + const cursor = input.cursor return { - data: [ + data: cursor ? [ + { + id: "second-page", + parentID: undefined, + fork: undefined, + location: { directory: "/repo", workspaceID: "workspace" }, + }, + ] : [ { id: "root", parentID: undefined, - revert: undefined, + fork: undefined, location: { directory: "/repo", workspaceID: "workspace" }, }, ], - cursor: {}, + cursor: { next: cursor ? null : "page-2" }, } }, async get({ sessionID }: { sessionID: string }) { return { id: sessionID, parentID: undefined, - revert: undefined, - location: { directory: sessionID === "foreign" ? "/other" : "/repo", workspaceID: "workspace" }, + fork: sessionID === "worktree" ? { + sessionID: "root", + boundary: { type: "through", messageID: "message" }, + } : undefined, + location: { + directory: sessionID === "foreign" ? "/other" : sessionID === "worktree" ? "/worktree" : "/repo", + workspaceID: "workspace", + }, } }, }, @@ -53,22 +69,33 @@ function createHarness() { settings, async () => client, ) - return { persistence } + return { persistence, listInputs } } describe("OpenCode Yolo persistence", () => { it("loads native sessions and Yolo state from the CodeNomad store", async () => { - const { persistence } = createHarness() + const { persistence, listInputs } = createHarness() await persistence.persist("instance", "root", true) assert.deepEqual(await persistence.loadSessions("instance"), [ { id: "root", parentId: null, - revert: undefined, + fork: undefined, workspaceId: "workspace", yoloEnabled: true, }, + { + id: "second-page", + parentId: null, + fork: undefined, + workspaceId: "workspace", + yoloEnabled: false, + }, + ]) + assert.deepEqual(listInputs, [ + { directory: "/repo", limit: 10_000, cursor: undefined }, + { directory: "/repo", limit: 10_000, cursor: "page-2" }, ]) }) @@ -78,4 +105,24 @@ describe("OpenCode Yolo persistence", () => { assert.equal(await persistence.loadSession!("instance", "foreign"), null) }) + it("lists sessions with the translated service location", async () => { + const { persistence, listInputs } = createHarness("/service/repo") + await persistence.loadSessions("instance") + assert.equal(listInputs[0]?.directory, "/service/repo") + }) + + it("restores a persisted Yolo session from an owned worktree", async () => { + const { persistence } = createHarness() + await persistence.persist("instance", "worktree", true) + + const worktree = (await persistence.loadSessions("instance")).find((session) => session.id === "worktree") + assert.deepEqual(worktree, { + id: "worktree", + parentId: null, + fork: { sessionID: "root", boundary: { type: "through", messageID: "message" } }, + workspaceId: "workspace", + yoloEnabled: true, + }) + }) + }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index e09e298e..3606bfa2 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -24,6 +24,11 @@ function sessionState(settings: SettingsService, sessionId: string): PersistedSe return record(sessions[sessionId]) as PersistedSessionState } +function enabledSessionIds(settings: SettingsService): string[] { + const sessions = record(settings.getOwner("state", STATE_OWNER).sessions) + return Object.keys(sessions).filter((sessionId) => record(sessions[sessionId]).yoloEnabled === true) +} + export function createOpencodeYoloPersistence( workspaceManager: WorkspaceManager, settings: SettingsService, @@ -38,15 +43,22 @@ export function createOpencodeYoloPersistence( const listSessions = async (instanceId: string) => { const workspace = workspaceManager.get(instanceId) if (!workspace) throw new Error(`Yolo: instance ${instanceId} is not ready`) - return (await (await clientFor(instanceId)).session.list({ - directory: workspace.path, - limit: SESSION_LIST_LIMIT, - })).data + const directory = workspaceManager.getServiceDirectory(instanceId) + if (!directory) throw new Error(`Yolo: instance ${instanceId} has no service location`) + const client = await clientFor(instanceId) + const sessions: SessionInfo[] = [] + let cursor: string | undefined + do { + const page = await client.session.list({ directory, limit: SESSION_LIST_LIMIT, cursor }) + sessions.push(...page.data) + cursor = page.cursor.next ?? undefined + } while (cursor) + return sessions } const persistedSession = (session: SessionInfo): PersistedAutoAcceptSession => ({ id: session.id, parentId: session.parentID ?? null, - revert: session.revert, + fork: session.fork, workspaceId: session.location.workspaceID, yoloEnabled: sessionState(settings, session.id).yoloEnabled === true, }) @@ -67,7 +79,21 @@ export function createOpencodeYoloPersistence( return { async loadSessions(instanceId): Promise { - return (await listSessions(instanceId)).map(persistedSession) + const client = await clientFor(instanceId) + const sessions = new Map((await listSessions(instanceId)).map((session) => [session.id, session])) + await Promise.all(enabledSessionIds(settings).map(async (sessionId) => { + if (sessions.has(sessionId)) return + try { + const session = await client.session.get({ sessionID: sessionId }) + if (await workspaceManager.ownsDirectory(instanceId, session.location.directory)) sessions.set(session.id, session) + } catch { + // Stale persisted IDs are harmless and may belong to a stopped workspace. + } + })) + const owned = await Promise.all(Array.from(sessions.values()).map(async (session) => ( + await workspaceManager.ownsDirectory(instanceId, session.location.directory) ? persistedSession(session) : null + ))) + return owned.filter((session): session is PersistedAutoAcceptSession => session !== null) }, async loadSession(instanceId, sessionId): Promise { try { diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 73ff19ec..c91e41ad 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -18,6 +18,10 @@ async function harness( sessionDirectory = "/repo/worktree", activeSessions: Record = {}, sessionLocations: Record = {}, + workspacePath = "/repo", + serviceDirectory = workspacePath, + pathMappings: Record = {}, + ptyDirectories: Record = {}, ) { const upstream = Fastify() apps.push(upstream) @@ -31,9 +35,17 @@ async function harness( const address = upstream.server.address() assert.ok(address && typeof address === "object") - const owned = new Set(["/repo", "/repo/worktree"]) + const owned = new Set([workspacePath, serviceDirectory, "/repo", "/repo/worktree"]) const sessionGets: string[] = [] + const pathOwnershipChecks: string[] = [] + const servicePathCalls: string[] = [] const client = { + project: { + list: async () => [ + { id: "owned-project", canonical: serviceDirectory, time: { created: 1, updated: 1 }, sandboxes: [sessionDirectory, "/other"] }, + { id: "foreign-project", canonical: "/other", time: { created: 1, updated: 1 }, sandboxes: [] }, + ], + }, session: { get: async ({ sessionID }: { sessionID: string }) => { sessionGets.push(sessionID) @@ -43,21 +55,44 @@ async function harness( }, active: async () => activeSessions, }, + pty: { + list: async () => ({ + location: { directory: serviceDirectory, project: { id: "project", directory: serviceDirectory, canonical: serviceDirectory } }, + data: Object.entries(ptyDirectories).filter((entry): entry is [string, string] => typeof entry[1] === "string").map(([id, cwd]) => ({ + id, title: id, command: "npm", args: ["run", "dev"], cwd, status: "running" as const, pid: 42, + })), + }), + get: async ({ ptyID }: { ptyID: string }) => { + const cwd = ptyDirectories[ptyID] ?? sessionDirectory + if (cwd instanceof Error) throw cwd + return { data: { id: ptyID, title: ptyID, command: "npm", args: ["run", "dev"], cwd, status: "running", pid: 42 } } + }, + }, } as OpenCodeClient const manager: InstanceProxyWorkspaceManager = { - get: () => ({ id: "workspace", path: "/repo" }) as never, + get: () => ({ id: "workspace", path: workspacePath }) as never, getSharedServiceEndpoint: async () => ({ url: `http://127.0.0.1:${address.port}` }), getInstanceAuthorizationHeader: () => "Basic internal-secret", + getServiceDirectory: () => serviceDirectory, + getServiceDirectoryForPath: async (_id, directory) => directory === workspacePath ? serviceDirectory : owned.has(directory) ? directory : undefined, + getServicePathForPath: async (_id, candidate) => { + assert.ok(pathOwnershipChecks.includes(candidate), "prompt path must be ownership-checked before translation") + servicePathCalls.push(candidate) + return pathMappings[candidate] ?? candidate + }, getSharedServiceClient: async () => client, ownsDirectory: async (_id, directory) => owned.has(directory), - ownsPath: async (_id, candidate) => candidate === "/repo" || candidate.startsWith("/repo/"), + ownsPath: async (_id, candidate) => { + pathOwnershipChecks.push(candidate) + return candidate === "/repo" || candidate.startsWith("/repo/") || candidate in pathMappings + }, } const app = Fastify() apps.push(app) await app.register(replyFrom) registerInstanceProxyRoutes(app, { workspaceManager: manager, logger: logger() }) await app.ready() - return { app, sessionGets, requestCount: () => requests } + return { app, servicePathCalls, sessionGets, requestCount: () => requests } } describe("instance proxy location enforcement", () => { @@ -68,7 +103,9 @@ describe("instance proxy location enforcement", () => { url: "/workspaces/workspace/instance/api/session?directory=%2Frepo%2Fworktree&limit=5", }) assert.equal(listed.statusCode, 200) - assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Frepo%2Fworktree&limit=5") + const listedUrl = new URL(JSON.parse(listed.body).url, "http://localhost") + assert.equal(listedUrl.pathname, "/api/session") + assert.deepEqual(Object.fromEntries(listedUrl.searchParams), { directory: "/repo/worktree", limit: "5" }) const created = await app.inject({ method: "POST", @@ -76,7 +113,7 @@ describe("instance proxy location enforcement", () => { payload: { title: "test", location: { directory: "/repo/worktree", workspaceID: "worktree" } }, }) assert.equal(created.statusCode, 200) - assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo/worktree", workspaceID: "worktree" }) + assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo/worktree" }) }) it("defaults session list and create to the workspace root", async () => { @@ -88,6 +125,50 @@ describe("instance proxy location enforcement", () => { assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo" }) }) + it("allows the exact model default route", async () => { + const { app } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/model/default" }) + assert.equal(response.statusCode, 200) + assert.match(JSON.parse(response.body).url, /^\/api\/model\/default\?/) + }) + + it("allows ownership-scoped agent fallback lookups", async () => { + const { app } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/agent/build" }) + assert.equal(response.statusCode, 200) + assert.match(JSON.parse(response.body).url, /^\/api\/agent\/build\?/) + }) + + it("filters the project list and its sandboxes to the workspace", async () => { + const { app, requestCount } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/project" }) + assert.equal(response.statusCode, 200) + assert.deepEqual(JSON.parse(response.body), [{ + id: "owned-project", + canonical: "/repo", + time: { created: 1, updated: 1 }, + sandboxes: ["/repo/worktree"], + }]) + assert.equal(requestCount(), 0) + }) + + it("translates the WSL workspace root in proxied API locations without changing native paths", async () => { + const unc = String.raw`\\wsl.localhost\Ubuntu\home\dev\repo` + const { app } = await harness("/home/dev/repo", {}, {}, unc, "/home/dev/repo") + const listed = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?directory=${encodeURIComponent(unc)}`, + }) + assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Fhome%2Fdev%2Frepo") + + const created = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { location: { directory: unc, workspaceID: "caller-selector" } }, + }) + assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/home/dev/repo" }) + }) + it("rejects arbitrary locations instead of overwriting them", async () => { const { app, requestCount } = await harness() const bodyResponse = await app.inject({ @@ -116,6 +197,36 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 2) }) + it("allows only ownership-checked native PTY list, get, update, and remove routes", async () => { + const { app, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, { + owned: "/repo/worktree", + foreign: "/other", + }) + + const listed = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/pty?location%5Bdirectory%5D=%2Frepo%2Fworktree", + }) + assert.equal(listed.statusCode, 200) + assert.deepEqual(JSON.parse(listed.body).data.map((pty: { id: string }) => pty.id), ["owned"]) + assert.equal((await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/pty?location%5Bdirectory%5D=%2Fother", + })).statusCode, 403) + + for (const [method, payload] of [["GET", undefined], ["PUT", { title: "renamed" }], ["DELETE", undefined]] as const) { + const url = "/workspaces/workspace/instance/api/pty/owned?location%5Bdirectory%5D=%2Frepo%2Fworktree" + assert.equal((await app.inject({ method, url, payload })).statusCode, 200, method) + assert.equal((await app.inject({ method, url: url.replace("owned", "foreign"), payload })).statusCode, 403, method) + } + + assert.equal((await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/pty/owned/output?location%5Bdirectory%5D=%2Frepo%2Fworktree", + })).statusCode, 403) + assert.equal(requestCount(), 3) + }) + it("strips browser session and hop-by-hop headers in both directions", async () => { const { app } = await harness() const response = await app.inject({ @@ -203,7 +314,7 @@ describe("instance proxy location enforcement", () => { const response = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/${route}` }) assert.equal(response.statusCode, 403) } - for (const route of ["event", "project", "debug/location"]) { + for (const route of ["event", "debug/location"]) { const response = await app.inject({ method: "GET", url: `/workspaces/workspace/instance/api/${route}` }) assert.equal(response.statusCode, 403) } @@ -225,8 +336,66 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) - it("rejects foreign prompt file URIs and accepts owned files", async () => { - const { app, requestCount } = await harness() + it("rejects literal and encoded dot-segment aliases before authorization", async () => { + const { app, sessionGets, requestCount } = await harness("/other") + for (const route of [ + "api/session/owned/%2e%2e/foreign", + "api/session/owned/%252e%252e/%252e%252e/event", + "api/session/owned/../../debug/location", + ]) { + const response = await app.inject({ method: "GET", url: `/workspaces/workspace/instance/${route}` }) + assert.ok([400, 403, 404].includes(response.statusCode), `${route}: ${response.statusCode}`) + } + assert.deepEqual(sessionGets, ["foreign"]) + assert.equal(requestCount(), 0) + }) + + it("allows ownership-checked form request, list, reply, and cancel routes", async () => { + const owned = await harness() + for (const [method, route, payload] of [ + ["GET", "api/form/request", undefined], + ["GET", "api/session/owned/form", undefined], + ["POST", "api/session/owned/form/form-1/reply", { answer: { choice: "yes" } }], + ["POST", "api/session/owned/form/form-1/cancel", undefined], + ] as const) { + const response = await owned.app.inject({ method, url: `/workspaces/workspace/instance/${route}`, payload }) + assert.equal(response.statusCode, 200, route) + } + assert.deepEqual(owned.sessionGets, ["owned", "owned", "owned"]) + + const foreign = await harness("/other") + for (const [method, route] of [ + ["GET", "api/session/foreign/form"], + ["POST", "api/session/foreign/form/form-1/reply"], + ["POST", "api/session/foreign/form/form-1/cancel"], + ] as const) { + const response = await foreign.app.inject({ method, url: `/workspaces/workspace/instance/${route}` }) + assert.equal(response.statusCode, 403, route) + } + assert.equal(foreign.requestCount(), 0) + }) + + it("propagates session transport failures instead of mapping them to not found", async () => { + const { app, requestCount } = await harness("/repo/worktree", {}, { broken: new TypeError("fetch failed") }) + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/broken/form" }) + assert.equal(response.statusCode, 500) + assert.equal(requestCount(), 0) + }) + + it("maps only typed session-not-found failures to 404", async () => { + const missing = Object.assign(new Error("missing"), { _tag: "SessionNotFoundError", sessionID: "missing" }) + const { app } = await harness("/repo/worktree", {}, { missing }) + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/missing/form" }) + assert.equal(response.statusCode, 404) + }) + + it("validates prompt file ownership before translating root, worktree, and Windows URIs", async () => { + const mappings = { + "/repo/notes.txt": "/home/dev/repo/notes.txt", + "/repo/worktree/notes.txt": "/home/dev/worktree/notes.txt", + "C:/repo/notes.txt": "/mnt/c/repo/notes.txt", + } + const { app, servicePathCalls, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", mappings) const malformed = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/session-1/prompt", @@ -240,15 +409,31 @@ describe("instance proxy location enforcement", () => { payload: { text: "read this", files: [{ uri: "file:///other/secret.txt" }] }, }) assert.equal(foreign.statusCode, 403) + const traversed = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/session-1/prompt", + payload: { text: "read this", files: [{ uri: "file:///repo/worktree/../../other/secret.txt" }] }, + }) + assert.equal(traversed.statusCode, 403) assert.equal(requestCount(), 0) + assert.deepEqual(servicePathCalls, []) const owned = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/session-1/prompt", - payload: { text: "read this", files: [{ uri: "file:///repo/worktree/notes.txt" }] }, + payload: { text: "read this", files: [ + { uri: "file:///repo/notes.txt" }, + { uri: "file:///repo/worktree/notes.txt" }, + { uri: "file:///C:/repo/notes.txt" }, + ] }, }) assert.equal(owned.statusCode, 200) - assert.equal(JSON.parse(owned.body).body.files[0].uri, "file:///repo/worktree/notes.txt") + assert.deepEqual(JSON.parse(owned.body).body.files.map((file: { uri: string }) => file.uri), [ + "file:///home/dev/repo/notes.txt", + "file:///home/dev/worktree/notes.txt", + "file:///mnt/c/repo/notes.txt", + ]) + assert.deepEqual(servicePathCalls, Object.keys(mappings)) assert.equal(requestCount(), 1) }) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 861c6563..d9d99e9d 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,7 +9,7 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import type { OpenCodeClient } from "@opencode-ai/client" +import { isPtyNotFoundError, isSessionNotFoundError, type OpenCodeClient } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -365,6 +365,9 @@ export interface InstanceProxyWorkspaceManager { get(id: string): ReturnType getSharedServiceEndpoint(id: string): ReturnType getInstanceAuthorizationHeader(id: string): string | undefined + getServiceDirectory?(id: string): string | undefined + getServiceDirectoryForPath?(id: string, directory: string): Promise + getServicePathForPath?(id: string, candidate: string): Promise getSharedServiceClient(): Promise ownsDirectory(id: string, directory: string): Promise ownsPath(id: string, candidate: string): Promise @@ -565,7 +568,7 @@ async function proxyWorkspaceRequest(args: { } const rawInstancePath = (request.raw.url ?? "").split("?", 1)[0]?.match(/\/instance(?:\/(.*))?$/)?.[1] ?? "" - if (/\\|%2f|%5c/i.test(rawInstancePath)) { + if (/\\|%2f|%5c/i.test(rawInstancePath) || hasDotSegment(rawInstancePath)) { reply.code(400).send({ error: "Invalid workspace instance path" }) return } @@ -575,7 +578,7 @@ async function proxyWorkspaceRequest(args: { return } appendIncomingQuery(targetUrl, request.raw.url ?? "") - const pathname = normalizeInstanceSuffix(args.pathSuffix) + const pathname = decodeURIComponent(targetUrl.pathname) if (!isAllowedInstanceApiRoute(request.method, pathname)) { reply.code(403).send({ error: "OpenCode route is not available through a workspace" }) return @@ -598,7 +601,25 @@ async function proxyWorkspaceRequest(args: { reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable => entry !== null)) }) return } - const imported = prepareSessionImport(pathname, request.method, request.body, workspace.path) + if (pathname.replace(/\/+$/, "") === "/api/project") { + const projects = await (await workspaceManager.getSharedServiceClient()).project.list() + const ownedProjects = await Promise.all(projects.map(async (project) => { + if (!await workspaceManager.ownsDirectory(workspaceId, project.canonical)) return null + const sandboxes = (await Promise.all(project.sandboxes.map(async (directory) => ( + await workspaceManager.ownsDirectory(workspaceId, directory) ? directory : null + )))).filter((directory): directory is string => directory !== null) + return { ...project, sandboxes } + })) + reply.send(ownedProjects.filter((project): project is NonNullable => project !== null)) + return + } + const serviceDirectory = workspaceManager.getServiceDirectory?.(workspaceId) ?? workspace.path + const imported = prepareSessionImport( + pathname, + request.method, + stripLocationSelectors(targetUrl, request.body, workspace.path, serviceDirectory), + serviceDirectory, + ) const requestLocations = readRequestDirectories(targetUrl, imported.body) requestLocations.directories.push(...imported.directories) requestLocations.invalid ||= imported.invalid @@ -608,19 +629,72 @@ async function proxyWorkspaceRequest(args: { reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" }) return } + const translatedDirectories = new Map() + for (const directory of new Set(requestLocations.directories)) { + const translated = workspaceManager.getServiceDirectoryForPath + ? await workspaceManager.getServiceDirectoryForPath(workspaceId, directory) + : directory + if (!translated) { + reply.code(403).send({ error: "Location does not belong to workspace" }) + return + } + translatedDirectories.set(directory, translated) + } + const serviceBody = replaceRequestDirectories(targetUrl, imported.body, translatedDirectories, pathname, request.method) if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) { reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" }) return } + const translatedPromptPaths = new Map() + for (const candidate of new Set(promptFiles.paths)) { + const translated = workspaceManager.getServicePathForPath + ? await workspaceManager.getServicePathForPath(workspaceId, candidate) + : candidate + if (!translated) { + reply.code(403).send({ error: "Prompt file does not belong to workspace" }) + return + } + translatedPromptPaths.set(candidate, translated) + } + const promptBody = replacePromptFileUris(serviceBody, translatedPromptPaths) + + const requestedDirectory = requestLocations.directories[0] + const ptyLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } + if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") { + const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: ptyLocation }) + const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))) + reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) + return + } + + const ptyId = getPtyRouteId(pathname) + if (ptyId) { + try { + const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: ptyLocation }) + if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) { + reply.code(403).send({ error: "PTY does not belong to workspace" }) + return + } + } catch (error) { + if (isPtyNotFoundError(error)) { + reply.code(404).send({ error: "PTY not found" }) + return + } + throw error + } + } const sessionId = getSessionRouteId(pathname) if (sessionId) { let session try { session = await (await workspaceManager.getSharedServiceClient()).session.get({ sessionID: sessionId }) - } catch { - reply.code(404).send({ error: "Session not found" }) - return + } catch (error) { + if (isSessionNotFoundError(error)) { + reply.code(404).send({ error: "Session not found" }) + return + } + throw error } if (!(await workspaceManager.ownsDirectory(workspaceId, session.location.directory))) { reply.code(403).send({ error: "Session does not belong to workspace" }) @@ -628,7 +702,7 @@ async function proxyWorkspaceRequest(args: { } } - const body = applyDefaultWorkspaceLocation(targetUrl, imported.body, request.method, workspace.path, requestLocations.directories.length > 0, Boolean(sessionId)) + const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0, Boolean(sessionId)) const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance") @@ -786,9 +860,13 @@ function getSessionRouteId(pathname: string): string | null { return match[1] } +function getPtyRouteId(pathname: string): string | null { + return pathname.match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null +} + function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null { const suffix = pathSuffix ?? "" - if (/\\|%2f|%5c/i.test(suffix)) return null + if (/\\|%2f|%5c/i.test(suffix) || hasDotSegment(suffix)) return null const targetUrl = new URL(endpoint) const origin = targetUrl.origin targetUrl.pathname = normalizeInstanceSuffix(suffix).split("/").map(encodeURIComponent).join("/") @@ -797,15 +875,39 @@ function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined return targetUrl.origin === origin ? targetUrl : null } +function hasDotSegment(value: string): boolean { + return value.split("/").some((segment) => { + let decoded = segment + for (let depth = 0; depth < 3; depth++) { + if (decoded === "." || decoded === "..") return true + try { + const next = decodeURIComponent(decoded) + if (next === decoded) break + decoded = next + } catch { + break + } + } + return decoded === "." || decoded === ".." + }) +} + function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ ["GET", /^\/api\/(?:agent|command|config|integration|mcp|model|provider)$/], + ["GET", /^\/api\/agent\/[^/]+$/], + ["GET", /^\/api\/model\/default$/], ["GET", /^\/api\/(?:permission|question)\/request$/], + ["GET", /^\/api\/form\/request$/], ["GET", /^\/api\/project\/current$/], + ["GET", /^\/api\/project$/], ["GET", /^\/api\/vcs\/status$/], ["GET", /^\/api\/fs\/(?:list|read\/.+)$/], + ["GET", /^\/api\/pty(?:\/[^/]+)?$/], ["POST", /^\/api\/(?:pty|shell)$/], + ["PUT", /^\/api\/pty\/[^/]+$/], + ["DELETE", /^\/api\/pty\/[^/]+$/], ["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/], ["DELETE", /^\/api\/credential\/[^/]+$/], ["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/], @@ -815,6 +917,7 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["GET", /^\/api\/session(?:\/active)?$/], ["POST", /^\/api\/session(?:\/import)?$/], ["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/], + ["GET", /^\/api\/session\/[^/]+\/form$/], ["DELETE", /^\/api\/session\/[^/]+$/], ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|fork)$/], ["POST", /^\/api\/session\/[^/]+\/revert\/stage$/], @@ -822,11 +925,85 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["DELETE", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/], ["POST", /^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/], ["POST", /^\/api\/session\/[^/]+\/question\/[^/]+\/(?:reply|reject)$/], + ["POST", /^\/api\/session\/[^/]+\/form\/[^/]+\/(?:reply|cancel)$/], ["GET", /^\/api\/experimental\/session\/[^/]+\/log$/], ] return allowed.some(([allowedMethod, pattern]) => method === allowedMethod && pattern.test(route)) } +function replaceRequestDirectories( + targetUrl: URL, + body: unknown, + replacements: ReadonlyMap, + pathname: string, + method: string, +): unknown { + for (const key of ["directory", "location[directory]"]) { + const values = targetUrl.searchParams.getAll(key) + if (!values.some((value) => replacements.has(value))) continue + targetUrl.searchParams.delete(key) + for (const value of values) targetUrl.searchParams.append(key, replacements.get(value) ?? value) + } + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body + const replaceLocation = (value: unknown): unknown => { + if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) return value + const location = value as Record + return typeof location.directory === "string" && replacements.has(location.directory) + ? { ...location, directory: replacements.get(location.directory) } + : value + } + const input = { ...(body as Record) } + for (const key of ["directory", "cwd"]) { + const value = input[key] + if (typeof value === "string" && replacements.has(value)) input[key] = replacements.get(value) + } + input.location = replaceLocation(input.location) + if (pathname !== "/api/session/import" || method !== "POST") return input + input.info = input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info) + ? { ...(input.info as Record), location: replaceLocation((input.info as Record).location) } + : input.info + if (Array.isArray(input.messages)) { + input.messages = input.messages.map((value) => { + if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) return value + const message = value as Record + if (message.type !== "location-switched") return value + const next: Record = { ...message, location: replaceLocation(message.location) } + if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) { + next.previous = { + ...(message.previous as Record), + location: replaceLocation((message.previous as Record).location), + } + } + return next + }) + } + return input +} + +function stripLocationSelectors(targetUrl: URL, body: unknown, workspaceDirectory: string, serviceDirectory: string): unknown { + for (const key of ["directory", "location[directory]"]) { + const values = targetUrl.searchParams.getAll(key) + if (values.includes(workspaceDirectory)) { + targetUrl.searchParams.delete(key) + for (const value of values) targetUrl.searchParams.append(key, value === workspaceDirectory ? serviceDirectory : value) + } + } + for (const key of ["workspace", "workspaceID", "location[workspace]", "location[workspaceID]"]) { + targetUrl.searchParams.delete(key) + } + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body + const input = body as Record + const canonicalInput = { ...input } + for (const key of ["directory", "cwd"]) { + if (canonicalInput[key] === workspaceDirectory) canonicalInput[key] = serviceDirectory + } + const location = input.location + if (!location || typeof location !== "object" || Array.isArray(location) || Buffer.isBuffer(location)) return canonicalInput + const { workspace: _workspace, workspaceID: _workspaceID, ...canonicalLocation } = location as Record + if (canonicalLocation.directory === workspaceDirectory) canonicalLocation.directory = serviceDirectory + return { ...canonicalInput, location: canonicalLocation } +} + function readPromptFilePaths(pathname: string, method: string, body: unknown) { const result = { paths: [] as string[], invalid: false } if (method !== "POST" || !/^\/api\/session\/[^/]+\/(?:prompt|command)\/?$/.test(pathname)) return result @@ -873,6 +1050,26 @@ function parsePromptFileUri(value: string): { path?: string; invalid: boolean } } } +function replacePromptFileUris(body: unknown, replacements: ReadonlyMap): unknown { + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body + const input = body as Record + if (!Array.isArray(input.files)) return body + return { + ...input, + files: input.files.map((file) => { + if (!file || typeof file !== "object" || Array.isArray(file) || Buffer.isBuffer(file)) return file + const source = file as Record + if (typeof source.uri !== "string" || !/^file:/i.test(source.uri)) return file + const parsed = parsePromptFileUri(source.uri) + const translated = parsed.path ? replacements.get(parsed.path) : undefined + if (!translated || translated === parsed.path) return file + const uri = new URL("file:///") + uri.pathname = translated.replace(/\\/g, "/") + return { ...source, uri: uri.href } + }), + } +} + function prepareSessionImport(pathname: string, method: string, body: unknown, directory: string) { const result = { body, directories: [] as string[], invalid: false } if (pathname !== "/api/session/import" || method !== "POST") return result diff --git a/packages/server/src/server/routes/events.test.ts b/packages/server/src/server/routes/events.test.ts new file mode 100644 index 00000000..ce3ee0ec --- /dev/null +++ b/packages/server/src/server/routes/events.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import Fastify from "fastify" +import type { EventBus } from "../../events/bus" +import type { Logger } from "../../logger" +import { registerEventRoutes } from "./events" + +const logger = { debug() {}, trace() {}, isLevelEnabled() { return false } } as unknown as Logger + +function harness(options: { limit?: number; timeout?: number } = {}) { + const app = Fastify() + let listener: ((event: any) => void) | undefined + let closeClient: (() => void) | undefined + let raw: NodeJS.EventEmitter | undefined + let unsubscribed = 0 + let unregistered = 0 + const eventBus = { + onEvent(next: (event: any) => void) { + listener = next + return () => { unsubscribed += 1 } + }, + } as EventBus + app.addHook("onRequest", (_request, reply, done) => { + raw = reply.raw + const originalWrite = reply.raw.write.bind(reply.raw) + let first = true + reply.raw.write = ((...args: Parameters) => { + const result = originalWrite(...args) + if (first) { + first = false + return false + } + return result + }) as typeof reply.raw.write + done() + }) + registerEventRoutes(app, { + eventBus, + registerClient: (close) => { + closeClient = close + return () => { unregistered += 1 } + }, + connectionManager: { + register: () => () => { unregistered += 1 }, + pong: () => false, + } as never, + logger, + backpressureLimitBytes: options.limit, + backpressureTimeoutMs: options.timeout, + }) + return { + app, + emit(event: any) { assert.ok(listener); listener(event) }, + drain() { assert.ok(raw); raw.emit("drain") }, + close() { assert.ok(closeClient); closeClient() }, + ready: () => Boolean(listener && closeClient), + counts: () => ({ unsubscribed, unregistered }), + } +} + +async function waitFor(check: () => boolean): Promise { + const deadline = Date.now() + 1_000 + while (!check()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for SSE route") + await new Promise((resolve) => setImmediate(resolve)) + } +} + +describe("SSE backpressure", () => { + it("queues after write(false), flushes on drain, and remains connected", async () => { + const test = harness() + try { + const response = test.app.inject({ method: "GET", url: "/api/events?clientId=client&connectionId=connection" }) + await waitFor(test.ready) + test.emit({ type: "workspace.stopped", workspaceId: "first", reason: "deleted" }) + test.emit({ type: "workspace.stopped", workspaceId: "second", reason: "deleted" }) + assert.deepEqual(test.counts(), { unsubscribed: 0, unregistered: 0 }) + test.drain() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(test.counts(), { unsubscribed: 0, unregistered: 0 }) + test.close() + const result = await response + assert.match(result.body, /"workspaceId":"first"/) + assert.match(result.body, /"workspaceId":"second"/) + assert.deepEqual(test.counts(), { unsubscribed: 1, unregistered: 0 }) + } finally { + await test.app.close() + } + }) + + it("disconnects deterministically when drain times out", async () => { + const test = harness({ timeout: 10 }) + try { + const response = test.app.inject({ method: "GET", url: "/api/events?clientId=client&connectionId=connection" }) + await waitFor(test.ready) + test.emit({ type: "workspace.stopped", workspaceId: "first", reason: "deleted" }) + await response.catch(() => undefined) + assert.deepEqual(test.counts(), { unsubscribed: 1, unregistered: 2 }) + } finally { + await test.app.close() + } + }) + + it("disconnects when the bounded pending buffer is exceeded", async () => { + const test = harness({ limit: 256, timeout: 1_000 }) + try { + const response = test.app.inject({ method: "GET", url: "/api/events?clientId=client&connectionId=connection" }) + await waitFor(test.ready) + test.emit({ type: "workspace.stopped", workspaceId: "first", reason: "deleted" }) + test.emit({ type: "workspace.stopped", workspaceId: "x".repeat(512), reason: "deleted" }) + await response.catch(() => undefined) + assert.deepEqual(test.counts(), { unsubscribed: 1, unregistered: 2 }) + } finally { + await test.app.close() + } + }) +}) diff --git a/packages/server/src/server/routes/events.ts b/packages/server/src/server/routes/events.ts index 158266e1..1309c352 100644 --- a/packages/server/src/server/routes/events.ts +++ b/packages/server/src/server/routes/events.ts @@ -10,6 +10,8 @@ interface RouteDeps { registerClient: (cleanup: () => void) => () => void logger: Logger connectionManager: ClientConnectionManager + backpressureLimitBytes?: number + backpressureTimeoutMs?: number } let nextClientId = 0 @@ -38,44 +40,106 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { reply.raw.flushHeaders?.() reply.hijack() + let unsubscribe = () => {} + let unregister = () => {} + let unregisterConnection = () => {} + let heartbeat: ReturnType | undefined + let drainTimeout: ReturnType | undefined + let closed = false + let cleaned = false + let backpressured = false + let bufferedBytes = 0 + const pending: string[] = [] + const backpressureLimitBytes = Math.max(1, deps.backpressureLimitBytes ?? 1024 * 1024) + const backpressureTimeoutMs = Math.max(1, deps.backpressureTimeoutMs ?? 10_000) + const clearDrain = () => { + reply.raw.off("drain", handleDrain) + if (drainTimeout) clearTimeout(drainTimeout) + drainTimeout = undefined + } + const close = (force = false) => { + if (closed) return + closed = true + if (heartbeat) clearInterval(heartbeat) + clearDrain() + pending.length = 0 + bufferedBytes = 0 + unsubscribe() + if (force) reply.raw.destroy() + else reply.raw.end?.() + deps.logger.debug({ clientId }, "SSE client disconnected") + } + const handleClose = (force = false) => { + if (cleaned) return + cleaned = true + close(force) + unregister() + unregisterConnection() + } + const waitForDrain = () => { + backpressured = true + reply.raw.once("drain", handleDrain) + drainTimeout = setTimeout(() => handleClose(true), backpressureTimeoutMs) + } + function handleDrain() { + if (closed) return + clearDrain() + backpressured = false + bufferedBytes = pending.reduce((total, payload) => total + Buffer.byteLength(payload), 0) + while (pending.length) { + const payload = pending.shift()! + bufferedBytes -= Buffer.byteLength(payload) + if (!reply.raw.write(payload)) { + bufferedBytes += Buffer.byteLength(payload) + waitForDrain() + return + } + } + bufferedBytes = 0 + } + const write = (payload: string) => { + if (closed) return + const bytes = Buffer.byteLength(payload) + if (bytes > backpressureLimitBytes || bufferedBytes + bytes > backpressureLimitBytes) { + handleClose(true) + return + } + if (backpressured) { + pending.push(payload) + bufferedBytes += bytes + return + } + if (!reply.raw.write(payload)) { + bufferedBytes = bytes + waitForDrain() + } + } const send = (event: WorkspaceEventPayload) => { deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched") if (deps.logger.isLevelEnabled("trace")) { deps.logger.trace({ clientId, event }, "SSE event payload") } - reply.raw.write(`data: ${JSON.stringify(event)}\n\n`) + write(`data: ${JSON.stringify(event)}\n\n`) } - const unsubscribe = deps.eventBus.onEvent(send) - const heartbeat = setInterval(() => { + unsubscribe = deps.eventBus.onEvent(send) + if (closed) { + unsubscribe() + return + } + heartbeat = setInterval(() => { const ping = { ts: Date.now() } - reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`) + write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`) }, 15000) - let closed = false - const close = () => { - if (closed) return - closed = true - clearInterval(heartbeat) - unsubscribe() - reply.raw.end?.() - deps.logger.debug({ clientId }, "SSE client disconnected") - } - - const unregister = deps.registerClient(close) - const unregisterConnection = deps.connectionManager.register({ + unregister = deps.registerClient(close) + unregisterConnection = deps.connectionManager.register({ ...connection, close, }) - const handleClose = () => { - close() - unregister() - unregisterConnection() - } - - request.raw.on("close", handleClose) - request.raw.on("error", handleClose) + request.raw.on("close", () => handleClose()) + request.raw.on("error", () => handleClose()) }) app.post("/api/client-connections/pong", (request, reply) => { diff --git a/packages/server/src/settings/migrate.test.ts b/packages/server/src/settings/migrate.test.ts new file mode 100644 index 00000000..cbebd1ed --- /dev/null +++ b/packages/server/src/settings/migrate.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import { parse as parseYaml } from "yaml" +import type { Logger } from "../logger" +import { resolveConfigLocation } from "../config/location" +import { migrateSettingsLayout } from "./migrate" + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +describe("settings migration", () => { + it("drops OPENCODE_DB while preserving unrelated environment variables", () => { + const root = mkdtempSync(path.join(tmpdir(), "codenomad-migrate-")) + roots.push(root) + const location = resolveConfigLocation(path.join(root, "config.json")) + writeFileSync(location.legacyJsonPath, JSON.stringify({ + preferences: { environmentVariables: { OPENCODE_DB: "/legacy/opencode.db", KEEP_ME: "yes" } }, + })) + + const logger = { info() {}, warn() {} } as unknown as Logger + migrateSettingsLayout(location, logger) + + const migrated = parseYaml(readFileSync(location.configYamlPath, "utf8")) + assert.deepEqual(migrated.server.environmentVariables, { KEEP_ME: "yes" }) + }) +}) diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index d734ea3e..b5937ee3 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -101,7 +101,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co // Server-owned stable keys const envVars = preferences.environmentVariables if (isPlainObject(envVars)) { - serverConfig.environmentVariables = envVars + serverConfig.environmentVariables = omitKeys(envVars, new Set(["OPENCODE_DB"])) } const listeningMode = preferences.listeningMode if (typeof listeningMode === "string") { diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index 7e2a1641..05482cf2 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -1,10 +1,18 @@ import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import { buildServiceLaunchSpec, buildWindowsSpawnSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" +import { + buildServiceLaunchSpec, + buildWindowsSpawnSpec, + parseWslUncPath, + resolveWslHostDirectory, + resolveWslServiceDirectory, + resolveWslWorkingDirectory, +} from "../spawn" describe("parseWslUncPath", () => { it("parses WSL UNC paths into distro and linux path", () => { @@ -145,11 +153,36 @@ describe("buildWindowsSpawnSpec", () => { { env: { NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`, + OPENCODE_DB: String.raw`C:\state\opencode.db`, }, }, ) - assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p") + assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p:OPENCODE_DB/p") + }) + + it("preserves a Linux-native OPENCODE_DB path in WSL", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { env: { OPENCODE_DB: "/home/dev/.local/share/opencode.db", WSLENV: "OPENCODE_DB/lp" } }, + ) + + assert.equal(spec.env?.OPENCODE_DB, "/home/dev/.local/share/opencode.db") + assert.equal(spec.env?.WSLENV, "OPENCODE_DB/l") + }) + + it("marks Windows and UNC OPENCODE_DB paths for WSL translation", () => { + for (const database of [String.raw`C:\state\opencode.db`, String.raw`\\server\state\opencode.db`]) { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { env: { OPENCODE_DB: database } }, + ) + + assert.equal(spec.env?.OPENCODE_DB, database) + assert.equal(spec.env?.WSLENV, "OPENCODE_DB/p") + } }) it("propagates requested configured variables into WSL", () => { @@ -217,6 +250,54 @@ describe("buildWindowsSpawnSpec", () => { }) +describe("resolveWslServiceDirectory", () => { + it("converts WSL UNC paths without invoking wslpath", () => { + assert.equal( + resolveWslServiceDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu", () => { + throw new Error("wslpath should not run") + }), + "/home/dev/workspace", + ) + }) + + it("uses wslpath for Windows workspace paths", () => { + assert.equal( + resolveWslServiceDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu", (folder, distro) => { + assert.equal(folder, String.raw`C:\Users\dev\workspace`) + assert.equal(distro, "Ubuntu") + return "/mnt/c/Users/dev/workspace" + }), + "/mnt/c/Users/dev/workspace", + ) + }) + + it("bounds Windows path translation and returns null on timeout", () => { + let timeoutMs = 0 + const startedAt = Date.now() + assert.equal( + resolveWslServiceDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu", (_folder, _distro, timeout) => { + timeoutMs = timeout + const result = spawnSync(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeout }) + assert.equal((result.error as NodeJS.ErrnoException | undefined)?.code, "ETIMEDOUT") + return result.status === 0 ? result.stdout.toString() : undefined + }, 25), + null, + ) + assert.equal(timeoutMs, 25) + assert.ok(Date.now() - startedAt < 1_000) + }) + + it("maps service paths back to host paths with the same bound", () => { + assert.equal( + resolveWslHostDirectory("/mnt/c/Users/dev/workspace", "Ubuntu", (folder, distro, timeout) => { + assert.deepEqual([folder, distro, timeout], ["/mnt/c/Users/dev/workspace", "Ubuntu", 23]) + return String.raw`C:\Users\dev\workspace` + }, 23), + String.raw`C:\Users\dev\workspace`, + ) + }) +}) + describe("buildServiceLaunchSpec", () => { it("returns direct commands for executables, PowerShell, and WSL", () => { assert.deepEqual( diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index adb7c5d0..12a5d5f8 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -11,9 +11,15 @@ const logger = { warn() {}, } as unknown as Logger +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + function waitFor(check: () => boolean): Promise { return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("Timed out waiting for event")), 1000) + const timeout = setTimeout(() => reject(new Error("Timed out waiting for event")), 2000) const poll = () => { if (check()) { clearTimeout(timeout) @@ -55,6 +61,60 @@ function locationlessManager( } describe("InstanceEventBridge", () => { + it("does not publish connected until the stream confirms with its first event", async () => { + const gate = deferred() + const manager = { + list: () => [{ id: "a", path: "/repo-a" }], + ownsDirectory: async () => true, + subscribeToSharedService: async (signal?: AbortSignal) => (async function* () { + await gate.promise + yield { type: "permission.asked", location: { directory: "/repo-a" }, data: { id: "p1" } } as OpenCodeEvent + await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })(), + } as unknown as WorkspaceManager + const bus = new EventBus() + const statuses: string[] = [] + bus.on("instance.eventStatus", (event) => statuses.push(event.status)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => statuses.includes("connecting")) + assert.equal(statuses.includes("connected"), false) + gate.resolve() + await waitFor(() => statuses.includes("connected")) + } finally { + bridge.shutdown() + } + }) + + it("clears routing caches before reconnecting", async () => { + let subscriptions = 0 + let ownershipChecks = 0 + const event = { type: "permission.asked", location: { directory: "/repo-a" }, data: { id: "p1" } } as OpenCodeEvent + const manager = { + list: () => [{ id: "a", path: "/repo-a" }], + ownsDirectory: async () => ++ownershipChecks === 1, + subscribeToSharedService: async (signal?: AbortSignal) => { + subscriptions += 1 + const current = subscriptions + return (async function* () { + yield event + if (current > 1) await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })() + }, + } as unknown as WorkspaceManager + const bus = new EventBus() + const received: unknown[] = [] + bus.on("instance.event", (value) => received.push(value)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => subscriptions === 2 && ownershipChecks === 2) + assert.equal(received.length, 1) + } finally { + bridge.shutdown() + } + }) it("routes root and owned worktree events to the logical workspace and caches ownership", async () => { const events = [ { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo-a" }, data: { id: "p1" } }, @@ -197,6 +257,29 @@ describe("InstanceEventBridge", () => { } }) + it("routes locationless PTY events by cwd without broadcasting ownership", async () => { + const events = [ + { type: "pty.created", data: { info: { id: "pty-1", title: "dev", command: "npm", args: [], cwd: "/repo-b", status: "running", pid: 42 } } }, + { type: "pty.exited", data: { id: "pty-1", exitCode: 0 } }, + { type: "pty.deleted", data: { id: "pty-1" } }, + ] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => received.push(event)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 3) + assert.deepEqual(received.map((event) => event.instanceId), ["b", "b", "b"]) + assert.deepEqual(received.map((event) => event.event.type), ["pty.created", "pty.exited", "pty.deleted"]) + } finally { + bridge.shutdown() + } + }) + it("broadcasts an unresolvable locationless deletion", async () => { const events = [{ type: "session.deleted", data: { sessionID: "deleted" } }] as OpenCodeEvent[] const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] @@ -217,8 +300,11 @@ describe("InstanceEventBridge", () => { } }) - it("routes a locationless session only to its owning workspace", async () => { - const events = [{ type: "session.status", data: { sessionID: "foreign", status: { type: "idle" } } }] as OpenCodeEvent[] + it("keeps locationless session and permission events scoped to their owning workspace", async () => { + const events = [ + { type: "session.status", data: { sessionID: "foreign", status: { type: "idle" } } }, + { type: "permission.asked", data: { id: "permission", sessionID: "foreign" } }, + ] as OpenCodeEvent[] const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] const { manager } = locationlessManager(events, { foreign: "/repo-b" }, workspaces) const bus = new EventBus() @@ -226,10 +312,63 @@ describe("InstanceEventBridge", () => { bus.on("instance.event", (event) => received.push(event)) const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 2) + assert.deepEqual(received.map((event) => event.instanceId), ["b", "b"]) + } finally { + bridge.shutdown() + } + }) + + it("routes locationless form.created through data.form.sessionID", async () => { + const events = [{ + type: "form.created", + data: { form: { id: "form", sessionID: "owned", title: "Question", fields: [] } }, + }] as unknown as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager, sessionGets } = locationlessManager(events, { owned: "/repo-b" }, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => received.push(event)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) try { bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) await waitFor(() => received.length === 1) + assert.equal(sessionGets(), 1) assert.equal(received[0].instanceId, "b") + assert.equal(received[0].event.properties.form.sessionID, "owned") + } finally { + bridge.shutdown() + } + }) + + it("broadcasts safe global locationless service events", async () => { + const events = [ + { type: "agent.updated", data: {} }, + { type: "catalog.updated", data: {} }, + { type: "command.updated", data: {} }, + { type: "config.updated", data: {} }, + { type: "integration.connection.updated", data: { integrationID: "test" } }, + { type: "integration.updated", data: {} }, + { type: "mcp.resources.changed", data: { server: "test" } }, + { type: "mcp.status.changed", data: { server: "test" } }, + { type: "models-dev.refreshed", data: {} }, + { type: "installation.updated", data: { version: "1.2.3" } }, + { type: "installation.update-available", data: { version: "1.2.4" } }, + ] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager, sessionGets } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => received.push(event)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === events.length * workspaces.length) + assert.equal(sessionGets(), 0) + assert.deepEqual(received.map((event) => event.event.type), events.flatMap((event) => [event.type, event.type])) + assert.deepEqual(received.map((event) => event.instanceId), events.flatMap(() => ["a", "b"])) } finally { bridge.shutdown() } diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index a0821489..de49d031 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -7,6 +7,19 @@ import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types" const RECONNECT_DELAY_MS = 1000 const DIRECTORY_OWNER_CACHE_MS = 2000 const SESSION_DIRECTORY_CACHE_MS = 2000 +const GLOBAL_EVENT_TYPES = new Set([ + "agent.updated", + "catalog.updated", + "command.updated", + "config.updated", + "integration.connection.updated", + "integration.updated", + "installation.update-available", + "installation.updated", + "mcp.resources.changed", + "mcp.status.changed", + "models-dev.refreshed", +]) interface InstanceEventBridgeOptions { workspaceManager: WorkspaceManager @@ -20,6 +33,7 @@ export class InstanceEventBridge { private task?: Promise private readonly directoryOwners = new Map }>() private readonly sessionDirectories = new Map }>() + private readonly ptyDirectories = new Map() private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { this.clearLocationCaches() if (!this.task) this.task = this.run() @@ -54,12 +68,17 @@ export class InstanceEventBridge { private async run() { while (!this.controller.signal.aborted) { + this.clearLocationCaches() this.updateStatus("connecting") try { const events = await this.options.workspaceManager.subscribeToSharedService(this.controller.signal) - this.updateStatus("connected") + let confirmed = false for await (const event of events) { if (this.controller.signal.aborted) return + if (!confirmed) { + confirmed = true + this.updateStatus("connected") + } await this.publishEvent(event) } if (!this.controller.signal.aborted) throw new Error("Shared OpenCode event stream ended") @@ -74,20 +93,22 @@ export class InstanceEventBridge { private async publishEvent(event: OpenCodeEvent) { const sessionId = this.sessionId(event) + const ptyId = this.ptyId(event) if (event.type === "session.moved" && sessionId) this.sessionDirectories.delete(sessionId) - const directory = event.location?.directory ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) + const directory = event.location?.directory + ?? this.ptyInfoDirectory(event) + ?? (ptyId ? this.ptyDirectories.get(ptyId) : undefined) + ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) if (!directory) { + if (GLOBAL_EVENT_TYPES.has(event.type)) { + this.broadcastEvent(event) + return + } if (event.type === "session.deleted" && sessionId) { // Deletion can make session.get return 404 before the event arrives. Session IDs are // service-global, so notifying every logical workspace cannot delete another session. - const compatibleEvent: InstanceStreamEvent = { - ...event, - properties: this.compatibilityProperties(event), - } - for (const workspace of this.options.workspaceManager.list()) { - this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event: compatibleEvent }) - } + this.broadcastEvent(event) this.sessionDirectories.delete(sessionId) } return @@ -98,10 +119,12 @@ export class InstanceEventBridge { directory: Promise.resolve(directory), }) } + if (ptyId) this.ptyDirectories.set(ptyId, directory) const instanceIds = await this.resolveDirectoryOwners(directory) if (instanceIds.length === 0) { if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) + if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) return } @@ -114,13 +137,38 @@ export class InstanceEventBridge { this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent }) } if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) + if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) } private sessionId(event: OpenCodeEvent): string | undefined { - const sessionId = (event.data as { sessionID?: unknown }).sessionID + const data = event.data as { sessionID?: unknown; form?: { sessionID?: unknown } } + const sessionId = data.sessionID ?? (event.type === "form.created" ? data.form?.sessionID : undefined) return typeof sessionId === "string" && sessionId ? sessionId : undefined } + private ptyId(event: OpenCodeEvent): string | undefined { + if (!event.type.startsWith("pty.")) return undefined + const data = event.data as { id?: unknown; info?: { id?: unknown } } + const id = data.id ?? data.info?.id + return typeof id === "string" && id ? id : undefined + } + + private ptyInfoDirectory(event: OpenCodeEvent): string | undefined { + if (event.type !== "pty.created" && event.type !== "pty.updated") return undefined + const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd + return typeof cwd === "string" && cwd ? cwd : undefined + } + + private broadcastEvent(event: OpenCodeEvent): void { + const compatibleEvent: InstanceStreamEvent = { + ...event, + properties: this.compatibilityProperties(event), + } + for (const workspace of this.options.workspaceManager.list()) { + this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event: compatibleEvent }) + } + } + private resolveSessionDirectory(sessionId: string): Promise { const now = Date.now() const cached = this.sessionDirectories.get(sessionId) @@ -158,6 +206,7 @@ export class InstanceEventBridge { private clearLocationCaches(): void { this.directoryOwners.clear() this.sessionDirectories.clear() + this.ptyDirectories.clear() } private compatibilityProperties(event: OpenCodeEvent): Record { diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index c3aad0f8..f535891f 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -12,6 +12,8 @@ import { import type { OpenCodeEnsureOptions } from "./opencode-service" import path from "node:path" import os from "node:os" +import { execFileSync } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" function deferred() { let resolve!: (value: T) => void @@ -30,6 +32,7 @@ class ControlledSharedService { evictions: LocationRef[] = [] failEvictions = 0 shutdownGate?: ReturnType> + verifyLaunch = true async endpoint(options?: OpenCodeEnsureOptions) { this.assertCommand(options) @@ -80,6 +83,7 @@ class ControlledSharedService { } private assertCommand(options?: OpenCodeEnsureOptions) { + if (!this.verifyLaunch) return const stateRoot = path.join(os.homedir(), ".codenomad", "state", "opencode-v2") assert.equal(options?.file, path.join(stateRoot, "opencode", "service.json")) assert.equal(options?.version, "0.0.0-next-17353") @@ -92,10 +96,11 @@ class ControlledSharedService { assert.equal(options?.command?.[5], options?.contenderFile) assert.equal(options?.launcherRecordsPid, true) assert.equal(options?.environment?.XDG_STATE_HOME, stateRoot) + assert.equal(options?.environment?.OPENCODE_DB, path.join(os.tmpdir(), "user-opencode.db")) } } -function createHarness(service = new ControlledSharedService()) { +function createHarness(service = new ControlledSharedService(), overrides: Record = {}) { const eventBus = new EventBus() const started: string[] = [] const stopped: string[] = [] @@ -103,17 +108,77 @@ function createHarness(service = new ControlledSharedService()) { eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) const manager = new WorkspaceManager({ rootDir: process.cwd(), - settings: { getOwner: () => ({}) } as never, + settings: { getOwner: () => ({ environmentVariables: { OPENCODE_DB: path.join(os.tmpdir(), "user-opencode.db") } }) } as never, binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "OpenCode V2" }) } as never, eventBus, logger: pino({ level: "silent" }), getServerBaseUrl: () => "http://127.0.0.1:4000", sharedService: service, + ...overrides, }) return { manager, service, started, stopped } } describe("workspace manager shared service lifecycle", () => { + it("fails before contacting the service when OPENCODE_DB is absent", async () => { + const { manager, service } = createHarness() + ;(manager as any).options.settings = { getOwner: () => ({ environmentVariables: { OPENCODE_DB: "" } }) } + await assert.rejects(manager.create(process.cwd()), /non-empty OPENCODE_DB/) + assert.equal(service.validationCalls.length, 0) + assert.equal((manager as any).workspaces.size, 0) + }) + + it("translates a matching WSL UNC workspace for service API calls", () => { + const { manager } = createHarness() + assert.equal( + (manager as any).requireWslServiceDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu"), + "/home/dev/workspace", + ) + }) + + it("uses bounded WSL mappings for root and real git worktree ownership", async () => { + const base = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-ownership-")) + const repo = path.join(base, "repo") + const worktree = path.join(base, "feature") + execFileSync("git", ["init", repo], { stdio: "ignore", timeout: 5_000 }) + await writeFile(path.join(repo, "tracked.txt"), "tracked") + execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore", timeout: 5_000 }) + execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "-m", "initial"], { + stdio: "ignore", + timeout: 5_000, + }) + execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", worktree], { stdio: "ignore", timeout: 5_000 }) + const service = new ControlledSharedService() + service.verifyLaunch = false + const servicePaths = new Map([[repo, "/service/repo"], [worktree, "/service/feature"]]) + const hostPaths = new Map(Array.from(servicePaths, ([host, servicePath]) => [servicePath, host])) + const { manager } = createHarness(service, { + rootDir: base, + platform: "win32", + binaryResolver: { + resolveDefault: () => ({ path: String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, label: "OpenCode V2" }), + }, + wslServiceDirectoryResolver: (directory: string, _distro: string, timeoutMs: number) => { + assert.ok(timeoutMs > 0 && timeoutMs <= 30_000) + return servicePaths.get(directory) ?? null + }, + wslHostDirectoryResolver: (directory: string, _distro: string, timeoutMs: number) => { + assert.ok(timeoutMs > 0 && timeoutMs <= 30_000) + return hostPaths.get(directory) ?? null + }, + }) + try { + const { workspace } = await manager.create(repo) + assert.equal(manager.getServiceDirectory(workspace.id), "/service/repo") + assert.equal(await manager.ownsDirectory(workspace.id, "/service/repo"), true) + assert.equal(await manager.ownsDirectory(workspace.id, "/service/feature"), true) + assert.equal(await manager.ownsDirectory(workspace.id, "/service/foreign"), false) + assert.equal(await manager.getServiceDirectoryForPath(workspace.id, worktree), "/service/feature") + } finally { + await manager.shutdown().catch(() => undefined) + await rm(base, { recursive: true, force: true }) + } + }) it("creates a ready logical location without a workspace process", async () => { const { manager, service, started } = createHarness() const { workspace, created } = await manager.create(process.cwd()) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index d633f3f0..5423ad59 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -12,7 +12,12 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { Logger } from "../logger" import { resolveWorkspaceIdentity } from "./workspace-identity" -import { buildServiceLaunchSpec, parseWslUncPath } from "./spawn" +import { + buildServiceLaunchSpec, + parseWslUncPath, + resolveWslHostDirectory, + resolveWslServiceDirectory, +} from "./spawn" import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" import { prepareServiceState, @@ -70,11 +75,15 @@ interface WorkspaceManagerOptions { launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ManagerTimeout clearTimeout?: (timer: ManagerTimeout) => void + platform?: NodeJS.Platform + wslServiceDirectoryResolver?: (directory: string, distro: string, timeoutMs: number) => string | null + wslHostDirectoryResolver?: (directory: string, distro: string, timeoutMs: number) => string | null } interface WorkspaceRecord extends WorkspaceDescriptor { identityKey: string location?: LocationRef + wslDistro?: string ownership: WorkspaceCreationOwnership [WORKSPACE_STATE]: WorkspaceState } @@ -153,6 +162,11 @@ export class WorkspaceManager { return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.serviceAuthorization : undefined } + getServiceDirectory(id: string): string | undefined { + const record = this.workspaces.get(id) + return record?.[WORKSPACE_STATE].published ? record.location?.directory ?? record.path : undefined + } + async getSharedServiceEndpoint(id: string): Promise { if (!this.workspaces.get(id)?.[WORKSPACE_STATE].published) return undefined try { @@ -171,22 +185,51 @@ export class WorkspaceManager { } async ownsDirectory(id: string, directory: string): Promise { - const workspace = this.get(id) - if (!workspace) return false + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return false + if (directory === record.path || directory === record.location?.directory) return true + if (await this.ownsHostDirectory(record, directory)) return true + if (!record.wslDistro) return false + const hostDirectory = this.resolveWslHostDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) + return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory)) + } + + async getServiceDirectoryForPath(id: string, directory: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published || !await this.ownsDirectory(id, directory)) return undefined + if (!record.wslDistro || path.posix.isAbsolute(directory)) return directory + return this.resolveWslServiceDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) ?? undefined + } + + async getServicePathForPath(id: string, candidate: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published || !await this.ownsPath(id, candidate)) return undefined + if (!record.wslDistro || path.posix.isAbsolute(candidate)) return candidate + return this.resolveWslServiceDirectory(candidate, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) ?? undefined + } + + private async ownsHostDirectory(record: WorkspaceRecord, directory: string): Promise { return (await resolveWorktreeSlugForDirectory({ - workspaceId: id, - workspacePath: workspace.path, + workspaceId: record.id, + workspacePath: record.path, directory, logger: this.options.logger, })) !== null } async ownsPath(id: string, candidate: string): Promise { - const workspace = this.get(id) - if (!workspace) return false + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return false + if (await this.ownsHostPath(record, candidate)) return true + if (!record.wslDistro) return false + const hostPath = this.resolveWslHostDirectory(candidate, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) + return Boolean(hostPath && await this.ownsHostPath(record, hostPath)) + } + + private ownsHostPath(record: WorkspaceRecord, candidate: string): Promise { return isPathOwnedByWorktree({ - workspaceId: id, - workspacePath: workspace.path, + workspaceId: record.id, + workspacePath: record.path, candidate, logger: this.options.logger, }) @@ -366,6 +409,7 @@ export class WorkspaceManager { Object.defineProperties(record, { identityKey: { value: identityKey }, ownership: { value: ownership }, + wslDistro: { value: undefined, writable: true }, [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } }, }) @@ -424,38 +468,47 @@ export class WorkspaceManager { ): Promise { const state = record[WORKSPACE_STATE] const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record - const serverConfig = this.options.settings.getOwner("config", "server") - const configuredEnvironment = this.readConfiguredEnvironment(serverConfig) - if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath - configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT - prepareServiceState(SERVICE_CONTENDER_FILE) - const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], { - env: { ...process.env, ...configuredEnvironment }, - propagateEnvKeys: Object.keys(configuredEnvironment), - contenderFile: SERVICE_CONTENDER_FILE, - }) - const ensureOptions: OpenCodeEnsureOptions = { - file: SERVICE_REGISTRATION_FILE, - version: OPENCODE_SERVICE_VERSION, - command: launch.command, - contenderFile: SERVICE_CONTENDER_FILE, - leaseFile: SERVICE_LEASE_FILE, - lockDirectory: SERVICE_STOP_LOCK, - nativePid: launch.nativePid, - wslDistro: launch.wslDistro, - environment: launch.env, - launcherRecordsPid: launch.launcherRecordsPid, - windowsVerbatimArguments: launch.windowsVerbatimArguments, - timeoutMs, - } try { + const serverConfig = this.options.settings.getOwner("config", "server") + const configuredEnvironment = this.readConfiguredEnvironment(serverConfig) + if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath + configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT + const serviceEnvironment = { ...process.env, ...configuredEnvironment } + if (!serviceEnvironment.OPENCODE_DB?.trim()) { + throw new Error("OpenCode V2 requires a non-empty OPENCODE_DB environment variable") + } + prepareServiceState(SERVICE_CONTENDER_FILE) + const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], { + env: serviceEnvironment, + propagateEnvKeys: Object.keys(configuredEnvironment), + contenderFile: SERVICE_CONTENDER_FILE, + platform: this.options.platform, + }) + const ensureOptions: OpenCodeEnsureOptions = { + file: SERVICE_REGISTRATION_FILE, + version: OPENCODE_SERVICE_VERSION, + command: launch.command, + contenderFile: SERVICE_CONTENDER_FILE, + leaseFile: SERVICE_LEASE_FILE, + lockDirectory: SERVICE_STOP_LOCK, + nativePid: launch.nativePid, + wslDistro: launch.wslDistro, + environment: launch.env, + launcherRecordsPid: launch.launcherRecordsPid, + windowsVerbatimArguments: launch.windowsVerbatimArguments, + timeoutMs, + } this.throwIfCancelled(record) - record.location = { directory: workspacePath } + record.wslDistro = launch.wslDistro + const serviceDirectory = launch.wslDistro + ? this.requireWslServiceDirectory(workspacePath, launch.wslDistro, timeoutMs) + : workspacePath + record.location = { directory: serviceDirectory } const [endpoint, headers, location] = await Promise.all([ this.sharedService.endpoint(ensureOptions), this.sharedService.headers(ensureOptions), this.sharedService.validateLocation( - { directory: workspacePath }, + { directory: serviceDirectory }, { signal: state.abortController.signal }, ensureOptions, ), @@ -648,6 +701,28 @@ export class WorkspaceManager { await this.sharedService.evict(record.location) } + private requireWslServiceDirectory(directory: string, distro: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { + const translated = this.resolveWslServiceDirectory(directory, distro, timeoutMs) + if (!translated) { + throw new Error(`Unable to translate workspace location for WSL distro "${distro}": ${directory}`) + } + return translated + } + + private resolveWslServiceDirectory(directory: string, distro: string, timeoutMs: number): string | null { + if (this.options.wslServiceDirectoryResolver) { + return this.options.wslServiceDirectoryResolver(directory, distro, timeoutMs) + } + return resolveWslServiceDirectory(directory, distro, undefined, timeoutMs) + } + + private resolveWslHostDirectory(directory: string, distro: string, timeoutMs: number): string | null { + if (this.options.wslHostDirectoryResolver) { + return this.options.wslHostDirectoryResolver(directory, distro, timeoutMs) + } + return resolveWslHostDirectory(directory, distro, undefined, timeoutMs) + } + private removeRecord(id: string, record: WorkspaceRecord, publishStopped: boolean): void { if (this.workspaces.get(id) !== record) return this.workspaces.delete(id) diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index 9ddeb35a..f9d44b78 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -3,6 +3,7 @@ import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from import os from "node:os" import path from "node:path" import { describe, it } from "node:test" +import { createHash } from "node:crypto" import type { OpenCodeClient } from "@opencode-ai/client" import type { Endpoint, Info } from "@opencode-ai/client/service" @@ -84,7 +85,7 @@ describe("OpenCodeSharedService", () => { location: { get: async (...args: unknown[]) => { calls.push(["get", ...args]) - return { directory: "/repo", project: { id: "p", directory: "/repo", canonical: "/repo" } } + return { directory: "/repo", workspaceID: "ws", project: { id: "p", directory: "/repo", canonical: "/repo" } } }, }, event: { subscribe: (...args: unknown[]) => { calls.push(["subscribe", ...args]); return events } }, @@ -105,12 +106,68 @@ describe("OpenCodeSharedService", () => { await service.evict({ directory: "/repo", workspaceID: "ws" }, { signal }) assert.deepEqual(calls, [ - ["get", { location: { directory: "/repo", workspace: "ws" } }, { signal }], + ["get", { location: { directory: "/repo" } }, { signal }], ["subscribe", { signal }], - ["evict", { location: { directory: "/repo", workspace: "ws" } }, { signal }], ]) }) + it("rejects a changed launch signature instead of reusing the connected daemon", async () => { + const endpoint = { url: "http://127.0.0.1:4321", auth: undefined } + const service = new OpenCodeSharedService({ + discover: async () => endpoint, + ensure: async () => endpoint, + headers: () => undefined, + makeClient: () => ({} as OpenCodeClient), + }) + await service.endpoint({ version: "0.0.0-next-17353", command: ["first"], environment: { OPENCODE_DB: "/one" } }) + for (const options of [ + { version: "other", command: ["first"], environment: { OPENCODE_DB: "/one" } }, + { version: "0.0.0-next-17353", command: ["second"], environment: { OPENCODE_DB: "/one" } }, + { version: "0.0.0-next-17353", command: ["first"], environment: { OPENCODE_DB: "/two" } }, + ]) { + await assert.rejects(service.endpoint(options), /launch configuration/) + } + }) + + it("validates a caller workspace selector against the canonical location", async () => { + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => ({ url: "http://127.0.0.1:4321" }), + headers: () => undefined, + makeClient: () => ({ location: { get: async () => ({ + directory: "/repo", workspaceID: "canonical", project: { id: "p", directory: "/repo", canonical: "/repo" }, + }) } }) as unknown as OpenCodeClient, + }) + await assert.rejects(service.validateLocation({ directory: "/repo", workspaceID: "foreign" }), /does not match/) + }) + + it("defers location eviction until the last shared-service owner shuts down", async () => { + const state = await serviceState("codenomad-service-deferred-eviction-") + let evictions = 0 + const client = { debug: { location: { evict: async () => { evictions += 1 } } } } as unknown as OpenCodeClient + const service = new OpenCodeSharedService({ + discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), + ensure: async (options) => { + options?.onStart?.("missing") + return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } } + }, + headers: () => undefined, + makeClient: () => client, + requestStop: async () => true, + waitForStop: async () => true, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + }) + try { + await service.endpoint(state.options("owner", true)) + await service.evict({ directory: "/repo", workspaceID: "workspace" }) + assert.equal(evictions, 0) + await service.shutdown() + assert.equal(evictions, 1) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + it("rejects malformed endpoints and locations", async () => { const invalidEndpoint = new OpenCodeSharedService({ discover: async () => undefined, @@ -293,6 +350,12 @@ describe("OpenCodeSharedService", () => { it("quarantines stale registration after deterministic PID reuse without signaling it", async () => { const state = await serviceState("codenomad-service-stale-registration-") const reusedPid = state.info.pid + const options = { + ...state.options("successor", false), + command: [process.execPath, "-e", "process.exit(0)"], + timeoutMs: 50, + } + const launchSignature = signature(options) await writeFile(state.lease("dead-owner"), JSON.stringify({ version: 1, identity: "dead-owner", @@ -301,12 +364,14 @@ describe("OpenCodeSharedService", () => { createdAt: 1, updatedAt: 1, state: "active", + launchSignature, service: { info: state.info, endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }, registrationFile: state.file, nativePid: true, processIdentity: processIdentity(reusedPid, "old-service-process"), + launchSignature, }, })) const service = new OpenCodeSharedService({ @@ -321,11 +386,7 @@ describe("OpenCodeSharedService", () => { makeClient: () => ({} as OpenCodeClient), }) try { - await assert.rejects(service.endpoint({ - ...state.options("successor", false), - command: [process.execPath, "-e", "process.exit(0)"], - timeoutMs: 50, - }), /exited before registration|timed out/) + await assert.rejects(service.endpoint(options), /exited before registration|timed out/) await assert.rejects(access(state.file)) } finally { await rm(state.root, { recursive: true, force: true }) @@ -336,6 +397,8 @@ describe("OpenCodeSharedService", () => { const state = await serviceState("codenomad-service-crash-window-") const deadPid = 7654321 const launchCreatedAt = Date.now() - 1_000 + const options = state.options("successor", false) + const launchSignature = signature(options) await writeFile(state.lease("dead-owner"), JSON.stringify({ version: 1, identity: "dead-owner", @@ -344,6 +407,7 @@ describe("OpenCodeSharedService", () => { createdAt: launchCreatedAt, updatedAt: launchCreatedAt, state: "active", + launchSignature, launch: { identity: "launch-before-crash", createdAt: launchCreatedAt, @@ -359,7 +423,7 @@ describe("OpenCodeSharedService", () => { makeClient: () => ({} as OpenCodeClient), }) try { - await service.endpoint(state.options("successor", false)) + await service.endpoint(options) const lease = JSON.parse(await readFile(state.lease("successor"), "utf8")) assert.deepEqual(lease.service.processIdentity, processIdentity(state.info.pid)) assert.equal(lease.service.info.pid, state.info.pid) @@ -404,6 +468,8 @@ describe("OpenCodeSharedService", () => { it("inherits proven ownership from a dead owner lease", async () => { const state = await serviceState("codenomad-service-dead-owner-") const deadPid = 7654321 + const options = state.options("peer", false) + const launchSignature = signature(options) await writeFile(state.lease("dead-owner"), JSON.stringify({ version: 1, identity: "dead-owner", @@ -412,17 +478,19 @@ describe("OpenCodeSharedService", () => { createdAt: 1, updatedAt: 1, state: "active", + launchSignature, service: { info: state.info, endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }, registrationFile: state.file, nativePid: true, + launchSignature, }, })) let stops = 0 const peer = createOwnedService(state, async () => { stops += 1; return true }, false, (pid) => pid !== deadPid) try { - await peer.endpoint(state.options("peer", false)) + await peer.endpoint(options) await peer.shutdown() assert.equal(stops, 1) await assert.rejects(access(state.lease("dead-owner"))) @@ -431,6 +499,28 @@ describe("OpenCodeSharedService", () => { } }) + it("rejects stale ownership proof from a daemon launched with a different OPENCODE_DB", async () => { + const state = await serviceState("codenomad-service-stale-signature-") + const owner = createOwnedService(state, async () => true) + const successor = createOwnedService(state, async () => true, false, () => true) + const deadPid = 7654321 + try { + await owner.endpoint({ ...state.options("dead-owner", true), environment: { OPENCODE_DB: "/db/one" } }) + const staleLease = JSON.parse(await readFile(state.lease("dead-owner"), "utf8")) + staleLease.pid = deadPid + staleLease.processIdentity = processIdentity(deadPid, "dead-codenomad") + await writeFile(state.lease("dead-owner"), JSON.stringify(staleLease)) + + await assert.rejects( + successor.endpoint({ ...state.options("successor", false), environment: { OPENCODE_DB: "/db/two" } }), + /does not match the discovered daemon/, + ) + await assert.rejects(access(state.lease("successor"))) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + it("replaces stale in-memory ownership with a newer transferred service proof", async () => { const state = await serviceState("codenomad-service-replacement-owner-") const requested: string[] = [] @@ -739,3 +829,14 @@ function processIdentity( ): ProcessIdentity { return { namespace, pid, start } } + +function signature(options: OpenCodeEnsureOptions): string { + const environment = Object.entries(options.environment ?? {}).sort(([left], [right]) => left.localeCompare(right)) + return createHash("sha256").update(JSON.stringify({ + command: options.command ?? [], + environment, + version: options.version ?? null, + wslDistro: options.wslDistro ?? null, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + })).digest("hex") +} diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index f4e2ff50..4b35f168 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -7,7 +7,7 @@ import { } from "@opencode-ai/client" import { Service, type Endpoint, type EnsureOptions, type Info, type StopOptions } from "@opencode-ai/client/service" import { spawn, type ChildProcess } from "node:child_process" -import { randomUUID } from "node:crypto" +import { createHash, randomUUID } from "node:crypto" import type { Stats } from "node:fs" import { appendFile, lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises" import path from "node:path" @@ -26,6 +26,7 @@ type ServiceProof = { registrationFile: string nativePid?: boolean processIdentity?: ProcessIdentity + launchSignature?: string } interface LaunchIntent { identity: string @@ -46,6 +47,7 @@ interface LeaseMetadata { contenderFile?: string launch?: LaunchIntent service?: ServiceProof + launchSignature?: string } interface LockOwner { version: 1 @@ -83,6 +85,7 @@ interface ServiceConnection { endpoint: Endpoint client: OpenCodeClient stopOptions: StopOptions + launchSignature: string } interface OwnedService { @@ -91,6 +94,7 @@ interface OwnedService { endpoint: Endpoint nativePid: boolean processIdentity?: ProcessIdentity + launchSignature: string } export interface OpenCodeSharedServiceDependencies { @@ -117,6 +121,7 @@ export class OpenCodeSharedService { private lease?: LeaseHandle private shutdownAttempt?: Promise private shutdownRequested = false + private readonly pendingEvictions = new Map() constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { discover: Service.discover, @@ -142,7 +147,7 @@ export class OpenCodeSharedService { ensureOptions?: OpenCodeEnsureOptions, ): Promise { const result = await this.withClient(ensureOptions, (client) => client.location.get({ - location: { directory: location.directory, workspace: location.workspaceID }, + location: { directory: location.directory }, }, requestOptions)) if ( !result @@ -153,6 +158,9 @@ export class OpenCodeSharedService { ) { throw new Error("OpenCode returned an invalid location") } + if (location.workspaceID && result.workspaceID !== location.workspaceID) { + throw new Error("OpenCode location workspace does not match the canonical location") + } return result } @@ -173,9 +181,9 @@ export class OpenCodeSharedService { requestOptions?: RequestOptions, ensureOptions?: OpenCodeEnsureOptions, ): Promise { - await this.withClient(ensureOptions, (client) => client.debug.location.evict({ - location: { directory: location.directory, workspace: location.workspaceID }, - }, requestOptions)) + requestOptions?.signal?.throwIfAborted() + if (ensureOptions) await this.connect(ensureOptions) + this.pendingEvictions.set(`${location.directory}\0${location.workspaceID ?? ""}`, location) } shutdown(options: { timeoutMs?: number } = {}): Promise { @@ -209,7 +217,12 @@ export class OpenCodeSharedService { } const leaseDirectory = path.dirname(lease.file) const initialPeers = await this.readPeerLeases(leaseDirectory, lease.file) - const inheritedProof = await this.deadPeerProof(initialPeers, this.ensureOptions?.file, deadlineAt) + const inheritedProof = await this.deadPeerProof( + initialPeers, + this.ensureOptions?.file, + deadlineAt, + ownMetadata.launchSignature, + ) await this.pruneLeaseArtifacts(leaseDirectory, lease.file, lease.staleLockMs, deadlineAt) const peers = await this.readPeerLeases(leaseDirectory, lease.file) const peerEntries = (await readdir(leaseDirectory)) @@ -221,14 +234,22 @@ export class OpenCodeSharedService { if (leasedProof && leasedProof.registrationFile !== this.ensureOptions?.file) { throw new Error("OpenCode service lease proof references an unexpected registration; retaining lease") } - const owned = leasedProof ? { - stopOptions: { file: leasedProof.registrationFile }, - info: leasedProof.info, - endpoint: leasedProof.endpoint, - nativePid: leasedProof.nativePid === true, - processIdentity: leasedProof.processIdentity, - } : this.owned - if (leasedProof) this.owned = owned + let owned = this.owned + if (leasedProof) { + const launchSignature = ownMetadata.launchSignature + if (!launchSignature || leasedProof.launchSignature !== launchSignature) { + throw new Error("OpenCode service lease proof has a different launch configuration; retaining lease") + } + owned = { + stopOptions: { file: leasedProof.registrationFile }, + info: leasedProof.info, + endpoint: leasedProof.endpoint, + nativePid: leasedProof.nativePid === true, + processIdentity: leasedProof.processIdentity, + launchSignature, + } + this.owned = owned + } if (!owned) { await this.releaseLease(lease) return @@ -248,7 +269,12 @@ export class OpenCodeSharedService { } const livePeers = peerStates.filter(({ state }) => state === "live").map(({ peer }) => peer) if (livePeers.length) { - if (!livePeers.some((peer) => peer.metadata.service && this.sameInfo(peer.metadata.service.info, owned.info))) { + if (livePeers.some((peer) => peer.metadata.launchSignature !== owned.launchSignature)) { + throw new Error("OpenCode service peer launch configuration changed; retaining lease") + } + if (!livePeers.some((peer) => peer.metadata.service + && peer.metadata.service.launchSignature === owned.launchSignature + && this.sameInfo(peer.metadata.service.info, owned.info))) { const elected = livePeers.sort((left, right) => left.metadata.identity.localeCompare(right.metadata.identity))[0] await this.writeLease(elected.file, { ...elected.metadata, @@ -271,6 +297,8 @@ export class OpenCodeSharedService { )) { throw new Error("OpenCode service process identity changed; retaining lease") } + // ponytail: eviction is process-local and only safe after proving no peer and the exact daemon identity. + await this.flushPendingEvictions(owned) const remaining = deadlineAt - Date.now() if (remaining <= 0) throw new Error("OpenCode service shutdown deadline elapsed; retaining lease") const proof = this.serviceProof(owned) @@ -317,12 +345,19 @@ export class OpenCodeSharedService { private connect(options?: OpenCodeEnsureOptions): Promise { const selectedOptions = options ?? this.ensureOptions ?? {} + const launchSignature = this.launchSignature(selectedOptions) if (!this.connected) { if (this.pendingLaunch) { + if (this.ensureOptions && launchSignature !== this.launchSignature(this.ensureOptions)) { + return Promise.reject(new Error("OpenCode service launch configuration changed while startup is in progress")) + } return this.withDeadline(this.pendingLaunch, selectedOptions.timeoutMs ?? 30_000, "OpenCode service ensure") } return this.connection ?? this.startConnection(selectedOptions) } + if (launchSignature !== this.connected.launchSignature) { + return Promise.reject(new Error("OpenCode service launch configuration does not match the connected daemon")) + } if (this.healthCheck) return this.healthCheck const current = this.connection! @@ -372,6 +407,7 @@ export class OpenCodeSharedService { windowsVerbatimArguments, ...ensureOptions } = options + const launchSignature = this.launchSignature(options) const onStart = ensureOptions.onStart const pending = this.ensureLease( leaseFile, @@ -380,6 +416,7 @@ export class OpenCodeSharedService { ensureOptions.file, timeoutMs, staleLockMs, + launchSignature, ).then(() => { const launch = (this.dependencies.ensure ? this.dependencies.ensure({ @@ -396,6 +433,7 @@ export class OpenCodeSharedService { baseUrl: endpoint.url, headers: this.dependencies.headers(endpoint), }), + launchSignature, } const info = await this.proveOwnership(connection.stopOptions.file, contenderFile, endpoint, started, timeoutMs) if (info) { @@ -406,6 +444,7 @@ export class OpenCodeSharedService { endpoint, nativePid: options.nativePid !== false, processIdentity, + launchSignature, } await this.updateLeaseService({ info, @@ -413,6 +452,7 @@ export class OpenCodeSharedService { registrationFile: connection.stopOptions.file!, nativePid: this.owned.nativePid, processIdentity, + launchSignature, }) } this.connected = connection @@ -683,8 +723,16 @@ export class OpenCodeSharedService { registrationFile: string | undefined, timeoutMs: number, staleLockMs: number, + launchSignature: string, ): Promise { - if (!file || !lockDirectory || this.lease) return + if (!file || !lockDirectory) return + if (this.lease) { + const metadata = await this.readLease(this.lease.file) + if (!metadata || metadata.identity !== this.lease.identity || metadata.launchSignature !== launchSignature) { + throw new Error("OpenCode service launch configuration does not match the active lifecycle lease") + } + return + } const identity = randomUUID() const lease = { file, @@ -696,14 +744,30 @@ export class OpenCodeSharedService { } const deadlineAt = Date.now() + timeoutMs await this.withLifecycleLock(lease, deadlineAt, async () => { + const peerLeases = await this.readPeerLeases(path.dirname(file)) + const peerStates = await Promise.all(peerLeases.map((peer) => this.processOwnerState(peer.metadata, deadlineAt))) + if (peerLeases.some((peer, index) => peerStates[index] === "live" && peer.metadata.launchSignature !== launchSignature)) { + throw new Error("OpenCode service launch configuration does not match the shared daemon") + } + if (await this.hasConflictingStaleService( + peerLeases, + peerStates, + registrationFile, + launchSignature, + deadlineAt, + )) { + throw new Error("OpenCode service launch configuration does not match the discovered daemon") + } const inheritedProof = await this.deadPeerProof( - await this.readPeerLeases(path.dirname(file)), + peerLeases, registrationFile, deadlineAt, + launchSignature, ) const inheritedLaunch = inheritedProof ? undefined : await this.deadPeerLaunch( - await this.readPeerLeases(path.dirname(file)), + peerLeases, deadlineAt, + launchSignature, ) await this.pruneLeaseArtifacts(path.dirname(file), undefined, lease.staleLockMs, deadlineAt) const now = Date.now() @@ -719,6 +783,7 @@ export class OpenCodeSharedService { contenderFile, launch: inheritedLaunch, service: inheritedProof, + launchSignature, }, true) this.lease = lease }) @@ -790,13 +855,16 @@ export class OpenCodeSharedService { peers: Array<{ file: string; metadata: LeaseMetadata }>, registrationFile: string | undefined, deadlineAt: number, + launchSignature: string | undefined, ): Promise { - if (!registrationFile) return undefined + if (!registrationFile || !launchSignature) return undefined const states = await Promise.all(peers.map((peer) => this.processOwnerState(peer.metadata, deadlineAt))) const proofs = peers - .filter((_peer, index) => states[index] === "stale") + .filter((peer, index) => states[index] === "stale" && peer.metadata.launchSignature === launchSignature) .map((peer) => peer.metadata.service) - .filter((proof): proof is ServiceProof => proof?.registrationFile === registrationFile) + .filter((proof): proof is ServiceProof => ( + proof?.registrationFile === registrationFile && proof.launchSignature === launchSignature + )) const first = proofs[0] if (!first) return undefined return proofs.every((proof) => this.sameInfo(proof.info, first.info) @@ -812,14 +880,63 @@ export class OpenCodeSharedService { private async deadPeerLaunch( peers: Array<{ file: string; metadata: LeaseMetadata }>, deadlineAt: number, + launchSignature: string, ): Promise { for (const peer of peers) { - if (!peer.metadata.launch || await this.processOwnerState(peer.metadata, deadlineAt) !== "stale") continue + if ( + peer.metadata.launchSignature !== launchSignature + || !peer.metadata.launch + || await this.processOwnerState(peer.metadata, deadlineAt) !== "stale" + ) continue return peer.metadata.launch } return undefined } + private async hasConflictingStaleService( + peers: Array<{ file: string; metadata: LeaseMetadata }>, + states: ProcessOwnerState[], + registrationFile: string | undefined, + launchSignature: string, + deadlineAt: number, + ): Promise { + if (!registrationFile) return false + const current = await readSecureServiceInfo(registrationFile) + if (!current) return false + for (let index = 0; index < peers.length; index++) { + const peer = peers[index] + const proof = peer?.metadata.service + if ( + states[index] !== "stale" + || !proof + || (peer?.metadata.launchSignature === launchSignature && proof.launchSignature === launchSignature) + || proof.registrationFile !== registrationFile + || !this.sameInfo(proof.info, current) + ) continue + if (!proof.processIdentity) return true + if (proof.processIdentity.namespace.kind === "host" && !this.processIsAlive(proof.info.pid)) continue + if (proof.processIdentity.namespace.kind === "wsl") { + const timeoutMs = deadlineAt - Date.now() + if (timeoutMs <= 0) return true + const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( + proof.info.pid, + timeoutMs, + proof.processIdentity.namespace, + ) + if (probe.status === "missing") continue + if (probe.status !== "found" || this.sameProcessIdentity(probe.identity, proof.processIdentity)) return true + continue + } + const identity = await this.currentProcessIdentity( + proof.info.pid, + deadlineAt, + proof.processIdentity.namespace, + ) + if (!identity || this.sameProcessIdentity(identity, proof.processIdentity)) return true + } + return false + } + private async processOwnerState( owner: Pick, deadlineAt: number, @@ -921,6 +1038,7 @@ export class OpenCodeSharedService { && (lease.contenderFile === undefined || typeof lease.contenderFile === "string") && (lease.launch === undefined || this.isLaunchIntent(lease.launch)) && (lease.service === undefined || this.isServiceProof(lease.service)) + && (lease.launchSignature === undefined || typeof lease.launchSignature === "string") } private isLaunchIntent(value: unknown): value is LaunchIntent { @@ -941,6 +1059,7 @@ export class OpenCodeSharedService { if (!proof.info.id || !Number.isInteger(proof.info.pid) || proof.info.pid <= 0) return false if (proof.nativePid !== undefined && typeof proof.nativePid !== "boolean") return false if (proof.processIdentity !== undefined && !this.isProcessIdentity(proof.processIdentity)) return false + if (proof.launchSignature !== undefined && typeof proof.launchSignature !== "string") return false if (proof.info.url !== proof.endpoint.url || proof.info.password !== proof.endpoint.auth?.password) return false try { assertLoopbackServiceUrl(proof.endpoint.url) } catch { return false } return true @@ -954,6 +1073,7 @@ export class OpenCodeSharedService { registrationFile: owned.stopOptions.file, nativePid: owned.nativePid, processIdentity: owned.processIdentity, + launchSignature: owned.launchSignature, } } @@ -1156,4 +1276,29 @@ export class OpenCodeSharedService { && (left.namespace.kind !== "wsl" || right.namespace.kind === "wsl" && left.namespace.distro.toLowerCase() === right.namespace.distro.toLowerCase()) } + + private launchSignature(options: OpenCodeEnsureOptions): string { + const environment = Object.entries(options.environment ?? {}).sort(([left], [right]) => left.localeCompare(right)) + return createHash("sha256").update(JSON.stringify({ + command: options.command ?? [], + environment, + version: options.version ?? null, + wslDistro: options.wslDistro ?? null, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + })).digest("hex") + } + + private async flushPendingEvictions(owned: OwnedService): Promise { + if (!this.pendingEvictions.size) return + const client = this.connected?.client ?? this.dependencies.makeClient({ + baseUrl: owned.endpoint.url, + headers: this.dependencies.headers(owned.endpoint), + }) + for (const location of this.pendingEvictions.values()) { + await client.debug.location.evict({ + location: { directory: location.directory, workspace: location.workspaceID }, + }) + } + this.pendingEvictions.clear() + } } diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index fe6d412d..112119b8 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -7,7 +7,7 @@ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i -const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", "XDG_STATE_HOME"]) +const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", "OPENCODE_DB", "XDG_STATE_HOME"]) const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" const WINDOWS_SHELL_NAMES = new Set([ @@ -156,6 +156,44 @@ export function buildSpawnSpec(binaryPath: string, args: string[], options: Buil return buildWindowsSpawnSpec(binaryPath, args, options) } +export function resolveWslServiceDirectory( + folder: string, + distro: string, + translateWindowsPath: (folder: string, distro: string, timeoutMs: number) => string | undefined = (windowsFolder, wslDistro, timeoutMs) => { + const result = spawnSync("wsl.exe", ["--distribution", wslDistro, "--exec", "wslpath", "-au", windowsFolder], { + encoding: "utf8", + windowsHide: true, + timeout: timeoutMs, + maxBuffer: 64 * 1024, + }) + return result.status === 0 ? result.stdout.trim() : undefined + }, + timeoutMs = 5_000, +): string | null { + const directory = resolveWslWorkingDirectory(folder, distro) + if (!directory) return null + if (directory.kind === "linux") return directory.path + return translateWindowsPath(directory.path, distro, Math.max(1, timeoutMs))?.trim() || null +} + +export function resolveWslHostDirectory( + folder: string, + distro: string, + translateLinuxPath: (folder: string, distro: string, timeoutMs: number) => string | undefined = (linuxFolder, wslDistro, timeoutMs) => { + const result = spawnSync("wsl.exe", ["--distribution", wslDistro, "--exec", "wslpath", "-aw", linuxFolder], { + encoding: "utf8", + windowsHide: true, + timeout: timeoutMs, + maxBuffer: 64 * 1024, + }) + return result.status === 0 ? result.stdout.trim() : undefined + }, + timeoutMs = 5_000, +): string | null { + if (!path.posix.isAbsolute(folder)) return null + return translateLinuxPath(folder, distro, Math.max(1, timeoutMs))?.trim() || null +} + export function buildServiceLaunchSpec( binaryPath: string, args: string[], @@ -424,27 +462,23 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry])) for (const key of keysToPropagate) { + const requiresPathTranslation = WSL_PATH_ENV_KEYS.has(key) && ( + key !== "OPENCODE_DB" || normalizeWindowsPath(next[key] ?? "") !== null + ) const existingEntry = byName.get(key) if (existingEntry) { - byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key))) + byName.set(key, setWslenvPathFlag(existingEntry, requiresPathTranslation)) continue } - byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key) + byName.set(key, requiresPathTranslation ? `${key}/p` : key) } next.WSLENV = Array.from(byName.values()).join(":") return next } -function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string { - if (!requiresPathTranslation) { - return entry - } - +function setWslenvPathFlag(entry: string, requiresPathTranslation: boolean): string { const [name, rawFlags = ""] = entry.split("/") - if (rawFlags.includes("p")) { - return entry - } - - return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p` + const flags = rawFlags.replaceAll("p", "") + (requiresPathTranslation ? "p" : "") + return flags ? `${name}/${flags}` : name } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 24fff08a..1dd3cd3a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -41,6 +41,8 @@ import { stopInstance, disconnectedInstance, acknowledgeDisconnectedInstance, + reconcilePendingSessionIndicators, + refreshVolatileInstanceState, syncPendingRequests, } from "./stores/instances" import { @@ -306,6 +308,7 @@ const App: Component = () => { registerInvalidation: (invalidate) => { invalidateSessions = invalidate }, }), syncPendingRequests(id, (invalidate) => { invalidatePendingRequests = invalidate }), + refreshVolatileInstanceState(id), ]), `Foreground refresh for ${id}`, () => { @@ -317,6 +320,7 @@ const App: Component = () => { ) const failedInstanceIds: string[] = [] sessionListResults.forEach((result, i) => { + reconcilePendingSessionIndicators(instanceIds[i]) if (result.status === "rejected") { failedInstanceIds.push(instanceIds[i]) log.error("Foreground refresh: fetchSessions failed", { instanceId: instanceIds[i], error: result.reason }) diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index be3616d3..58f5a5fc 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -2,7 +2,7 @@ import { Select } from "@kobalte/core/select" import { Show, createEffect, createMemo } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" -import { getSelectableAgentsForSession, type Agent } from "../types/session" +import { findAgentById, getSelectableAgentsForSession, type Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -31,14 +31,7 @@ export default function AgentSelector(props: AgentSelectorProps) { const availableAgents = createMemo(() => { return getSelectableAgentsForSession(instanceAgents(), props.currentAgent, isChildSession()) }) - - createEffect(() => { - const list = availableAgents() - if (list.length === 0) return - if (!list.some((agent) => agent.name === props.currentAgent)) { - void props.onAgentChange(list[0].name) - } - }) + const selectedAgent = createMemo(() => findAgentById(availableAgents(), props.currentAgent)) createEffect(() => { if (instanceAgents().length === 0) { @@ -47,18 +40,18 @@ export default function AgentSelector(props: AgentSelectorProps) { }) const handleChange = async (value: Agent | null) => { - if (value && value.name !== props.currentAgent) { - await props.onAgentChange(value.name) + if (value && value.id !== props.currentAgent) { + await props.onAgentChange(value.id) } } return ( + ) + }} + + {(message) => } +
+ + +
+ + ) +} + +export default FormRequest diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index 3918eeee..d9f448c8 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -7,7 +7,7 @@ import { getLogger } from "../lib/logger" const log = getLogger("session") -type ServiceSection = "lsp" | "mcp" | "plugins" +type ServiceSection = "mcp" | "plugins" interface InstanceServiceStatusProps { sections?: ServiceSection[] @@ -49,22 +49,19 @@ const InstanceServiceStatus: Component = (props) => }) const isLoading = metadataContext?.isLoading ?? (() => false) const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve()) - const sections = createMemo(() => props.sections ?? ["lsp", "mcp", "plugins"]) - const includeLsp = createMemo(() => sections().includes("lsp")) + const sections = createMemo(() => props.sections ?? ["mcp", "plugins"]) const includeMcp = createMemo(() => sections().includes("mcp")) const includePlugins = createMemo(() => sections().includes("plugins")) const showHeadings = () => props.showSectionHeadings !== false const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata) const metadata = createMemo(() => metadataAccessor()) - const hasLspMetadata = () => metadata()?.lspStatus !== undefined const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined const hasPluginsMetadata = () => metadata()?.plugins !== undefined const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined)) const plugins = createMemo(() => metadata()?.plugins ?? []) - const isLspLoading = () => isLoading() || !hasLspMetadata() const isMcpLoading = () => isLoading() || !hasMcpMetadata() const isPluginsLoading = () => isLoading() || !hasPluginsMetadata() @@ -109,22 +106,6 @@ const InstanceServiceStatus: Component = (props) =>

) - const renderLspSection = () => ( -
- -
- {t("instanceServiceStatus.sections.lsp")} -
-
- - {renderEmptyState(t("instanceServiceStatus.lsp.loading"))} - -
- ) - const renderMcpSection = () => (
@@ -226,7 +207,6 @@ const InstanceServiceStatus: Component = (props) => return (
- {renderLspSection()} {renderMcpSection()} {renderPluginsSection()}
diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index d47ee6ba..3819dab7 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -37,6 +37,7 @@ import { getLogger } from "../../lib/logger" import PromptInput from "../prompt-input" import { useI18n } from "../../lib/i18n" import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" +import { getFormQueue } from "../../stores/forms" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" @@ -332,7 +333,7 @@ const InstanceShell2: Component = (props) => { const hasPendingRequests = createMemo(() => { const permissions = getPermissionQueueLength(props.instance.id) const questions = getQuestionQueueLength(props.instance.id) - return permissions + questions > 0 + return permissions + questions + getFormQueue(props.instance.id).length > 0 }) const activePromptInputApi = createMemo(() => { @@ -411,7 +412,7 @@ const InstanceShell2: Component = (props) => { const activeSession = activeSessionForInstance() const needsPermission = Boolean(activeSession?.pendingPermission) - const needsQuestion = Boolean(activeSession?.pendingQuestion) + const needsQuestion = Boolean(activeSession?.pendingQuestion || activeSession?.pendingForm) const needsInput = needsPermission || needsQuestion if (needsInput) { diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index 0b20164c..5be5b7b1 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -14,8 +14,8 @@ interface CoreStatusSectionRenderers { renderYoloModeSection: () => JSX.Element renderProviderUsage: () => JSX.Element renderPlanSectionContent: () => JSX.Element + renderBackgroundProcesses: () => JSX.Element renderMcpStatus: () => JSX.Element - renderLspStatus: () => JSX.Element renderPluginStatus: () => JSX.Element } @@ -60,8 +60,8 @@ export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRend "yolo-mode": renderers.renderYoloModeSection, "provider-usage": renderers.renderProviderUsage, plan: renderers.renderPlanSectionContent, + "background-processes": renderers.renderBackgroundProcesses, mcp: renderers.renderMcpStatus, - lsp: renderers.renderLspStatus, plugins: renderers.renderPluginStatus, } diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts index cc35638e..2216cc7a 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -70,8 +70,8 @@ describe("right panel plugin manifests", () => { renderYoloModeSection: render, renderProviderUsage: render, renderPlanSectionContent: render, + renderBackgroundProcesses: render, renderMcpStatus: render, - renderLspStatus: render, renderPluginStatus: render, }) @@ -83,8 +83,8 @@ describe("right panel plugin manifests", () => { "yolo-mode", "provider-usage", "plan", + "background-processes", "mcp", - "lsp", "plugins", ]) }) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index d958d0d1..c01dfbc6 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type Accessor, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, type Accessor, type Component } from "solid-js" import type { ToolState } from "../../../../../types/tool-state" import { DragDropProvider, @@ -12,7 +12,7 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { ChevronDown, GripVertical, Info } from "lucide-solid" +import { ChevronDown, GripVertical, Info, Pencil, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { Session } from "../../../../../types/session" @@ -25,6 +25,8 @@ import { togglePermissionAutoAcceptForSession } from "../../../../../stores/inst import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" import { createCoreStatusSectionManifest } from "../core-plugin" +import { ptyStore } from "../../../../../stores/ptys" +import { showConfirmDialog, showPromptDialog } from "../../../../../stores/alerts" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -80,6 +82,12 @@ const SortableStatusSection: Component = (props) => const StatusTab: Component = (props) => { const isSectionExpanded = (id: string) => props.expandedItems().includes(id) + const ptyDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) + const ptyState = createMemo(() => ptyStore.getState(props.instanceId, ptyDirectory())) + + createEffect(() => { + void ptyStore.load(props.instanceId, ptyDirectory()) + }) const renderYoloModeSection = () => { const session = props.activeSession() @@ -127,6 +135,105 @@ const StatusTab: Component = (props) => { return } + const renamePty = async (ptyId: string, currentTitle: string) => { + const title = await showPromptDialog(props.t("instanceShell.backgroundProcesses.rename.message"), { + title: props.t("instanceShell.backgroundProcesses.rename.title"), + inputLabel: props.t("instanceShell.backgroundProcesses.rename.inputLabel"), + inputDefaultValue: currentTitle, + confirmLabel: props.t("instanceShell.backgroundProcesses.actions.rename"), + }) + const trimmed = title?.trim() + if (trimmed && trimmed !== currentTitle) { + await ptyStore.updateTitle(props.instanceId, ptyDirectory(), ptyId, trimmed) + } + } + + const removePty = async (ptyId: string, title: string, running: boolean) => { + const confirmed = await showConfirmDialog( + props.t("instanceShell.backgroundProcesses.remove.message", { title }), + { + title: props.t("instanceShell.backgroundProcesses.remove.title"), + confirmLabel: props.t(running + ? "instanceShell.backgroundProcesses.actions.stopRemove" + : "instanceShell.backgroundProcesses.actions.remove"), + }, + ) + if (confirmed) await ptyStore.remove(props.instanceId, ptyDirectory(), ptyId) + } + + const renderBackgroundProcesses = () => ( + {props.t("instanceShell.backgroundProcesses.error")}} + > + 0} + fallback={
{props.t("instanceShell.backgroundProcesses.loading")}
} + > + 0} + fallback={
{props.t("instanceShell.backgroundProcesses.empty")}
} + > +
+ + {(pty) => { + const running = () => pty.status === "running" + return ( +
+
+
+

{pty.title}

+ + {[pty.command, ...pty.args].join(" ")} + +
+
+ + +
+
+
+ {props.t(`instanceShell.backgroundProcesses.status.${pty.status}`)} + {props.t("instanceShell.backgroundProcesses.pid", { pid: pty.pid })} + + {props.t("instanceShell.backgroundProcesses.exitCode", { code: pty.exitCode })} + +
+
{pty.cwd}
+
+ ) + }} +
+
+
+
+
+ ) + const renderProviderUsage = () => { const session = props.activeSession() if (!session) { @@ -144,8 +251,8 @@ const StatusTab: Component = (props) => { renderYoloModeSection, renderProviderUsage, renderPlanSectionContent, + renderBackgroundProcesses, renderMcpStatus: () => , - renderLspStatus: () => , renderPluginStatus: () => ( ), diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx index e697db7c..96756e3c 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, lazy, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, lazy, onCleanup, type Accessor, type JSX } from "solid-js" import type { DiffWordWrapMode, RightPanelTab } from "../types" import type { FileBrowserEntry } from "./FilesTab" @@ -10,6 +10,7 @@ import { getWorktrees } from "../../../../../stores/worktrees" import { serverApi } from "../../../../../lib/api-client" import { showConfirmDialog } from "../../../../../stores/alerts" import { showToastNotification } from "../../../../../lib/notifications" +import { createDebouncedRefresh, filesystemInvalidationVersion } from "../../../../../lib/filesystem-events" import { writeClientLayoutValue } from "../../../../../stores/client-state" import { RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, @@ -267,6 +268,21 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS const browserParentPath = createMemo(() => getParentPath(browserPath())) const browserScopeKey = createMemo(() => `${options.instanceId}:${options.worktreeSlug()}`) + let seenFilesystemInvalidation = filesystemInvalidationVersion(options.instanceId) + const filesystemRefresh = createDebouncedRefresh(() => { + void loadBrowserEntries(browserPath()) + const selected = browserSelectedPath() + if (selected && !browserSelectedDirty()) void openBrowserFile(selected) + }) + + createEffect(() => { + const version = filesystemInvalidationVersion(options.instanceId) + if (version === seenFilesystemInvalidation) return + seenFilesystemInvalidation = version + if (options.rightPanelTab() === "files") filesystemRefresh.trigger() + else setBrowserEntries(null) + }) + onCleanup(() => filesystemRefresh.cancel()) return () => ( ) => string @@ -38,6 +38,7 @@ export function useGitChanges(options: UseGitChangesOptions) { let passiveGitRefreshInFlight = false let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null let previousGitChangesActivationKey: string | null = null + let seenFilesystemInvalidation = filesystemInvalidationVersion(options.instanceId) const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries())) @@ -429,21 +430,15 @@ export function useGitChanges(options: UseGitChangesOptions) { void passiveRefreshGitStatus() }) + const filesystemRefresh = createDebouncedRefresh(() => void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })) createEffect(() => { - if (options.rightPanelTab() !== "git-changes") return - - const unsubscribe = serverEvents.on("instance.event", (event) => { - if (event.type !== "instance.event") return - if (event.instanceId !== options.instanceId) return - const eventType = (event.event as { type?: unknown } | undefined)?.type - if (eventType !== "session.updated") return - void passiveRefreshGitStatus({ forceReloadSelectedDiff: true }) - }) - - onCleanup(() => { - unsubscribe() - }) + const version = filesystemInvalidationVersion(options.instanceId) + if (version === seenFilesystemInvalidation) return + seenFilesystemInvalidation = version + if (options.rightPanelTab() === "git-changes") filesystemRefresh.trigger() + else setGitStatusEntries(null) }) + onCleanup(() => filesystemRefresh.cancel()) createEffect(() => { if (options.rightPanelTab() === "git-changes") return diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 36975ea6..2f5e5f92 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -12,6 +12,7 @@ import { useSpeech } from "../lib/hooks/use-speech" import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu" import { getMessageDurationMs, getMessageStartedAt } from "../lib/message-timing" import SpeechActionButton from "./speech-action-button" +import { shouldShowGeneratingPlaceholder } from "../stores/message-v2/message-status" interface MessageItemProps { record: MessageRecord @@ -260,18 +261,7 @@ export default function MessageItem(props: MessageItemProps) { } const isGenerating = () => { - if (hasContent()) { - return false - } - - // Prefer the local record status for streaming placeholders. - if (!isUser() && props.record.status === "streaming") { - return true - } - - const info = props.messageInfo - const timeInfo = info?.time as { created: number; end?: number } | undefined - return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0)) + return shouldShowGeneratingPlaceholder(hasContent(), isUser() ? "user" : "assistant", props.record.status) } const handleRevert = () => { diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 20013be3..46327cf3 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -249,7 +249,7 @@ export default function MessageSection(props: MessageSectionProps) { let scrollControlsRef: HTMLDivElement | undefined // Only preferences should force a follow-token re-anchor. Message/session - // revision churn at the end of a turn (message.updated, session.idle, etc.) + // revision churn at the end of a turn (terminal updates, session idle, etc.) // should not trigger an immediate scroll-to-bottom. const followToken = createMemo(() => preferenceSignature()) diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index b2de5f11..9d4e5a6d 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -14,6 +14,9 @@ import { import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" +import FormRequest from "./form-request" +import { getFormQueue, type FormInfo } from "../stores/forms" +import { sendFormCancel, sendFormReply } from "../stores/instances" const LazyToolCall = lazy(() => import("./tool-call")) @@ -195,11 +198,13 @@ const PermissionApprovalModal: Component = (props) const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId)) const questionQueue = createMemo(() => getQuestionQueue(props.instanceId)) + const formQueue = createMemo(() => getFormQueue(props.instanceId)) const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) type InterruptionItem = | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequest } | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } + | { kind: "form"; id: string; sessionId: string; createdAt: number; payload: FormInfo } const orderedQueue = createMemo(() => { const permissions = permissionQueue().map((permission) => ({ @@ -218,7 +223,15 @@ const PermissionApprovalModal: Component = (props) payload: question, })) - return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt) + const forms = formQueue().map((form, index) => ({ + kind: "form" as const, + id: form.id, + sessionId: form.sessionID, + createdAt: Number.MAX_SAFE_INTEGER - formQueue().length + index, + payload: form, + })) + + return [...permissions, ...questions, ...forms].sort((a, b) => a.createdAt - b.createdAt) }) const hasRequests = createMemo(() => orderedQueue().length > 0) @@ -305,7 +318,8 @@ const PermissionApprovalModal: Component = (props) if (item.kind === "permission") { return resolveToolCallFromPermission(props.instanceId, item.payload) } - return resolveToolCallFromQuestion(props.instanceId, item.payload) + if (item.kind === "question") return resolveToolCallFromQuestion(props.instanceId, item.payload) + return null }) const showFallback = () => !resolved() @@ -313,12 +327,15 @@ const PermissionApprovalModal: Component = (props) const kindLabel = () => item.kind === "permission" ? t("permissionApproval.kind.permission") - : t("permissionApproval.kind.question") + : item.kind === "question" + ? t("permissionApproval.kind.question") + : t("permissionApproval.kind.form") const primaryTitle = () => { if (item.kind === "permission") { return getPermissionDisplayTitle(item.payload) } + if (item.kind === "form") return item.payload.title const first = item.payload.questions?.[0]?.question return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question") } @@ -327,6 +344,7 @@ const PermissionApprovalModal: Component = (props) if (item.kind === "permission") { return getPermissionKind(item.payload) } + if (item.kind === "form") return t("permissionApproval.kind.form") const count = item.payload.questions?.length ?? 0 return count === 1 ? t("permissionApproval.questionCount.one", { count }) @@ -376,85 +394,77 @@ const PermissionApprovalModal: Component = (props) - -
- {primaryTitle()} -
- -
-