mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-20 22:03:28 +00:00
merge(DEV-v2): refresh worktree session families
Integrate the current native V2 routing, workspace controls, shared desktop opener, lifecycle hardening, and pruned validation matrix while removing the PR's duplicate Electron and Tauri file-manager bridges. Inventory sessions through paginated native project filtering, move complete families transactionally, revalidate worktree path and HEAD immediately before deletion, and fail closed if a checkout is replaced. Preserve evacuated sessions at the safe root rather than rolling them into a replacement checkout. Reduce feature tests to project pagination, family moves, rollback, active-session blocking, replacement races, route ownership, UI projection, and authoritative refresh. Server, UI, Electron, and Rust checks plus focused suites and diff checks pass; the full server matrix had one transient process timeout that passed alone.
This commit is contained in:
commit
cee018dbd0
283 changed files with 13279 additions and 8724 deletions
2
.github/workflows/comment-pr-artifacts.yml
vendored
2
.github/workflows/comment-pr-artifacts.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BASE_REF" = "dev" ]; then
|
||||
if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
24
.github/workflows/pr-build.yml
vendored
24
.github/workflows/pr-build.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BASE_REF" = "dev" ]; then
|
||||
if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -104,44 +104,58 @@ jobs:
|
|||
- name: Test changed runnable UI behavior
|
||||
run: >-
|
||||
node --import tsx --test
|
||||
packages/ui/src/components/message-timeline-v2.test.ts
|
||||
packages/ui/src/components/provider-auth/provider-options.test.ts
|
||||
packages/ui/src/components/session/session-bottom-pin-intent.test.ts
|
||||
packages/ui/src/components/session-list-visibility.test.ts
|
||||
packages/ui/src/components/unified-picker-path.test.ts
|
||||
packages/ui/src/components/virtual-follow-behavior.test.ts
|
||||
packages/ui/src/lib/filesystem-events.test.ts
|
||||
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
|
||||
packages/ui/src/lib/hooks/use-instance-metadata.test.ts
|
||||
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
|
||||
packages/ui/src/lib/launch-errors.test.ts
|
||||
packages/ui/src/lib/message-selection-position.test.ts
|
||||
packages/ui/src/lib/model-visibility.test.ts
|
||||
packages/ui/src/lib/runtime-env.test.ts
|
||||
packages/ui/src/lib/trailing-resync.test.ts
|
||||
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
|
||||
packages/ui/src/stores/app-session-reconciliation.test.ts
|
||||
packages/ui/src/stores/app-session-restore-gate.test.ts
|
||||
packages/ui/src/stores/app-session-restore-queue.test.ts
|
||||
packages/ui/src/stores/app-session-restore-timeout.test.ts
|
||||
packages/ui/src/stores/app-session-snapshot-merge.test.ts
|
||||
packages/ui/src/stores/restore-workspace-commit-gates.test.ts
|
||||
packages/ui/src/stores/client-state-codec.test.ts
|
||||
packages/ui/src/stores/client-state.test.ts
|
||||
packages/ui/src/stores/instances-restore-cancellation.test.ts
|
||||
packages/ui/src/stores/message-v2/instance-store.test.ts
|
||||
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
|
||||
packages/ui/src/stores/message-v2/message-status.test.ts
|
||||
packages/ui/src/stores/message-v2/normalizers.test.ts
|
||||
packages/ui/src/stores/pty-store.test.ts
|
||||
packages/ui/src/stores/session-generation-recovery.test.ts
|
||||
packages/ui/src/stores/session-list-options.test.ts
|
||||
packages/ui/src/stores/session-pagination.test.ts
|
||||
packages/ui/src/stores/session-pending-state.test.ts
|
||||
packages/ui/src/stores/session-tree.test.ts
|
||||
packages/ui/src/stores/workspace-load-readiness.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-tool-target.test.ts
|
||||
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/native-session-streaming.test.ts
|
||||
packages/ui/src/stores/permission-lifecycle.test.ts
|
||||
packages/ui/src/stores/pty-store-reactivity.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
|
||||
packages/ui/src/stores/worktree-ready.test.ts
|
||||
|
||||
- name: Test server
|
||||
|
|
@ -188,4 +202,4 @@ jobs:
|
|||
|
||||
- name: Test Tauri crate on Windows
|
||||
working-directory: packages/tauri-app/src-tauri
|
||||
run: cargo test --locked
|
||||
run: cargo test --locked -- --test-threads=1
|
||||
|
|
|
|||
4
.github/workflows/restrict-non-dev-prs.yml
vendored
4
.github/workflows/restrict-non-dev-prs.yml
vendored
|
|
@ -14,7 +14,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
restrict-non-dev-prs:
|
||||
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
||||
if: ${{ github.event.pull_request.base.ref != 'dev' && github.event.pull_request.base.ref != 'DEV-v2' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target the \`dev\` or \`DEV-v2\` branch. Please retarget this PR to an authorized development branch."
|
||||
|
||||
- name: Close unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ description: |
|
|||
|
||||
## Native OpenCode V2 Baseline
|
||||
|
||||
- The only OpenCode client dependency is exact version `@opencode-ai/client@0.0.0-next-17288` 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. Server and UI must stay aligned on the latest reviewed `next` release; runtime CLI discovery is not exact-version-gated. 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`), session instructions (`client.session.instructions.entry`), and location-scoped native PTYs. Shell remains separate. The Status panel lists PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop API, so output and distinct stop are unavailable; removal is the native stop action for a running PTY.
|
||||
- CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`.
|
||||
- V2 service startup forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; never share the V1 database with V2.
|
||||
|
||||
## 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-17288` |
|
||||
| One `opencode serve` per workspace | One `Service.ensure` shared service |
|
||||
| Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` 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` or server plugin/background-process paths | Separate native Shell/instructions and native PTY management |
|
||||
| OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers |
|
||||
| Hardcoded UI strings | `t()` / `tGlobal()` and every locale |
|
||||
|
||||
|
|
|
|||
|
|
@ -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-17288` |
|
||||
| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell/instructions, location-scoped PTYs | latest reviewed experimental `@opencode-ai/client` `next` 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.
|
||||
Native Shell remains separate from PTY management. The Status panel lists location-scoped PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop endpoint; output display and a distinct stop action are unavailable, and removal is the native stop action for a running PTY. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used 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 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; V1 and V2 databases must remain separate.
|
||||
|
||||
## Entry Points
|
||||
|
||||
- Server: `packages/server/src/index.ts`
|
||||
|
|
|
|||
|
|
@ -4,20 +4,23 @@
|
|||
|
||||
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
|
||||
## Prompt, Shell, Instructions, And PTYs
|
||||
|
||||
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`.
|
||||
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.
|
||||
3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. Native Shell remains separate from PTY management.
|
||||
4. The Status panel lists native PTYs for the active location, displays their native metadata, and refreshes on PTY events and reconnect.
|
||||
5. Title updates and removal use native PTY APIs; the proxy verifies native `cwd` ownership before ID-scoped operations. Removing a running PTY is its native stop action.
|
||||
6. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable.
|
||||
7. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route.
|
||||
8. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores.
|
||||
|
||||
No CodeNomad OpenCode plugin participates in this flow.
|
||||
No CodeNomad OpenCode plugin participates in this flow. `packages/opencode-plugin` and server plugin/background-process paths remain deleted and must not be restored.
|
||||
|
||||
## Permission And Yolo
|
||||
|
||||
|
|
@ -41,3 +44,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.*`.
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
|
||||
## Package
|
||||
|
||||
CodeNomad pins `@opencode-ai/client@0.0.0-next-17288` exactly in both `packages/server/package.json` and `packages/ui/package.json`.
|
||||
CodeNomad keeps the experimental `@opencode-ai/client` protocol aligned in `packages/server/package.json` and `packages/ui/package.json` on the latest reviewed `next` release. Runtime CLI discovery is not exact-version-gated. 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.
|
||||
|
|
|
|||
|
|
@ -2,32 +2,37 @@
|
|||
|
||||
## Contract
|
||||
|
||||
- Version is pinned to `@opencode-ai/client@0.0.0-next-17288`; update server and UI together.
|
||||
- The package root is the generated zero-Effect Promise client. Use installed declarations, not old SDK examples.
|
||||
- Keep server and UI on the same latest reviewed experimental `@opencode-ai/client` `next` release. Review OpenCode release notes, current documentation, installed declarations, and proxy/API parity on every upgrade; runtime CLI discovery is not exact-version-gated.
|
||||
- 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.
|
||||
- V2 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; V1/V2 schemas must not share a database.
|
||||
- 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; Shell remains separate from PTY management |
|
||||
| PTY list/metadata/title/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on PTY events/reconnect |
|
||||
| PTY output/distinct stop | Unavailable in current installed declarations; removal is the native stop action for a running PTY |
|
||||
| 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 |
|
||||
| Browser event multiplexing | CodeNomad `/api/events` |
|
||||
|
||||
Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, plugin background-process tools, or deleted plugin/runtime file paths.
|
||||
Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so the UI cannot display PTY output or offer a distinct stop action. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths.
|
||||
|
|
|
|||
|
|
@ -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 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`. 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
|
||||
|
||||
|
|
@ -20,11 +22,15 @@ const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) })
|
|||
|
||||
Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`. Native location/directory inputs replace the old per-worktree-client pattern. Destroy cached clients when an instance is removed.
|
||||
|
||||
## Native Shell And Instructions
|
||||
## Native Shell, Instructions, And PTYs
|
||||
|
||||
- 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.
|
||||
- Shell remains separate from native PTY management.
|
||||
- PTYs are location-scoped and listed with `client.pty.list`; the Status panel refreshes on PTY lifecycle events and reconnect, displays native metadata, and supports title updates.
|
||||
- PTY ID operations are ownership-checked against the native `cwd`. Removal is the native stop action for a running PTY.
|
||||
- Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable.
|
||||
- Keep `packages/opencode-plugin` and server plugin/background-process paths deleted.
|
||||
|
||||
## Event Flow
|
||||
|
||||
|
|
@ -33,7 +39,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- Force V2 `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db` and never share the V1 database with V2.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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/<locale>/` 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)
|
||||
|
|
@ -49,6 +49,13 @@ Behavior for agents:
|
|||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||
- Use the `write` tool only when creating new files from scratch.
|
||||
|
||||
## V2 Runtime Handoff
|
||||
- Treat `codenomad-v2-slots/build-{A|B}/release` as build staging and `codenomad-v2-slots/{A|B}` as the runnable deployment slots. Launch the deployed slot recorded by its `deployment.json`.
|
||||
- For a first V2 launch, start the deployed executable from PowerShell with the dedicated WebView2 profile, CDP port, Rust backtraces, and Node source maps described in `MIGRATION_V2.md`.
|
||||
- To replace a running V2 instance, submit `codenomad-v2-handoff-request.json` to `codenomad-v2-handoff.ps1` through an interactive Windows scheduled task. The task must be owned by the logged-in user so it runs outside the CodeNomad process tree while retaining desktop access.
|
||||
- Set `waitForPid` to the top-level CodeNomad window process, `executable` to the deployed target slot, and `fallbackExecutable` to the previously validated slot.
|
||||
- Consider the handoff complete after `codenomad-v2-handoff-result.json` reports `status: "started"`. Then verify that the reported PID is running from the requested slot and that the executable hash matches that slot's `deployment.json` before reporting success.
|
||||
|
||||
## Commit Message Guidelines
|
||||
- When creating commits, use detailed commit messages: a concise conventional-style subject followed by body paragraphs that explain the user-visible behavior change, the implementation approach, important edge cases or platform considerations, and the validation or test coverage added.
|
||||
- Prefer messages that explain why the change exists and how regressions are prevented, not just a list of touched files.
|
||||
|
|
|
|||
|
|
@ -111,10 +111,15 @@ 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-17288`; 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 must use the same latest reviewed `@opencode-ai/client` `next` release. Runtime discovery does not require an exact CLI version. Review OpenCode release notes, current documentation, and installed declarations on every upgrade; this is not the public `@opencode-ai/sdk` contract.
|
||||
- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode-ai/client/dist/promise/`.
|
||||
- `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 always uses `~/.local/share/opencode2/opencode.db`. Never reuse the V1 database for V2.
|
||||
- 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 Shell (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from native V2 PTY management.
|
||||
- Native PTYs are location-scoped and listed in the Status panel. The UI refreshes them on PTY events and reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so removal is the native stop action for a running PTY. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored.
|
||||
- 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
|
||||
|
|
|
|||
121
MIGRATION_V2.md
121
MIGRATION_V2.md
|
|
@ -8,21 +8,25 @@ 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-17288`.
|
||||
- Replace `@opencode-ai/sdk` with the experimental `@opencode-ai/client` protocol. Server and UI track the latest reviewed `next` release together; 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.
|
||||
- Reconcile session state through `session.active()` after reconnecting so missed events do not leave stale working states.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Removed Legacy Components
|
||||
|
||||
- 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.
|
||||
- Keep Git mutation operations on the CodeNomad server where V2 does not yet provide sufficient parity.
|
||||
|
||||
|
|
@ -35,39 +39,29 @@ 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.
|
||||
- Stop a shared service only when CodeNomad can prove that its own process started it.
|
||||
|
||||
## 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.
|
||||
- Consistent behavior between root workspaces and Git worktrees.
|
||||
- Simpler service startup, event handling, and client-side API access.
|
||||
- Discover the installed `opencode2` service without requiring an exact runtime version match. Each dependency upgrade must review OpenCode release notes, current documentation, installed client declarations, and proxy/API parity.
|
||||
- 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. 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.
|
||||
- Force the V2 service database to `~/.local/share/opencode2/opencode.db`. V1 and V2 must never point at the same database because their schemas are incompatible.
|
||||
- Isolate V2 restore state under `~/.codenomad/client-state/v2` and copy V1 state non-destructively on first launch, preserving downgrade history.
|
||||
|
||||
## Current Status
|
||||
|
||||
- Server and UI typechecks pass.
|
||||
- Focused tests for service ownership, proxy security, worktree event routing, provider authentication, and voice instructions pass.
|
||||
- UI tests and builds passed earlier in the migration.
|
||||
- A real service smoke test is blocked because `opencode2` is not installed in the current `PATH`.
|
||||
- The migration is not merge-ready yet. The final security review found unresolved proxy isolation issues that must be fixed first.
|
||||
- The server/UI client dependencies are aligned on the latest reviewed OpenCode `next` release. The installed `opencode2` CLI is not exact-version-gated at runtime.
|
||||
- The current working tree includes hardened lifecycle proof, launch-configuration matching, an isolated V2 database, deferred location eviction, proxy path/location validation, and reconnect reconciliation changes.
|
||||
- Current installed client declarations provide native PTY list/get/create/title-or-size update/remove and 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
|
||||
|
||||
- Fix the encoded-path proxy issue that can redirect an authenticated upstream request to another host.
|
||||
- Restrict or filter global V2 endpoints that are not scoped by `Location`.
|
||||
- Enforce session ownership for experimental session log routes.
|
||||
- Validate embedded locations when importing sessions.
|
||||
- Harden shared service registration and multi-process shutdown behavior.
|
||||
- Complete the remaining high-priority event and provider-auth security fixes.
|
||||
- Run the complete test and build matrix after the fixes.
|
||||
- Run an end-to-end smoke test with the actual `opencode2` binary.
|
||||
- Run the real-service smoke test before marking the PR ready for review.
|
||||
|
||||
## Validation
|
||||
|
||||
|
|
@ -80,8 +74,79 @@ The final validation should include:
|
|||
- `git diff --check`.
|
||||
- A real OpenCode V2 startup, session, event, Shell, and shutdown smoke test.
|
||||
|
||||
### Required Parallel UI Smoke
|
||||
|
||||
CodeNomad V1 is the working environment and must remain open and untouched. V2 always uses `~/.local/share/opencode2/opencode.db`. Build into `codenomad-v2-slots/build-{A|B}/release`, copy the validated output into the corresponding `codenomad-v2-slots/{A|B}` deployment slot, and record its source, hash, slot, and deployment time in `deployment.json`.
|
||||
|
||||
For a first V2 launch, start the deployed slot beside V1 from PowerShell with a dedicated CDP port, WebView profile, Rust backtraces, and Node source maps:
|
||||
|
||||
```powershell
|
||||
$slot = "$env:TEMP\opencode\codenomad-v2-slots\A"
|
||||
$environment = @{
|
||||
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9223'
|
||||
WEBVIEW2_USER_DATA_FOLDER = "$env:TEMP\opencode\codenomad-v2-debug"
|
||||
RUST_BACKTRACE = '1'
|
||||
NODE_OPTIONS = '--enable-source-maps'
|
||||
}
|
||||
Start-Process -FilePath "$slot\codenomad-tauri.exe" -WorkingDirectory $slot -Environment $environment
|
||||
```
|
||||
|
||||
To replace an active V2 instance, write the target slot, validated fallback slot, top-level CodeNomad window PID, and a unique request ID to `$env:TEMP\opencode\codenomad-v2-handoff-request.json`. Run `$env:TEMP\opencode\codenomad-v2-handoff.ps1` through an interactive Windows scheduled task owned by the logged-in user. This gives the handoff an external lifetime and desktop access while it closes the active process, applies the environment above, and starts the target slot:
|
||||
|
||||
```powershell
|
||||
$root = "$env:TEMP\opencode"
|
||||
$targetSlot = Join-Path $root 'codenomad-v2-slots\A'
|
||||
$fallbackSlot = Join-Path $root 'codenomad-v2-slots\B'
|
||||
$requestPath = Join-Path $root 'codenomad-v2-handoff-request.json'
|
||||
$handoffPath = Join-Path $root 'codenomad-v2-handoff.ps1'
|
||||
$taskName = 'CodeNomad-V2-Handoff'
|
||||
$windowProcess = Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.Name -eq 'codenomad-tauri.exe' -and
|
||||
$_.ExecutablePath -like "$root\codenomad-v2-slots\?\codenomad-tauri.exe" -and
|
||||
$_.CommandLine -notmatch 'internal-cli-launcher'
|
||||
} |
|
||||
Select-Object -First 1
|
||||
|
||||
@{
|
||||
mode = 'launch'
|
||||
requestId = [guid]::NewGuid().ToString('N')
|
||||
executable = Join-Path $targetSlot 'codenomad-tauri.exe'
|
||||
fallbackExecutable = Join-Path $fallbackSlot 'codenomad-tauri.exe'
|
||||
waitForPid = $windowProcess.ProcessId
|
||||
closeOldProcess = $true
|
||||
clientStateSeedPath = $null
|
||||
clientStateSeedSha256 = $null
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath $requestPath -Encoding utf8
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute (Get-Command pwsh).Source -Argument (
|
||||
"-NoProfile -ExecutionPolicy Bypass -File `"$handoffPath`" -RequestPath `"$requestPath`""
|
||||
)
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5)
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) `
|
||||
-LogonType Interactive `
|
||||
-RunLevel Limited
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
```
|
||||
|
||||
Read `$env:TEMP\opencode\codenomad-v2-handoff-result.json` after reconnection. A successful handoff has `status: "started"`; verify its PID runs from the requested slot and compare the executable hash with that slot's `deployment.json` before recording the smoke build as active. Remove the completed one-shot task with `Unregister-ScheduledTask -TaskName 'CodeNomad-V2-Handoff' -Confirm:$false`.
|
||||
|
||||
The smoke is complete only after all of these actions succeed in the visible V2 UI:
|
||||
|
||||
1. Confirm the selected `opencode2` binary reports the latest version reviewed for this branch.
|
||||
2. Open `D:\CodeNomad` from Recent Folders or the folder picker.
|
||||
3. Open an existing session from the session list; direct API session creation is not a substitute.
|
||||
4. Send a prompt from the composer and receive its visible assistant response.
|
||||
5. Reload the V2 window and confirm the workspace and session list recover. While V1 owns cross-host restore, reopen the existing V2 session from the list and confirm its messages and pending state recover correctly.
|
||||
6. Exercise one PTY create/list/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. PTY creation is not currently exposed in the visible UI.
|
||||
|
||||
Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The PTY protocol check is the sole exception until the UI exposes creation.
|
||||
|
||||
## Review Notes
|
||||
|
||||
- The OpenCode V2 client is still a beta contract and may change.
|
||||
- The OpenCode V2 protocol client is experimental and may change. Review its release notes, current documentation, and installed declarations on every upgrade; public `@opencode-ai/sdk` examples are not authoritative for this build.
|
||||
- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and the installed `node_modules/@opencode-ai/client/dist/promise/` declarations.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ See full workaround in the original README.
|
|||
|
||||
## Community
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
[](https://star-history.dera.page/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -57,41 +57,6 @@ Executive summary of the entire project - **start here!**
|
|||
|
||||
**Read this to understand:** Current implementation boundaries
|
||||
|
||||
### [build-roadmap.md](build-roadmap.md)
|
||||
|
||||
**Development plan**
|
||||
|
||||
- 8 phases of development
|
||||
- Task dependencies
|
||||
- Timeline estimates
|
||||
- Success criteria
|
||||
- Risk mitigation
|
||||
|
||||
**Read this to understand:** The development journey from start to finish
|
||||
|
||||
---
|
||||
|
||||
## Task Documents
|
||||
|
||||
### [tasks/README.md](../tasks/README.md)
|
||||
|
||||
**Task management guide**
|
||||
|
||||
- Task workflow
|
||||
- Naming conventions
|
||||
- How to work on tasks
|
||||
- Progress tracking
|
||||
|
||||
### Task Files (in tasks/todo/)
|
||||
|
||||
- **001-project-setup.md** - Electron + SolidJS boilerplate
|
||||
- **002-empty-state-ui.md** - Initial UI with folder selection
|
||||
- **003-process-manager.md** - OpenCode server spawning
|
||||
- **004-sdk-integration.md** - API client integration
|
||||
- **005-session-picker-modal.md** - Session selection UI
|
||||
|
||||
More tasks will be added as we progress through phases.
|
||||
|
||||
---
|
||||
|
||||
## Reading Order
|
||||
|
|
@ -101,15 +66,11 @@ More tasks will be added as we progress through phases.
|
|||
1. [SUMMARY.md](SUMMARY.md) - Get the big picture
|
||||
2. [architecture.md](architecture.md) - Understand the structure
|
||||
3. [user-interface.md](user-interface.md) - See what you're building
|
||||
4. [build-roadmap.md](build-roadmap.md) - Understand the plan
|
||||
5. [tasks/README.md](../tasks/README.md) - Learn the workflow
|
||||
|
||||
### For Implementers:
|
||||
|
||||
1. [tasks/README.md](../tasks/README.md) - Understand task workflow
|
||||
2. [technical-implementation.md](technical-implementation.md) - Implementation patterns
|
||||
3. [tasks/todo/001-\*.md](../tasks/todo/) - Start with first task
|
||||
4. Refer to architecture.md and user-interface.md as needed
|
||||
1. [technical-implementation.md](technical-implementation.md) - Implementation patterns
|
||||
2. Refer to architecture.md and user-interface.md as needed
|
||||
|
||||
### For Designers:
|
||||
|
||||
|
|
@ -117,12 +78,6 @@ More tasks will be added as we progress through phases.
|
|||
2. [architecture.md](architecture.md) - Component structure
|
||||
3. [SUMMARY.md](SUMMARY.md) - Feature overview
|
||||
|
||||
### For Project Managers:
|
||||
|
||||
1. [SUMMARY.md](SUMMARY.md) - Executive overview
|
||||
2. [build-roadmap.md](build-roadmap.md) - Timeline and phases
|
||||
3. [tasks/README.md](../tasks/README.md) - Task tracking
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
|
@ -130,10 +85,7 @@ More tasks will be added as we progress through phases.
|
|||
### Common Questions
|
||||
|
||||
**Q: Where do I start?**
|
||||
A: Read [SUMMARY.md](SUMMARY.md), then start [Task 001](../tasks/todo/001-project-setup.md)
|
||||
|
||||
**Q: How long will this take?**
|
||||
A: See [build-roadmap.md](build-roadmap.md) - MVP in 3-7 weeks depending on commitment
|
||||
A: Read [SUMMARY.md](SUMMARY.md), then [architecture.md](architecture.md) and [technical-implementation.md](technical-implementation.md).
|
||||
|
||||
**Q: What does the UI look like?**
|
||||
A: See [user-interface.md](user-interface.md) for complete specifications
|
||||
|
|
@ -143,38 +95,3 @@ A: See [architecture.md](architecture.md) for system design
|
|||
|
||||
**Q: How do I build feature X?**
|
||||
A: See [technical-implementation.md](technical-implementation.md) for patterns
|
||||
|
||||
**Q: What's the development plan?**
|
||||
A: See [build-roadmap.md](build-roadmap.md) for phases
|
||||
|
||||
---
|
||||
|
||||
## Document Status
|
||||
|
||||
| Document | Status | Last Updated |
|
||||
| --------------------------- | ----------- | ------------ |
|
||||
| README.md | ✅ Complete | 2024-10-22 |
|
||||
| SUMMARY.md | ✅ Complete | 2024-10-22 |
|
||||
| architecture.md | ✅ Complete | 2024-10-22 |
|
||||
| user-interface.md | ✅ Complete | 2024-10-22 |
|
||||
| technical-implementation.md | ✅ Complete | 2024-10-22 |
|
||||
| build-roadmap.md | ✅ Complete | 2024-10-22 |
|
||||
| tasks/README.md | ✅ Complete | 2024-10-22 |
|
||||
| Task 001-005 | ✅ Complete | 2024-10-22 |
|
||||
|
||||
**Project phase:** Post-MVP (Phases 1-3 complete; Phase 4 work underway).
|
||||
|
||||
---
|
||||
|
||||
## Contributing to Documentation
|
||||
|
||||
When updating documentation:
|
||||
|
||||
1. Update the relevant file
|
||||
2. Update "Last Updated" in this index
|
||||
3. Update SUMMARY.md if adding major changes
|
||||
4. Keep consistent formatting and style
|
||||
|
||||
---
|
||||
|
||||
_This index will be updated as more documentation is added._
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
## Current Status
|
||||
|
||||
We have completed the MVP milestones (Phases 1-3) and are now operating in post-MVP mode. Future work prioritizes multi-instance support, advanced input polish, and system integrations outlined in later phases.
|
||||
The MVP and multi-instance milestones are complete. Current architecture and implementation details live in the documents indexed below.
|
||||
|
||||
## What We've Created
|
||||
|
||||
A comprehensive specification and task breakdown for building the CodeNomad desktop application.
|
||||
Development documentation for the CodeNomad desktop application.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ packages/ui/ SolidJS UI and native Promise clients
|
|||
packages/electron-app Electron host
|
||||
packages/tauri-app/ Tauri host
|
||||
dev-docs/ Development documentation
|
||||
tasks/ Task tracking
|
||||
```
|
||||
|
||||
## Documentation Overview
|
||||
|
|
@ -80,75 +79,11 @@ 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
|
||||
|
||||
### 4. Build Roadmap (build-roadmap.md)
|
||||
|
||||
**What it covers:**
|
||||
|
||||
- 8 development phases
|
||||
- Task dependencies
|
||||
- Timeline estimates
|
||||
- Success criteria per phase
|
||||
- Risk mitigation
|
||||
- Release strategy
|
||||
|
||||
**Phases:**
|
||||
|
||||
1. **Foundation** (Week 1) - Project setup, process management
|
||||
2. **Core Chat** (Week 2) - Message display, SSE streaming
|
||||
3. **Essential Features** (Week 3) - Markdown, agents, errors
|
||||
4. **Multi-Instance** (Week 4) - Multiple projects support
|
||||
5. **Advanced Input** (Week 5) - Commands, file attachments
|
||||
6. **Polish** (Week 6) - UX refinements, settings
|
||||
7. **System Integration** (Week 7) - Native features
|
||||
8. **Advanced** (Week 8+) - Performance, plugins
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Current Tasks (Phase 1)
|
||||
|
||||
**001 - Project Setup** (2-3 hours)
|
||||
|
||||
- Set up Electron + SolidJS + Vite
|
||||
- Configure TypeScript, TailwindCSS
|
||||
- Create basic project structure
|
||||
- Verify build pipeline works
|
||||
|
||||
**002 - Empty State UI** (2-3 hours)
|
||||
|
||||
- Create empty state component
|
||||
- Implement folder selection dialog
|
||||
- Add keyboard shortcuts
|
||||
- Style and test responsiveness
|
||||
|
||||
**003 - Shared Service Manager** (4-5 hours)
|
||||
|
||||
- Discover or start one OpenCode service with `Service.ensure`
|
||||
- Validate workspace locations/directories
|
||||
- Stop only the shared endpoint CodeNomad owns
|
||||
- Handle errors and timeouts
|
||||
- Auto-cleanup on app quit
|
||||
|
||||
**004 - Native Client Integration** (3-4 hours)
|
||||
|
||||
- Create native clients through the CodeNomad proxy
|
||||
- Fetch sessions, agents, models
|
||||
- Implement session CRUD operations
|
||||
- Add error handling and retries
|
||||
|
||||
**005 - Session Picker Modal** (3-4 hours)
|
||||
|
||||
- Build modal with session list
|
||||
- Agent selector for new sessions
|
||||
- Keyboard navigation
|
||||
- Loading and error states
|
||||
|
||||
**Total Phase 1 time: ~15-20 hours (2-3 weeks part-time)**
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Two-Level Tabs
|
||||
|
|
@ -159,14 +94,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
|
||||
|
||||
|
|
@ -194,15 +129,6 @@ tasks/ Task tracking
|
|||
|
||||
## Implementation Guidelines
|
||||
|
||||
### For Each Task:
|
||||
|
||||
1. Read task file completely
|
||||
2. Review related documentation
|
||||
3. Follow steps in order
|
||||
4. Check off acceptance criteria
|
||||
5. Test thoroughly
|
||||
6. Move to done/ when complete
|
||||
|
||||
### Code Standards:
|
||||
|
||||
- TypeScript for everything
|
||||
|
|
@ -220,73 +146,14 @@ tasks/ Task tracking
|
|||
- Test edge cases (long text, special chars)
|
||||
- Keyboard navigation verification
|
||||
|
||||
## Next Steps
|
||||
|
||||
### To Start Building:
|
||||
|
||||
1. **Read all documentation**
|
||||
- Understand architecture
|
||||
- Review UI specifications
|
||||
- Study technical approach
|
||||
|
||||
2. **Start with Task 001**
|
||||
- Set up project structure
|
||||
- Install dependencies
|
||||
- Verify build works
|
||||
|
||||
3. **Follow sequential order**
|
||||
- Each task builds on previous
|
||||
- Don't skip ahead
|
||||
- Dependencies matter
|
||||
|
||||
4. **Track progress**
|
||||
- Update task checkboxes
|
||||
- Move completed tasks to done/
|
||||
- Update roadmap as you go
|
||||
|
||||
### When You Hit Issues:
|
||||
|
||||
1. Review task prerequisites
|
||||
2. Check documentation for clarification
|
||||
3. Look at related specs
|
||||
4. Ask questions on unclear requirements
|
||||
5. Document blockers and solutions
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### MVP (After Task 015)
|
||||
|
||||
- Can select folder → spawn server → chat
|
||||
- Messages stream in real-time
|
||||
- Can switch agents and models
|
||||
- Tool executions visible
|
||||
- Basic error handling works
|
||||
- **Performance is NOT a concern** - focus on functionality
|
||||
|
||||
### Beta (After Task 030)
|
||||
|
||||
- Multi-instance support
|
||||
- Advanced input (files, commands)
|
||||
- Polished UX
|
||||
- Settings and preferences
|
||||
- Native menus
|
||||
|
||||
### v1.0 (After Task 035)
|
||||
|
||||
- System tray integration
|
||||
- Auto-updates
|
||||
- Crash reporting
|
||||
- Production-ready stability
|
||||
|
||||
## Useful References
|
||||
|
||||
### Within This Project:
|
||||
|
||||
- `README.md` - Project overview and getting started
|
||||
- `docs/architecture.md` - System design
|
||||
- `docs/user-interface.md` - UI specifications
|
||||
- `docs/technical-implementation.md` - Implementation details
|
||||
- `tasks/README.md` - Task workflow guide
|
||||
- `dev-docs/architecture.md` - System design
|
||||
- `dev-docs/user-interface.md` - UI specifications
|
||||
- `dev-docs/technical-implementation.md` - Implementation details
|
||||
|
||||
### External:
|
||||
|
||||
|
|
@ -297,38 +164,13 @@ tasks/ Task tracking
|
|||
|
||||
## Current OpenCode Baseline
|
||||
|
||||
- Native client: `@opencode-ai/client@0.0.0-next-17288`
|
||||
- Service: one shared `Service.ensure`
|
||||
- Experimental protocol client: server and UI use the same latest reviewed `@opencode-ai/client` `next` release; the runtime `opencode2` CLI is not exact-version-gated, and every upgrade reviews release notes, current documentation, installed declarations, and proxy/API parity
|
||||
- Service: one shared endpoint managed by CodeNomad's lease-locked process-proof lifecycle
|
||||
- Workspaces: native locations/directories
|
||||
- Shell and instructions: native session APIs
|
||||
- Database: V2 always uses `~/.local/share/opencode2/opencode.db`, separate from V1
|
||||
- Events: volatile native stream with authoritative reconnect reconciliation
|
||||
- Proxy: explicit method/path allowlist; upstream additions are not automatic
|
||||
- Shell and instructions: native session APIs, separate from PTY management
|
||||
- PTYs: location-scoped native entries in Status, refreshed on PTY events/reconnect with metadata, title updates, and ownership-checked removal; current installed declarations have no output/read/stream or separate stop API, so output and distinct stop are unavailable and removal stops a running PTY
|
||||
- Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted
|
||||
- Git mutations and Yolo: CodeNomad-owned
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
**Conservative estimate (part-time, ~15 hours/week):**
|
||||
|
||||
- Phase 1 (MVP Foundation): 2-3 weeks
|
||||
- Phase 2 (Core Chat): 2 weeks
|
||||
- Phase 3 (Essential): 2 weeks
|
||||
- **MVP Complete: 6-7 weeks**
|
||||
|
||||
**Aggressive estimate (full-time, ~40 hours/week):**
|
||||
|
||||
- Phase 1: 1 week
|
||||
- Phase 2: 1 week
|
||||
- Phase 3: 1 week
|
||||
- **MVP Complete: 3 weeks**
|
||||
|
||||
Add 2-4 weeks for testing, bug fixes, and polish before alpha release.
|
||||
|
||||
## This is a Living Document
|
||||
|
||||
As you build:
|
||||
|
||||
- Update estimates based on actual time
|
||||
- Add new tasks as needed
|
||||
- Refine specifications
|
||||
- Document learnings
|
||||
- Track blockers and solutions
|
||||
|
||||
Good luck! 🚀
|
||||
|
|
|
|||
|
|
@ -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-17288`.
|
||||
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates with the experimental `@opencode-ai/client` protocol, with server and UI kept on the same latest reviewed `next` release. This is 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 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.
|
||||
|
||||
The V2 service always uses `~/.local/share/opencode2/opencode.db`. V1 and V2 must use separate databases because their schemas are incompatible.
|
||||
|
||||
`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,14 @@ 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 management | Location-scoped OpenCode V2 API through the ownership-checking proxy; Status panel UI |
|
||||
| PTY output and distinct stop | Unavailable in the current installed declarations; removal is the native stop action for a running PTY |
|
||||
| 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 remains separate from PTY management. The Status panel lists location-scoped native PTYs, refreshes on PTY events/reconnect, displays native metadata, and allows title updates and ownership-checked removal. Current installed declarations expose no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored.
|
||||
|
||||
## Persistence
|
||||
|
||||
|
|
|
|||
|
|
@ -1,391 +0,0 @@
|
|||
# CodeNomad Build Roadmap
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the phased approach to building the CodeNomad desktop application. Each phase builds incrementally on the previous, with clear deliverables and milestones.
|
||||
|
||||
**Status:** MVP (Phases 1-3) is complete. Focus now shifts to post-MVP phases starting with multi-instance support and advanced input refinements.
|
||||
|
||||
## MVP Scope (Phases 1-3)
|
||||
|
||||
The minimum viable product includes:
|
||||
|
||||
- Single instance management
|
||||
- Session selection and creation
|
||||
- Message display (streaming)
|
||||
- Basic prompt input (text only)
|
||||
- Agent/model selection
|
||||
- Process lifecycle management
|
||||
|
||||
**Target: 3-4 weeks for MVP**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (Week 1)
|
||||
|
||||
**Goal:** Running desktop app connected to one shared OpenCode service
|
||||
|
||||
### Tasks
|
||||
|
||||
1. ✅ **001-project-setup** - Electron + SolidJS + Vite boilerplate
|
||||
2. ✅ **002-empty-state-ui** - Empty state UI with folder selection
|
||||
3. ✅ **003-process-manager** - Discover/start and manage the shared OpenCode service
|
||||
4. ✅ **004-sdk-integration** - Connect through the native OpenCode client
|
||||
5. ✅ **005-session-picker-modal** - Select/create session modal
|
||||
|
||||
### Deliverables
|
||||
|
||||
- App launches successfully
|
||||
- Can select folder
|
||||
- Shared service starts or reconnects automatically
|
||||
- Session picker appears
|
||||
- Can create/select session
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User can launch app → select folder → see session picker
|
||||
- Workspace location is ready on the shared service
|
||||
- Sessions fetch from API successfully
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Chat Interface (Week 2)
|
||||
|
||||
**Goal:** Display messages and send basic prompts
|
||||
|
||||
### Tasks
|
||||
|
||||
6. **006-instance-session-tabs** - Two-level tab navigation
|
||||
7. **007-message-display** - Render user and assistant messages
|
||||
8. **008-sse-integration** - Real-time message streaming
|
||||
9. **009-prompt-input-basic** - Text input with send functionality
|
||||
10. **010-tool-call-rendering** - Display tool executions inline
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Tab navigation works
|
||||
- Messages display correctly
|
||||
- Real-time updates via SSE
|
||||
- Can send text messages
|
||||
- Tool calls show status
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User can type message → see response stream in real-time
|
||||
- Tool executions visible and expandable
|
||||
- Multiple sessions can be open simultaneously
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Essential Features (Week 3)
|
||||
|
||||
**Goal:** Feature parity with basic TUI functionality
|
||||
|
||||
### Tasks
|
||||
|
||||
11. **011-agent-model-selectors** - Dropdown for agent/model switching
|
||||
12. **012-markdown-rendering** - Proper markdown with code highlighting
|
||||
13. **013-logs-tab** - View server logs
|
||||
14. **014-error-handling** - Comprehensive error states and recovery
|
||||
15. **015-keyboard-shortcuts** - Essential keyboard navigation
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Can switch agents and models
|
||||
- Markdown renders beautifully
|
||||
- Code blocks have syntax highlighting
|
||||
- Server logs accessible
|
||||
- Errors handled gracefully
|
||||
- Cmd/Ctrl+N, K, L shortcuts work
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User experience matches TUI quality
|
||||
- All error cases handled
|
||||
- Keyboard-first navigation option available
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Multi-Instance Support (Week 4)
|
||||
|
||||
**Goal:** Work on multiple projects simultaneously
|
||||
|
||||
### Tasks
|
||||
|
||||
16. **016-instance-tabs** - Instance-level tab management
|
||||
17. **017-instance-state-persistence** - Remember instances across restarts
|
||||
18. **018-child-session-handling** - Auto-create tabs for child sessions
|
||||
19. **019-instance-lifecycle** - Stop, restart, reconnect instances
|
||||
20. **020-multiple-sdk-clients** - Location-scoped clients over one shared service
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Multiple instance tabs
|
||||
- Persists across app restarts
|
||||
- Child sessions appear as new tabs
|
||||
- Can stop individual instances
|
||||
- All instances work independently
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User can work on 3+ projects simultaneously
|
||||
- App remembers state on restart
|
||||
- No interference between instances
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Advanced Input (Week 5)
|
||||
|
||||
**Goal:** Full input capabilities matching TUI
|
||||
|
||||
### Tasks
|
||||
|
||||
21. **021-slash-commands** - Command palette with autocomplete
|
||||
22. **022-file-attachments** - @ mention file picker
|
||||
23. **023-drag-drop-files** - Drag files onto input
|
||||
24. **024-attachment-chips** - Display and manage attachments
|
||||
25. **025-input-history** - Up/down arrow message history
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `/command` autocomplete works
|
||||
- `@file` picker searches files
|
||||
- Drag & drop attaches files
|
||||
- Attachment chips removable
|
||||
- Previous messages accessible
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Input feature parity with TUI
|
||||
- File context easy to add
|
||||
- Command discovery intuitive
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & UX (Week 6)
|
||||
|
||||
**Goal:** Production-ready user experience
|
||||
|
||||
### Tasks
|
||||
|
||||
26. **026-message-actions** - Copy, edit, regenerate messages
|
||||
27. **027-search-in-session** - Find text in conversation
|
||||
28. **028-session-management** - Rename, share, export sessions
|
||||
29. **029-settings-ui** - Preferences and configuration
|
||||
30. **030-native-menus** - Platform-native menu bar
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Message context menus
|
||||
- Search within conversation
|
||||
- Session CRUD operations
|
||||
- Settings dialog
|
||||
- Native File/Edit/View menus
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Feels polished and professional
|
||||
- All common actions accessible
|
||||
- Settings discoverable
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: System Integration (Week 7)
|
||||
|
||||
**Goal:** Native desktop app features
|
||||
|
||||
### Tasks
|
||||
|
||||
31. **031-system-tray** - Background running with tray icon
|
||||
32. **032-notifications** - Desktop notifications for events
|
||||
33. **033-auto-updater** - In-app update mechanism
|
||||
34. **034-crash-reporting** - Error reporting and recovery
|
||||
35. **035-performance-profiling** - Optimize rendering and memory
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Runs in background
|
||||
- Notifications for session activity
|
||||
- Auto-updates on launch
|
||||
- Crash logs captured
|
||||
- Smooth performance with large sessions
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- App feels native to platform
|
||||
- Updates seamlessly
|
||||
- Crashes don't lose data
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Advanced Features (Week 8+)
|
||||
|
||||
**Goal:** Beyond MVP, power user features
|
||||
|
||||
### Tasks
|
||||
|
||||
36. **036-virtual-scrolling** - Handle 1000+ message sessions
|
||||
37. **037-message-search-advanced** - Full-text search across sessions
|
||||
38. **038-workspace-management** - Save/load workspace configurations
|
||||
39. **039-theme-customization** - Custom themes and UI tweaks
|
||||
40. **040-native-capabilities** - Integrate additional native OpenCode capabilities
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Virtual scrolling for performance
|
||||
- Cross-session search
|
||||
- Workspace persistence
|
||||
- Theme editor
|
||||
- Native capability integration
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Handles massive sessions (5000+ messages)
|
||||
- Can search entire project history
|
||||
- Fully customizable
|
||||
|
||||
---
|
||||
|
||||
## Parallel Tracks
|
||||
|
||||
Some tasks can be worked on independently:
|
||||
|
||||
### Design Track
|
||||
|
||||
- Visual design refinements
|
||||
- Icon creation
|
||||
- Brand assets
|
||||
- Marketing materials
|
||||
|
||||
### Documentation Track
|
||||
|
||||
- User guide
|
||||
- Keyboard shortcuts reference
|
||||
- Troubleshooting docs
|
||||
- Video tutorials
|
||||
|
||||
### Infrastructure Track
|
||||
|
||||
- CI/CD pipeline
|
||||
- Automated testing
|
||||
- Release automation
|
||||
- Analytics integration
|
||||
|
||||
---
|
||||
|
||||
## Release Strategy
|
||||
|
||||
### Alpha (After Phase 3)
|
||||
|
||||
- Internal testing only
|
||||
- Frequent bugs expected
|
||||
- Rapid iteration
|
||||
|
||||
### Beta (After Phase 6)
|
||||
|
||||
- Public beta program
|
||||
- Feature complete
|
||||
- Bug fixes and polish
|
||||
|
||||
### v1.0 (After Phase 7)
|
||||
|
||||
- Public release
|
||||
- Stable and reliable
|
||||
- Production-ready
|
||||
|
||||
### v1.x (Phase 8+)
|
||||
|
||||
- Regular feature updates
|
||||
- Community-driven priorities
|
||||
- Plugin ecosystem
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### MVP Success
|
||||
|
||||
- 10 internal users daily
|
||||
- Can complete full coding session
|
||||
- <5 critical bugs
|
||||
|
||||
### Beta Success
|
||||
|
||||
- 100+ external users
|
||||
- NPS >50
|
||||
- <10 bugs per week
|
||||
|
||||
### v1.0 Success
|
||||
|
||||
- 1000+ users
|
||||
- <1% crash rate
|
||||
- Feature requests > bug reports
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Technical Risks
|
||||
|
||||
- **Process management complexity**
|
||||
- Mitigation: Extensive testing, graceful degradation
|
||||
- **SSE connection stability**
|
||||
- Mitigation: Robust reconnection logic, offline mode
|
||||
- **Performance with large sessions**
|
||||
- Mitigation: NOT a concern for MVP - defer to Phase 8
|
||||
- Accept slower performance initially, optimize later based on user feedback
|
||||
|
||||
### Product Risks
|
||||
|
||||
- **Feature creep**
|
||||
- Mitigation: Strict MVP scope, user feedback prioritization
|
||||
- **Over-optimization too early**
|
||||
- Mitigation: Focus on functionality first, optimize in Phase 8
|
||||
- Avoid premature performance optimization
|
||||
- **Platform inconsistencies**
|
||||
- Mitigation: Test on all platforms regularly
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External
|
||||
|
||||
- OpenCode CLI availability
|
||||
- `@opencode-ai/client` contract stability
|
||||
- Electron framework updates
|
||||
|
||||
### Internal
|
||||
|
||||
- Design assets
|
||||
- Documentation
|
||||
- Testing resources
|
||||
|
||||
---
|
||||
|
||||
## Milestone Checklist
|
||||
|
||||
### Pre-Alpha
|
||||
|
||||
- [ ] All Phase 1 tasks complete
|
||||
- [ ] Can create instance and session
|
||||
- [ ] Internal demo successful
|
||||
|
||||
### Alpha
|
||||
|
||||
- [ ] All Phase 2-3 tasks complete
|
||||
- [ ] MVP feature complete
|
||||
- [ ] 5+ internal users testing
|
||||
|
||||
### Beta
|
||||
|
||||
- [ ] All Phase 4-6 tasks complete
|
||||
- [ ] Multi-instance stable
|
||||
- [ ] 50+ external testers
|
||||
|
||||
### v1.0
|
||||
|
||||
- [ ] All Phase 7 tasks complete
|
||||
- [ ] Documentation complete
|
||||
- [ ] <5 known bugs
|
||||
- [ ] Ready for public release
|
||||
|
|
@ -2,23 +2,17 @@
|
|||
|
||||
## OpenCode Dependency
|
||||
|
||||
Server and UI pin `@opencode-ai/client@0.0.0-next-17288`. Import the generated Promise client from `@opencode-ai/client` and service lifecycle APIs from `@opencode-ai/client/service`.
|
||||
Server and UI use the same latest reviewed experimental `@opencode-ai/client` `next` release. Import the generated Promise client from `@opencode-ai/client`. Runtime service discovery does not require an exact CLI version. Every upgrade must review OpenCode release notes, current documentation, installed declarations, and proxy/API parity.
|
||||
|
||||
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.
|
||||
The V2 service database is fixed at `~/.local/share/opencode2/opencode.db`; 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,16 @@ 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 and remain separate from native V2 PTYs. None requires a CodeNomad plugin.
|
||||
|
||||
Native PTYs are location-scoped and listed in the Status panel. `packages/ui/src/stores/pty-store.ts` refreshes the list on native PTY events and reconnect, exposes native metadata, and supports title updates and removal. The proxy verifies PTY `cwd` ownership before ID-scoped operations. Current installed declarations have no PTY output/read/stream API and no separate stop endpoint: output is not displayed, and removing a running PTY is the only native stop action.
|
||||
|
||||
## 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 +55,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.*`; PTY refresh events include `pty.created`, `pty.updated`, `pty.exited`, and `pty.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`.
|
||||
|
||||
## Current Structure
|
||||
|
||||
|
|
@ -79,9 +77,10 @@ packages/ui/src/
|
|||
stores/opencode-client.ts root client authority
|
||||
stores/session-api.ts session queries/lifecycle
|
||||
stores/session-actions.ts prompt, Shell, instructions
|
||||
stores/pty-store.ts location-scoped native PTY state/actions
|
||||
```
|
||||
|
||||
Deleted plugin, background-process, and per-workspace runtime files are not architectural extension points.
|
||||
Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored.
|
||||
|
||||
## Validation
|
||||
|
||||
|
|
|
|||
40
package-lock.json
generated
40
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
|
@ -3303,13 +3303,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/client": {
|
||||
"version": "0.0.0-next-17288",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17288.tgz",
|
||||
"integrity": "sha512-9qD73yHk4zpIafusE5NQ/fMAQhm9TdEmiocy3niuL6VuTaUzy7/18gjqYbUAjmKriCPf3G/ue+l44JC/P8n8rw==",
|
||||
"version": "0.0.0-next-17444",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17444.tgz",
|
||||
"integrity": "sha512-o5nNtHTSqId6eQWJXdMQ132f8anOgm1q6YrB2JgiVGDfhu8EA8TLl2nrP1789CA7Tvkfr855sT/QiZaPynVFcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "0.0.0-next-17288",
|
||||
"@opencode-ai/schema": "0.0.0-next-17288"
|
||||
"@opencode-ai/protocol": "0.0.0-next-17444",
|
||||
"@opencode-ai/schema": "0.0.0-next-17444"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101"
|
||||
|
|
@ -3321,19 +3321,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/protocol": {
|
||||
"version": "0.0.0-next-17288",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17288.tgz",
|
||||
"integrity": "sha512-UlKX6+3ShWAJF6Ctq5yvJQbBvpb48GFeFaGSyZWTkqNdzG9kCbiXpxo6/bMUps+JyS7yQ7tdNcn9vfSNZ0QFEA==",
|
||||
"version": "0.0.0-next-17444",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17444.tgz",
|
||||
"integrity": "sha512-IHCsPp3Tu0BlCqIziANPL04spnAO1NaN91eUeuwYPP1SGF1BhQltilG5jjzcTNssd+I7SbOEcpYs5TIrFKwAjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "0.0.0-next-17288",
|
||||
"@opencode-ai/schema": "0.0.0-next-17444",
|
||||
"effect": "4.0.0-beta.101"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/schema": {
|
||||
"version": "0.0.0-next-17288",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17288.tgz",
|
||||
"integrity": "sha512-c/raCR/Es3UXada+7ZpL/nabjBtSV5QPS11sL3mmfmryMtsrZE4DSnnG5I7ihwwr9ZpR8taAmXbyh0fzq6h3lQ==",
|
||||
"version": "0.0.0-next-17444",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17444.tgz",
|
||||
"integrity": "sha512-HhiKwCFDqOABWbjQgmTQcltRnoRaLxkK6TyXbQpCY1+QnvHu7hxJIwgL0z8YFhYNxyTCTgy85UccEvaBgfwwvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
|
@ -13621,7 +13621,7 @@
|
|||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yaml": "^2.4.2"
|
||||
|
|
@ -13648,13 +13648,13 @@
|
|||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17444",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
@ -13691,7 +13691,7 @@
|
|||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
|
|
@ -13699,12 +13699,12 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17444",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
CROSS_HOST_OWNER_DIRECTORY,
|
||||
resolveCrossHostElectionDirectory,
|
||||
resolveCrossHostStatePath,
|
||||
resolveLegacyCrossHostStatePath,
|
||||
type CrossHostLeaseDependencies,
|
||||
} from "./client-state-cross-host"
|
||||
import type { ProcessOwner } from "./client-state-process"
|
||||
|
|
@ -217,10 +218,13 @@ test("primary crash remains fenced by its non-claiming secondary cohort", async
|
|||
})
|
||||
|
||||
test("platform paths match the Rust contract", () => {
|
||||
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election"))
|
||||
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election"))
|
||||
assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "election"))
|
||||
assert.equal(resolveCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "client-state.json"))
|
||||
assert.equal(resolveCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "client-state.json"))
|
||||
assert.equal(resolveCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "client-state.json"))
|
||||
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "v2", "election"))
|
||||
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "v2", "election"))
|
||||
assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "v2", "election"))
|
||||
assert.equal(resolveCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "v2", "client-state.json"))
|
||||
assert.equal(resolveCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "v2", "client-state.json"))
|
||||
assert.equal(resolveCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "v2", "client-state.json"))
|
||||
assert.equal(resolveLegacyCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "client-state.json"))
|
||||
assert.equal(resolveLegacyCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "client-state.json"))
|
||||
assert.equal(resolveLegacyCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "client-state.json"))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,13 +43,25 @@ export function resolveCrossHostElectionDirectory(
|
|||
const configured = platform === "win32"
|
||||
? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform)
|
||||
: validHome(environment.HOME, platform)
|
||||
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "election")
|
||||
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "v2", "election")
|
||||
}
|
||||
|
||||
export function resolveCrossHostStatePath(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
fallbackHome = homedir(),
|
||||
): string {
|
||||
const pathApi = platform === "win32" ? win32 : posix
|
||||
const configured = platform === "win32"
|
||||
? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform)
|
||||
: validHome(environment.HOME, platform)
|
||||
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "v2", "client-state.json")
|
||||
}
|
||||
|
||||
export function resolveLegacyCrossHostStatePath(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
fallbackHome = homedir(),
|
||||
): string {
|
||||
const pathApi = platform === "win32" ? win32 : posix
|
||||
const configured = platform === "win32"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ interface IPCRegistrar {
|
|||
handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void
|
||||
}
|
||||
|
||||
export function validateClientStateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | null, allowedOrigins: string[]) {
|
||||
function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | null, allowedOrigins: string[]) {
|
||||
if (
|
||||
!mainWindow ||
|
||||
mainWindow.isDestroyed() ||
|
||||
|
|
@ -35,7 +35,7 @@ export function setupClientStateIPC(
|
|||
) {
|
||||
const validate = (event: IpcMainInvokeEvent) => {
|
||||
const window = getMainWindow()
|
||||
validateClientStateSender(event, window, getAllowedOrigins(window))
|
||||
validateSender(event, window, getAllowedOrigins(window))
|
||||
}
|
||||
const handle = (
|
||||
channel: string,
|
||||
|
|
|
|||
|
|
@ -100,8 +100,8 @@ test("first shared primary deterministically migrates legacy host envelopes", as
|
|||
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
|
||||
assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 20, host: "tauri" })
|
||||
assert.equal(manager.getWindowState(), undefined)
|
||||
assert.equal(existsSync(join(electron, "client-state.json")), false)
|
||||
assert.equal(existsSync(join(tauri, "client-state.json")), false)
|
||||
assert.equal(existsSync(join(electron, "client-state.json")), true)
|
||||
assert.equal(existsSync(join(tauri, "client-state.json")), true)
|
||||
await manager.drainAndReleasePrimary()
|
||||
})
|
||||
|
||||
|
|
@ -130,20 +130,41 @@ test("legacy migration does not resurrect a snapshot after clear", async (t) =>
|
|||
await manager.drainAndReleasePrimary()
|
||||
})
|
||||
|
||||
test("legacy cleanup failure cannot abort startup after shared state replacement", async (t) => {
|
||||
test("V1 shared state is copied once and V2 mutations remain isolated", async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
|
||||
const electron = join(root, "electron"), shared = join(root, "shared"), election = join(shared, "election")
|
||||
mkdirSync(electron, { recursive: true })
|
||||
const electron = join(root, "electron"), shared = join(root, "shared"), v2 = join(shared, "v2"), election = join(v2, "election")
|
||||
const legacyShared = join(shared, "client-state.json"), v2State = join(v2, "client-state.json")
|
||||
mkdirSync(electron, { recursive: true }); mkdirSync(shared, { recursive: true })
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }))
|
||||
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 10 } }))
|
||||
const legacyBytes = '{\n "version": 1, "restoreEnabled": true, "snapshot": { "source": "v1" }, "v1Only": true\n}'
|
||||
writeFileSync(legacyShared, legacyBytes)
|
||||
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { source: "host-local" } }))
|
||||
|
||||
const manager = new ClientStateManager(electron, undefined, {
|
||||
crossHostElectionDirectory: election,
|
||||
removeLegacyState: () => { throw new Error("injected cleanup failure") },
|
||||
legacySharedStatePath: legacyShared,
|
||||
})
|
||||
assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 10 })
|
||||
assert.equal(existsSync(join(shared, "client-state.json")), true)
|
||||
assert.equal(readFileSync(v2State, "utf8"), legacyBytes)
|
||||
assert.deepEqual(manager.loadClientState().snapshot, { source: "v1" })
|
||||
await manager.saveClientState({ source: "v2-save" })
|
||||
assert.equal(JSON.parse(readFileSync(v2State, "utf8")).snapshot.source, "v2-save")
|
||||
assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes)
|
||||
assert.equal(await manager.setRestoreEnabled(false), true)
|
||||
assert.equal(JSON.parse(readFileSync(v2State, "utf8")).restoreEnabled, false)
|
||||
assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes)
|
||||
assert.equal(await manager.clearClientState(), true)
|
||||
assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes)
|
||||
assert.equal(JSON.parse(readFileSync(join(electron, "client-state.json"), "utf8")).snapshot.source, "host-local")
|
||||
await manager.drainAndReleasePrimary()
|
||||
|
||||
const restarted = new ClientStateManager(electron, undefined, {
|
||||
crossHostElectionDirectory: election,
|
||||
legacySharedStatePath: legacyShared,
|
||||
})
|
||||
assert.equal(restarted.loadClientState().restoreEnabled, false)
|
||||
assert.notEqual(readFileSync(v2State, "utf8"), legacyBytes)
|
||||
assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes)
|
||||
await restarted.drainAndReleasePrimary()
|
||||
})
|
||||
|
||||
test("ownership loss immediately disables restore reads and mutations", async (t) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
||||
import { open, rename, rm } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import {
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
crossHostParticipants,
|
||||
resolveCrossHostElectionDirectory,
|
||||
resolveCrossHostStatePath,
|
||||
resolveLegacyCrossHostStatePath,
|
||||
resolveLegacyTauriDataDirectory,
|
||||
type CrossHostLeaseDependencies,
|
||||
} from "./client-state-cross-host"
|
||||
|
|
@ -27,6 +28,7 @@ const CLIENT_STATE_VERSION = 1
|
|||
const CLIENT_STATE_FILENAME = "client-state.json"
|
||||
const PRIMARY_LOCK_FILENAME = "client-state.primary.lock"
|
||||
const REGISTRATION_LOCK_FILENAME = "client-state.registration.lock"
|
||||
const CROSS_HOST_PARTICIPANT_GRACE_MS = 50
|
||||
|
||||
export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024
|
||||
|
||||
|
|
@ -65,9 +67,9 @@ export type ClientStateWriter = (
|
|||
interface ClientStateManagerOptions {
|
||||
crossHostElectionDirectory?: string
|
||||
crossHostDependencies?: CrossHostLeaseDependencies
|
||||
legacySharedStatePath?: string | null
|
||||
legacyTauriDataPath?: string | null
|
||||
processOwner?: ProcessOwner
|
||||
removeLegacyState?(path: string): void
|
||||
}
|
||||
|
||||
async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise<void> {
|
||||
|
|
@ -191,13 +193,18 @@ export class ClientStateManager {
|
|||
() => {
|
||||
if (!this.primary || !legacyTauriDataPath) return this.primary
|
||||
try {
|
||||
return !hasLiveTauriClient(
|
||||
const legacyBlocked = () => hasLiveTauriClient(
|
||||
legacyTauriDataPath,
|
||||
options?.crossHostDependencies?.pidAlive,
|
||||
options?.crossHostDependencies?.processStartIdentity,
|
||||
undefined,
|
||||
crossHostParticipants(crossHostElectionDirectory),
|
||||
)
|
||||
if (!legacyBlocked()) return true
|
||||
// A peer may have published its legacy marker just before its
|
||||
// cross-host participant. Reconcile once before yielding ownership.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CROSS_HOST_PARTICIPANT_GRACE_MS)
|
||||
return !legacyBlocked()
|
||||
} catch (error) {
|
||||
console.warn("[client-state] failed to inspect legacy Tauri process markers; continuing as secondary", error)
|
||||
return false
|
||||
|
|
@ -213,11 +220,15 @@ export class ClientStateManager {
|
|||
this.primary = false
|
||||
}
|
||||
if (this.isPrimary) {
|
||||
const legacySharedStatePath = options?.legacySharedStatePath === undefined
|
||||
? (options?.crossHostElectionDirectory ? null : resolveLegacyCrossHostStatePath())
|
||||
: options.legacySharedStatePath
|
||||
this.copyLegacySharedStateIfNeeded(legacySharedStatePath)
|
||||
const legacyPaths = [
|
||||
["electron", join(userDataPath, CLIENT_STATE_FILENAME)],
|
||||
...(legacyTauriDataPath ? [["tauri", join(legacyTauriDataPath, CLIENT_STATE_FILENAME)] as const] : []),
|
||||
] as ReadonlyArray<readonly ["electron" | "tauri", string]>
|
||||
this.migrateLegacyStateIfNeeded(legacyPaths, options?.removeLegacyState)
|
||||
this.migrateLegacyStateIfNeeded(legacyPaths)
|
||||
const futureLegacyBlocked = this.unsupportedFutureEnvelope
|
||||
const persisted = this.readState()
|
||||
this.state = futureLegacyBlocked
|
||||
|
|
@ -379,7 +390,6 @@ export class ClientStateManager {
|
|||
|
||||
private migrateLegacyStateIfNeeded(
|
||||
paths: ReadonlyArray<readonly ["electron" | "tauri", string]>,
|
||||
removeLegacyState = (path: string) => rmSync(path, { force: true }),
|
||||
): void {
|
||||
try {
|
||||
readFileSync(this.statePath)
|
||||
|
|
@ -412,12 +422,42 @@ export class ClientStateManager {
|
|||
descriptor = undefined
|
||||
this.assertReplacementAllowed()
|
||||
renameSync(temporaryPath, this.statePath)
|
||||
for (const [, path] of paths) {
|
||||
try {
|
||||
removeLegacyState(path)
|
||||
} catch (error) {
|
||||
console.warn(`[client-state] failed to remove migrated legacy state at ${path}`, error)
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor)
|
||||
rm(temporaryPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
private copyLegacySharedStateIfNeeded(legacyPath: string | null): void {
|
||||
if (!legacyPath) return
|
||||
try {
|
||||
readFileSync(this.statePath)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) return
|
||||
}
|
||||
|
||||
let bytes: Buffer
|
||||
try {
|
||||
bytes = readFileSync(legacyPath)
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return
|
||||
throw error
|
||||
}
|
||||
|
||||
const temporaryPath = join(dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.shared-migration.tmp`)
|
||||
let descriptor: number | undefined
|
||||
try {
|
||||
descriptor = openSync(temporaryPath, "wx", 0o600)
|
||||
writeFileSync(descriptor, bytes)
|
||||
fsyncSync(descriptor)
|
||||
closeSync(descriptor)
|
||||
descriptor = undefined
|
||||
this.assertReplacementAllowed()
|
||||
try {
|
||||
linkSync(temporaryPath, this.statePath)
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EEXIST")) throw error
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import { requestMicrophoneAccess } from "./permissions"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open"
|
||||
import { setWorkspaceMenuEnabled } from "./menu"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
|
||||
|
|
@ -18,6 +20,37 @@ interface DialogOpenResult {
|
|||
paths: string[]
|
||||
}
|
||||
|
||||
async function resolveLocalWorkspaceFolder(
|
||||
mainWindow: BrowserWindow,
|
||||
cliManager: CliProcessManager,
|
||||
instanceId: string,
|
||||
worktreeSlug: string,
|
||||
): Promise<string> {
|
||||
const baseUrl = cliManager.getStatus().url
|
||||
if (!baseUrl) throw new Error("Local CodeNomad server is unavailable")
|
||||
const cookieName = cliManager.getAuthCookieName()
|
||||
const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0]
|
||||
const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined
|
||||
const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!workspaceResponse.ok) throw new Error("Workspace is not active")
|
||||
const workspace = await workspaceResponse.json() as { path?: unknown }
|
||||
if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable")
|
||||
if (worktreeSlug === "root") return workspace.path
|
||||
|
||||
const worktreeResponse = await fetch(
|
||||
`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`,
|
||||
{ headers, signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable")
|
||||
const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> }
|
||||
const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug)
|
||||
if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable")
|
||||
return worktree.directory
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
|
|
@ -88,6 +121,49 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"workspace:openTarget",
|
||||
async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => {
|
||||
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error("Workspace open requests are limited to the local main window")
|
||||
}
|
||||
const localUrl = cliManager.getStatus().url
|
||||
if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) {
|
||||
throw new Error("Workspace open requests require the local CodeNomad origin")
|
||||
}
|
||||
const target = payload?.target
|
||||
const instanceId = payload?.instanceId
|
||||
const worktreeSlug = payload?.worktreeSlug
|
||||
const editor = payload?.editor
|
||||
if (
|
||||
(target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor")
|
||||
|| typeof instanceId !== "string"
|
||||
|| typeof worktreeSlug !== "string"
|
||||
|| (payload.path !== undefined && typeof payload.path !== "string")
|
||||
|| (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium")
|
||||
) {
|
||||
throw new Error("Invalid workspace open request")
|
||||
}
|
||||
const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug)
|
||||
await openWorkspaceTarget(
|
||||
target as WorkspaceOpenTarget,
|
||||
workspaceFolder,
|
||||
payload.path as string | undefined,
|
||||
editor as WorkspaceEditor | undefined,
|
||||
{ openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) },
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => {
|
||||
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error("Workspace menu updates are limited to the local main window")
|
||||
}
|
||||
setWorkspaceMenuEnabled(enabled === true)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ import { dirname, join } from "path"
|
|||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { ClientStateManager } from "./client-state"
|
||||
import { setupClientStateIPC, validateClientStateSender } from "./client-state-ipc"
|
||||
import { setupClientStateIPC } from "./client-state-ipc"
|
||||
import { ClientStateLifecycle } from "./client-state-lifecycle"
|
||||
import { ClientStateNavigationController } from "./client-state-navigation"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { setupWorktreeFileManagerIPC } from "./worktree-file-manager"
|
||||
import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions"
|
||||
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
|
@ -122,15 +121,6 @@ const bindClientStateWindow = setupClientStateIPC(
|
|||
() => mainWindow,
|
||||
getAllowedRendererOrigins,
|
||||
)
|
||||
setupWorktreeFileManagerIPC(
|
||||
ipcMain,
|
||||
(event, token) => {
|
||||
const window = mainWindow
|
||||
validateClientStateSender(event, window, getAllowedRendererOrigins(window))
|
||||
clientStateManager.assertRendererAccessToken(token)
|
||||
},
|
||||
(value) => shell.openPath(value),
|
||||
)
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,34 @@
|
|||
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
|
||||
interface ApplicationMenuActions {
|
||||
reload(): void
|
||||
forceReload(): void
|
||||
}
|
||||
|
||||
let workspaceActionsRequested = false
|
||||
let applicationMenu: Menu | null = null
|
||||
let localMainWindow: BrowserWindow | null = null
|
||||
|
||||
function updateWorkspaceMenuState() {
|
||||
const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow
|
||||
for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) {
|
||||
const item = applicationMenu?.getMenuItemById(id)
|
||||
if (item) item.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
export function setWorkspaceMenuEnabled(enabled: boolean) {
|
||||
workspaceActionsRequested = enabled
|
||||
updateWorkspaceMenuState()
|
||||
}
|
||||
|
||||
export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) {
|
||||
localMainWindow = mainWindow
|
||||
const isMac = process.platform === "darwin"
|
||||
const sendCommand = (id: string) => () => {
|
||||
if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return
|
||||
mainWindow.webContents.send("menu:action", id)
|
||||
}
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
|
|
@ -31,9 +53,20 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic
|
|||
{
|
||||
label: "New Instance",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => {
|
||||
mainWindow.webContents.send("menu:newInstance")
|
||||
},
|
||||
click: sendCommand("new-instance"),
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
{ id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") },
|
||||
{ id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") },
|
||||
{
|
||||
id: "open-workspace-editor",
|
||||
label: "Open Project In",
|
||||
submenu: [
|
||||
{ label: "VS Code", click: sendCommand("open-workspace-editor-vscode") },
|
||||
{ label: "Cursor", click: sendCommand("open-workspace-editor-cursor") },
|
||||
{ label: "Zed", click: sendCommand("open-workspace-editor-zed") },
|
||||
{ label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") },
|
||||
],
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
isMac ? { role: "close" as const } : { role: "quit" as const },
|
||||
|
|
@ -85,5 +118,14 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic
|
|||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
applicationMenu = menu
|
||||
Menu.setApplicationMenu(menu)
|
||||
updateWorkspaceMenuState()
|
||||
mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
|
||||
if (!isMainFrame) return
|
||||
workspaceActionsRequested = false
|
||||
updateWorkspaceMenuState()
|
||||
})
|
||||
app.on("browser-window-focus", updateWorkspaceMenuState)
|
||||
app.on("browser-window-blur", updateWorkspaceMenuState)
|
||||
}
|
||||
|
|
|
|||
80
packages/electron-app/electron/main/workspace-open.test.ts
Normal file
80
packages/electron-app/electron/main/workspace-open.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import test from "node:test"
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { defaultOpenMode, editorCandidates, openWorkspaceTarget } from "./workspace-open"
|
||||
|
||||
test("selects only the requested editor", () => {
|
||||
assert.deepEqual(editorCandidates("zed", "linux", {}), [{ command: "zed", verifyStart: true }])
|
||||
assert.deepEqual(editorCandidates("vscodium", "darwin", {}), [
|
||||
{ command: "/usr/bin/open", args: ["-a", "VSCodium"], waitForExit: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("opens only paths inside the workspace", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-workspace-"))
|
||||
const outside = mkdtempSync(join(tmpdir(), "codenomad-outside-"))
|
||||
const opened: string[] = []
|
||||
const dependencies = {
|
||||
openPath: async (path: string) => { opened.push(path); return "" },
|
||||
revealPath: () => undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
await openWorkspaceTarget("default", root, ".", undefined, dependencies)
|
||||
assert.deepEqual(opened, [root])
|
||||
await assert.rejects(openWorkspaceTarget("default", root, outside, undefined, dependencies), /outside the workspace/)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("edits Windows scripts and rejects executable default-open targets", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-executable-"))
|
||||
const script = join(root, "run.cmd")
|
||||
writeFileSync(script, "echo unsafe")
|
||||
try {
|
||||
assert.equal(defaultOpenMode(script, "win32"), "edit")
|
||||
const document = join(root, "notes.txt")
|
||||
writeFileSync(document, "safe")
|
||||
assert.equal(defaultOpenMode(document, "win32"), "open")
|
||||
const executable = join(root, "run.exe")
|
||||
writeFileSync(executable, "unsafe")
|
||||
assert.equal(defaultOpenMode(executable, "win32"), "choose")
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("launches terminals in the selected directory without a shell", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "codenomad-terminal-"))
|
||||
const nested = join(root, "nested")
|
||||
mkdirSync(nested)
|
||||
const launches: Array<{ command: string; args: readonly string[]; cwd: string }> = []
|
||||
const spawnProcess = (command: string, args: readonly string[], options: { cwd: string }) => {
|
||||
launches.push({ command, args, cwd: options.cwd })
|
||||
const child = new EventEmitter() as ChildProcess
|
||||
child.unref = () => child
|
||||
queueMicrotask(() => {
|
||||
child.emit("spawn")
|
||||
child.emit("exit", 0)
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
try {
|
||||
await openWorkspaceTarget("terminal", root, "nested", undefined, {
|
||||
openPath: async () => "",
|
||||
revealPath: () => undefined,
|
||||
spawnProcess,
|
||||
})
|
||||
assert.equal(launches[0]?.cwd, nested)
|
||||
assert.equal(launches[0]?.args.includes(nested), false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
238
packages/electron-app/electron/main/workspace-open.ts
Normal file
238
packages/electron-app/electron/main/workspace-open.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { existsSync, realpathSync, statSync } from "node:fs"
|
||||
import { extname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
|
||||
export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor"
|
||||
export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium"
|
||||
|
||||
interface LaunchCandidate {
|
||||
command: string
|
||||
args?: string[]
|
||||
passPath?: boolean
|
||||
waitForExit?: boolean
|
||||
verifyStart?: boolean
|
||||
}
|
||||
|
||||
type SpawnProcess = (command: string, args: readonly string[], options: {
|
||||
cwd: string
|
||||
detached: boolean
|
||||
stdio: "ignore"
|
||||
}) => ChildProcess
|
||||
|
||||
interface WorkspaceOpenDependencies {
|
||||
openPath: (path: string) => Promise<string>
|
||||
revealPath: (path: string) => void
|
||||
spawnProcess?: SpawnProcess
|
||||
}
|
||||
|
||||
const BLOCKED_DEFAULT_OPEN_EXTENSIONS = new Set([
|
||||
".appimage", ".application", ".chm", ".com", ".cpl", ".desktop", ".exe", ".jar", ".lnk", ".msi",
|
||||
".msp", ".pif", ".scr", ".url",
|
||||
])
|
||||
|
||||
const SAFE_WINDOWS_OPEN_EXTENSIONS = new Set([
|
||||
".7z", ".avi", ".bmp", ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".csv", ".dart",
|
||||
".diff", ".docx", ".env", ".flac", ".fs", ".fsx", ".gif", ".go", ".gz", ".h", ".hpp",
|
||||
".htm", ".html", ".ini", ".java", ".jpeg", ".jpg", ".json", ".jsonc", ".jsx", ".kt", ".kts",
|
||||
".less", ".lock", ".log", ".lua", ".md", ".markdown", ".mkv", ".mov", ".mp3", ".mp4", ".ogg",
|
||||
".patch", ".pdf", ".png", ".pptx", ".r", ".rar", ".rmd", ".rs", ".scss", ".sql", ".svg",
|
||||
".svelte", ".swift", ".tar", ".toml", ".ts", ".tsx", ".txt", ".vue", ".wav", ".webm", ".webp",
|
||||
".xlsx", ".xml", ".yaml", ".yml", ".zip",
|
||||
])
|
||||
|
||||
const WINDOWS_EDIT_EXTENSIONS = new Set([
|
||||
".bat", ".cmd", ".js", ".jse", ".pl", ".ps1", ".psd1", ".psm1", ".py", ".pyw", ".rb", ".reg",
|
||||
".vbe", ".vbs", ".wsf", ".wsh",
|
||||
])
|
||||
|
||||
export function defaultOpenMode(path: string, platform: NodeJS.Platform): "open" | "edit" | "choose" {
|
||||
const stats = statSync(path)
|
||||
const extension = extname(path).toLowerCase()
|
||||
if (stats.isDirectory()) {
|
||||
if (platform === "darwin" && extension === ".app") throw new Error("Application bundles cannot be opened externally")
|
||||
return "open"
|
||||
}
|
||||
if (platform === "win32") {
|
||||
if (SAFE_WINDOWS_OPEN_EXTENSIONS.has(extension)) return "open"
|
||||
if (WINDOWS_EDIT_EXTENSIONS.has(extension)) return "edit"
|
||||
return "choose"
|
||||
}
|
||||
if (BLOCKED_DEFAULT_OPEN_EXTENSIONS.has(extension)) {
|
||||
throw new Error("Executable files cannot be opened externally")
|
||||
}
|
||||
if (stats.isFile() && (stats.mode & 0o111) !== 0) {
|
||||
throw new Error("Executable files cannot be opened externally")
|
||||
}
|
||||
return "open"
|
||||
}
|
||||
|
||||
export function editorCandidates(editor: WorkspaceEditor, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): LaunchCandidate[] {
|
||||
if (platform === "darwin") {
|
||||
const names: Record<WorkspaceEditor, string> = {
|
||||
vscode: "Visual Studio Code",
|
||||
cursor: "Cursor",
|
||||
zed: "Zed",
|
||||
vscodium: "VSCodium",
|
||||
}
|
||||
return [{ command: "/usr/bin/open", args: ["-a", names[editor]], waitForExit: true }]
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
const paths: Record<WorkspaceEditor, Array<string | undefined>> = {
|
||||
vscode: [
|
||||
env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"),
|
||||
env.ProgramFiles && join(env.ProgramFiles, "Microsoft VS Code", "Code.exe"),
|
||||
],
|
||||
cursor: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")],
|
||||
zed: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Zed", "Zed.exe")],
|
||||
vscodium: [
|
||||
env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "VSCodium", "VSCodium.exe"),
|
||||
env.ProgramFiles && join(env.ProgramFiles, "VSCodium", "VSCodium.exe"),
|
||||
],
|
||||
}
|
||||
return paths[editor]
|
||||
.filter((command): command is string => Boolean(command))
|
||||
.filter((command) => !isAbsolute(command) || existsSync(command))
|
||||
.map((command) => ({ command }))
|
||||
}
|
||||
|
||||
const commands: Record<WorkspaceEditor, string> = { vscode: "code", cursor: "cursor", zed: "zed", vscodium: "codium" }
|
||||
return [{ command: commands[editor], verifyStart: true }]
|
||||
}
|
||||
|
||||
export function terminalCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, folder = "."): LaunchCandidate[] {
|
||||
if (platform === "darwin") return [{ command: "/usr/bin/open", args: ["-a", "Terminal"], waitForExit: true }]
|
||||
if (platform === "win32") {
|
||||
const systemRoot = env.SystemRoot?.trim() || "C:\\Windows"
|
||||
const command = env.ComSpec?.trim() || join(systemRoot, "System32", "cmd.exe")
|
||||
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
const script = `Set-Location -LiteralPath '${folder.replace(/'/g, "''")}'`
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64")
|
||||
return [{
|
||||
command,
|
||||
args: ["/D", "/C", "start", "", powershell, "-NoExit", "-EncodedCommand", encoded],
|
||||
passPath: false,
|
||||
waitForExit: true,
|
||||
}]
|
||||
}
|
||||
|
||||
const configured = env.TERMINAL?.trim()
|
||||
return [
|
||||
...(configured ? [{ command: configured, passPath: false, verifyStart: true }] : []),
|
||||
...["xdg-terminal-exec", "x-terminal-emulator", "gnome-terminal", "konsole", "kitty", "alacritty", "wezterm"]
|
||||
.map((command) => ({ command, passPath: false, verifyStart: true })),
|
||||
]
|
||||
}
|
||||
|
||||
function launch(candidate: LaunchCandidate, selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let verificationTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (verificationTimer) clearTimeout(verificationTimer)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const args = [...(candidate.args ?? []), ...(candidate.passPath === false ? [] : [selectedPath])]
|
||||
const child = spawnProcess(candidate.command, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
if (!candidate.waitForExit) {
|
||||
if (candidate.verifyStart) {
|
||||
verificationTimer = setTimeout(() => {
|
||||
child.unref()
|
||||
finish()
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
child.unref()
|
||||
finish()
|
||||
}
|
||||
})
|
||||
child.once("exit", (code) => {
|
||||
if (!candidate.waitForExit && !candidate.verifyStart) return
|
||||
if (code === 0) finish()
|
||||
else finish(new Error(`${candidate.command} exited with code ${code ?? "unknown"}`))
|
||||
})
|
||||
child.once("error", (error) => finish(error))
|
||||
})
|
||||
}
|
||||
|
||||
async function launchFirst(candidates: LaunchCandidate[], selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise<void> {
|
||||
let lastError: unknown
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await launch(candidate, selectedPath, cwd, spawnProcess)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("No supported application was found")
|
||||
}
|
||||
|
||||
export async function openWorkspaceTarget(
|
||||
target: WorkspaceOpenTarget,
|
||||
workspaceFolder: string,
|
||||
path = ".",
|
||||
editor?: WorkspaceEditor,
|
||||
dependencies?: WorkspaceOpenDependencies,
|
||||
): Promise<void> {
|
||||
if (!dependencies) throw new Error("Native workspace openers are unavailable")
|
||||
const spawnProcess = dependencies.spawnProcess ?? spawn
|
||||
const root = realpathSync(workspaceFolder)
|
||||
if (!statSync(root).isDirectory()) throw new Error("Workspace folder does not exist")
|
||||
const selectedPath = realpathSync(resolve(root, path))
|
||||
const relativePath = relative(root, selectedPath)
|
||||
if (relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath)) {
|
||||
throw new Error("Selected path is outside the workspace")
|
||||
}
|
||||
const cwd = statSync(selectedPath).isDirectory() ? selectedPath : resolve(selectedPath, "..")
|
||||
|
||||
if (target === "default") {
|
||||
const mode = defaultOpenMode(selectedPath, process.platform)
|
||||
if (mode === "edit" || mode === "choose") {
|
||||
const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows"
|
||||
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
const rundll32 = join(systemRoot, "System32", "rundll32.exe")
|
||||
const script = `$ErrorActionPreference='Stop'; Start-Process -FilePath '${selectedPath.replace(/'/g, "''")}' -Verb Edit`
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64")
|
||||
const candidates: LaunchCandidate[] = [
|
||||
...(mode === "edit" ? [{
|
||||
command: powershell,
|
||||
args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
||||
passPath: false,
|
||||
waitForExit: true,
|
||||
}] : []),
|
||||
{
|
||||
command: rundll32,
|
||||
args: ["shell32.dll,OpenAs_RunDLL"],
|
||||
verifyStart: true,
|
||||
},
|
||||
]
|
||||
await launchFirst(candidates, selectedPath, cwd, spawnProcess)
|
||||
return
|
||||
}
|
||||
const error = await dependencies.openPath(selectedPath)
|
||||
if (error) throw new Error(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (target === "reveal") {
|
||||
dependencies.revealPath(selectedPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (!statSync(selectedPath).isDirectory() && target === "terminal") throw new Error("Terminal target is not a folder")
|
||||
if (target === "editor" && !editor) throw new Error("Editor is required")
|
||||
|
||||
const candidates = target === "terminal"
|
||||
? terminalCandidates(process.platform, process.env, selectedPath)
|
||||
: editorCandidates(editor!, process.platform, process.env)
|
||||
await launchFirst(candidates, selectedPath, cwd, spawnProcess)
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { authorizeOpenWorktree, parseWorktreeInventory, validateRegisteredDirectory } from "./worktree-file-manager"
|
||||
|
||||
test("parses exact NUL-delimited worktree records", () => {
|
||||
assert.deepEqual(
|
||||
parseWorktreeInventory(Buffer.from("worktree C:/repo\0HEAD abc\0branch refs/heads/main\0\0worktree C:/repo/wt\0HEAD def\0detached\0\0")),
|
||||
["C:/repo", "C:/repo/wt"],
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects relative, control, UNC, and device paths before filesystem access", () => {
|
||||
for (const value of ["relative/path", "C:/bad\0path", "C:/bad:name", "C:/repo/../other", "C:/repo/NUL.txt", "C:/repo/trailing.", "\\\\server\\share", "//server/share", "\\\\?\\C:\\repo", "\\\\.\\pipe\\name"]) {
|
||||
assert.throws(() => validateRegisteredDirectory(value, "win32"))
|
||||
}
|
||||
assert.equal(validateRegisteredDirectory("C:/repo", "win32"), "C:/repo")
|
||||
assert.equal(validateRegisteredDirectory("/repo", "linux"), "/repo")
|
||||
assert.throws(() => validateRegisteredDirectory("/dev/null", "linux"))
|
||||
})
|
||||
|
||||
test("revalidates authorization and inventory before opening", async () => {
|
||||
let authorizations = 0
|
||||
let inventories = 0
|
||||
const opened: string[] = []
|
||||
await authorizeOpenWorktree(
|
||||
{ rootDirectory: "C:/repo", registeredDirectory: "C:/repo/wt", targetDirectory: "C:/repo/wt/apps/web" },
|
||||
{
|
||||
authorize: () => { authorizations++ },
|
||||
canonicalize: async (value) => value.replace("C:", "c:"),
|
||||
inventory: async () => { inventories++; return ["C:/repo", "C:/repo/wt"] },
|
||||
openPath: async (value) => { opened.push(value); return "" },
|
||||
isDirectory: async () => true,
|
||||
platform: "win32",
|
||||
},
|
||||
)
|
||||
assert.equal(authorizations, 3)
|
||||
assert.equal(inventories, 2)
|
||||
assert.deepEqual(opened, ["c:/repo/wt/apps/web"])
|
||||
})
|
||||
|
||||
test("does not open when the second inventory no longer contains the target", async () => {
|
||||
let inventories = 0
|
||||
let opened = false
|
||||
await assert.rejects(authorizeOpenWorktree(
|
||||
{ rootDirectory: "/repo", registeredDirectory: "/repo/wt", targetDirectory: "/repo/wt" },
|
||||
{
|
||||
authorize: () => {},
|
||||
canonicalize: async (value) => value,
|
||||
inventory: async () => ++inventories === 1 ? ["/repo", "/repo/wt"] : ["/repo"],
|
||||
openPath: async () => { opened = true; return "" },
|
||||
isDirectory: async () => true,
|
||||
platform: "linux",
|
||||
},
|
||||
), /not registered/)
|
||||
assert.equal(opened, false)
|
||||
})
|
||||
|
||||
test("requires the root itself to be an exact inventory member", async () => {
|
||||
await assert.rejects(authorizeOpenWorktree(
|
||||
{ rootDirectory: "/repo", registeredDirectory: "/repo/wt", targetDirectory: "/repo/wt" },
|
||||
{
|
||||
authorize: () => {},
|
||||
canonicalize: async (value) => value,
|
||||
inventory: async () => ["/repo/wt"],
|
||||
openPath: async () => "",
|
||||
isDirectory: async () => true,
|
||||
platform: "linux",
|
||||
},
|
||||
), /Workspace root is not registered/)
|
||||
})
|
||||
|
||||
test("requires the logical target to be a directory", async () => {
|
||||
await assert.rejects(authorizeOpenWorktree(
|
||||
{ rootDirectory: "/repo", registeredDirectory: "/repo/wt", targetDirectory: "/repo/wt/file.sh" },
|
||||
{
|
||||
authorize: () => {},
|
||||
canonicalize: async (value) => value,
|
||||
inventory: async () => ["/repo", "/repo/wt"],
|
||||
openPath: async () => assert.fail("A file must not be opened"),
|
||||
isDirectory: async () => false,
|
||||
platform: "linux",
|
||||
},
|
||||
), /must be a directory/)
|
||||
})
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import { spawn } from "node:child_process"
|
||||
import { realpath, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { TextDecoder } from "node:util"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
|
||||
const GIT_TIMEOUT_MS = 5_000
|
||||
const MAX_GIT_OUTPUT_BYTES = 1024 * 1024
|
||||
|
||||
export interface WorktreeFileManagerRequest {
|
||||
rootDirectory: string
|
||||
registeredDirectory: string
|
||||
targetDirectory: string
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
authorize(): void
|
||||
canonicalize(value: string): Promise<string>
|
||||
inventory(root: string): Promise<string[]>
|
||||
openPath(value: string): Promise<string>
|
||||
isDirectory(value: string): Promise<boolean>
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
interface IPCRegistrar {
|
||||
handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void
|
||||
}
|
||||
|
||||
export function validateRegisteredDirectory(value: unknown, platform: NodeJS.Platform): string {
|
||||
if (typeof value !== "string" || !value || /[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
||||
throw new Error("Worktree directory must be a valid absolute local path")
|
||||
}
|
||||
if (value.startsWith("\\\\") || value.startsWith("//")) {
|
||||
throw new Error("Network and device paths are not allowed")
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const components = value.slice(3).split(/[\\/]/).filter(Boolean)
|
||||
if (
|
||||
!/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
/[:<>"|?*]/.test(value.slice(2)) ||
|
||||
components.some((component) =>
|
||||
component === "." || component === ".." || /[ .]$/.test(component) ||
|
||||
/^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM(?:[1-9¹²³])|LPT(?:[1-9¹²³]))(?:\..*)?$/i.test(component)
|
||||
)
|
||||
) {
|
||||
throw new Error("Worktree directory must be an absolute drive path")
|
||||
}
|
||||
} else {
|
||||
if (!path.posix.isAbsolute(value)) throw new Error("Worktree directory must be absolute")
|
||||
if (value === "/dev" || value.startsWith("/dev/")) throw new Error("Device paths are not allowed")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseWorktreeInventory(output: Buffer): string[] {
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
const entries: string[] = []
|
||||
for (const field of output.toString("binary").split("\0")) {
|
||||
if (!field) continue
|
||||
const bytes = Buffer.from(field, "binary")
|
||||
const value = decoder.decode(bytes)
|
||||
if (value.startsWith("worktree ")) entries.push(value.slice("worktree ".length))
|
||||
}
|
||||
if (!entries.length) throw new Error("Git returned an empty worktree inventory")
|
||||
return entries
|
||||
}
|
||||
|
||||
function sameCanonicalPath(left: string, right: string, platform: NodeJS.Platform): boolean {
|
||||
return left === right
|
||||
}
|
||||
|
||||
async function verifyInventory(request: WorktreeFileManagerRequest, dependencies: Dependencies): Promise<string> {
|
||||
const rootInput = validateRegisteredDirectory(request.rootDirectory, dependencies.platform)
|
||||
const registeredInput = validateRegisteredDirectory(request.registeredDirectory, dependencies.platform)
|
||||
const targetInput = validateRegisteredDirectory(request.targetDirectory, dependencies.platform)
|
||||
const [root, registered, target] = (await Promise.all([
|
||||
dependencies.canonicalize(rootInput),
|
||||
dependencies.canonicalize(registeredInput),
|
||||
dependencies.canonicalize(targetInput),
|
||||
])).map((entry) => validateRegisteredDirectory(entry, dependencies.platform))
|
||||
const inventory = await dependencies.inventory(root)
|
||||
const canonicalInventory = await Promise.all(inventory.map(async (entry) => {
|
||||
const input = validateRegisteredDirectory(entry, dependencies.platform)
|
||||
return validateRegisteredDirectory(await dependencies.canonicalize(input), dependencies.platform)
|
||||
}))
|
||||
if (!canonicalInventory.some((entry) => sameCanonicalPath(entry, root, dependencies.platform))) {
|
||||
throw new Error("Workspace root is not registered in the Git worktree inventory")
|
||||
}
|
||||
if (!canonicalInventory.some((entry) => sameCanonicalPath(entry, registered, dependencies.platform))) {
|
||||
throw new Error("Worktree is not registered in the Git worktree inventory")
|
||||
}
|
||||
const pathApi = dependencies.platform === "win32" ? path.win32 : path.posix
|
||||
const relativeTarget = pathApi.relative(registered, target)
|
||||
if (relativeTarget === ".." || relativeTarget.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relativeTarget)) {
|
||||
throw new Error("Worktree target is outside the registered directory")
|
||||
}
|
||||
if (!await dependencies.isDirectory(target)) throw new Error("Worktree target must be a directory")
|
||||
return target
|
||||
}
|
||||
|
||||
export async function authorizeOpenWorktree(
|
||||
request: WorktreeFileManagerRequest,
|
||||
dependencies: Dependencies,
|
||||
): Promise<void> {
|
||||
dependencies.authorize()
|
||||
await verifyInventory(request, dependencies)
|
||||
dependencies.authorize()
|
||||
const target = await verifyInventory(request, dependencies)
|
||||
dependencies.authorize()
|
||||
const error = await dependencies.openPath(target)
|
||||
if (error) throw new Error(error)
|
||||
}
|
||||
|
||||
export function readGitWorktreeInventory(root: string): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", ["-C", root, "worktree", "list", "--porcelain", "-z"], {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
let stdoutBytes = 0
|
||||
let stderrBytes = 0
|
||||
let settled = false
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
child.kill()
|
||||
reject(error)
|
||||
}
|
||||
const timer = setTimeout(() => fail(new Error("Git worktree inventory timed out")), GIT_TIMEOUT_MS)
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length
|
||||
if (stdoutBytes > MAX_GIT_OUTPUT_BYTES) fail(new Error("Git worktree inventory output exceeded the limit"))
|
||||
else stdout.push(chunk)
|
||||
})
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBytes += chunk.length
|
||||
if (stderrBytes > MAX_GIT_OUTPUT_BYTES) fail(new Error("Git worktree inventory error output exceeded the limit"))
|
||||
else stderr.push(chunk)
|
||||
})
|
||||
child.once("error", fail)
|
||||
child.once("close", (code) => {
|
||||
clearTimeout(timer)
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (code !== 0) {
|
||||
reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || "Git worktree inventory failed"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(parseWorktreeInventory(Buffer.concat(stdout)))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setupWorktreeFileManagerIPC(
|
||||
ipcMain: IPCRegistrar,
|
||||
authorize: (event: IpcMainInvokeEvent, token: unknown) => void,
|
||||
openPath: (value: string) => Promise<string>,
|
||||
): void {
|
||||
ipcMain.handle("worktree:openInFileManager", async (event, token, request) => {
|
||||
if (!request || typeof request !== "object") throw new Error("Invalid worktree request")
|
||||
const value = request as WorktreeFileManagerRequest
|
||||
await authorizeOpenWorktree(value, {
|
||||
authorize: () => authorize(event, token),
|
||||
canonicalize: realpath,
|
||||
inventory: readGitWorktreeInventory,
|
||||
openPath,
|
||||
isDirectory: async (value) => (await stat(value)).isDirectory(),
|
||||
platform: process.platform,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -26,6 +26,13 @@ const localElectronAPI = {
|
|||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload),
|
||||
setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)),
|
||||
onMenuAction: (callback) => {
|
||||
const handler = (_event, action) => callback(action)
|
||||
ipcRenderer.on("menu:action", handler)
|
||||
return () => ipcRenderer.removeListener("menu:action", handler)
|
||||
},
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
|
|
@ -43,8 +50,6 @@ const localElectronAPI = {
|
|||
setClientStateRestoreEnabled: (token, enabled) =>
|
||||
ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)),
|
||||
clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token),
|
||||
openWorktreeInFileManager: (token, rootDirectory, registeredDirectory, targetDirectory) =>
|
||||
ipcRenderer.invoke("worktree:openInFileManager", token, { rootDirectory, registeredDirectory, targetDirectory }),
|
||||
}
|
||||
|
||||
const remoteElectronAPI = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"prebuild": "npm run prepare:resources",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts electron/main/worktree-file-manager.test.ts",
|
||||
"test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- **OpenCode V2**: Install the latest `opencode2` CLI. Runtime discovery does not require an exact version match.
|
||||
- **OpenCode database**: V2 uses `~/.local/share/opencode2/opencode.db`, separate from the incompatible V1 database.
|
||||
- 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 <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||
| `--ui-dev-server <url>` | `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 <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) |
|
||||
| `--ui-manifest-url <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
|
||||
|
||||
|
|
|
|||
1442
packages/server/package-lock.json
generated
1442
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17444",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
Preferences,
|
||||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
|
|
@ -292,11 +293,7 @@ export interface InstanceData {
|
|||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type InstanceStreamEvent = OpenCodeEvent
|
||||
|
||||
export type SideCarKind = "port"
|
||||
|
||||
|
|
|
|||
|
|
@ -370,7 +370,6 @@ async function main() {
|
|||
binaryResolver,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
nodeExtraCaCertsPath,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({
|
||||
|
|
@ -390,7 +389,7 @@ async function main() {
|
|||
const yoloManager = new AutoAcceptManager({
|
||||
eventBus,
|
||||
logger: yoloLogger,
|
||||
replier: createOpencodePermissionReplier({ workspaceManager, logger: yoloLogger }),
|
||||
replier: createOpencodePermissionReplier({ workspaceManager }),
|
||||
persistence: sessionMetadataPersistence,
|
||||
})
|
||||
yoloManager.start()
|
||||
|
|
@ -487,7 +486,6 @@ async function main() {
|
|||
clientConnectionManager,
|
||||
remoteProxySessionManager,
|
||||
yoloManager,
|
||||
sessionMetadataPersistence,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
|
|
@ -514,7 +512,6 @@ async function main() {
|
|||
clientConnectionManager,
|
||||
remoteProxySessionManager,
|
||||
yoloManager,
|
||||
sessionMetadataPersistence,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { OpenCodeUpdateError, OpenCodeUpdateService, type OpenCodeUpdateServiceDeps } from "./service"
|
||||
import {
|
||||
OpenCodeUpdateError,
|
||||
OpenCodeUpdateService,
|
||||
buildOpenCodeUpgradeCommand,
|
||||
compareOpenCodeVersionStrings,
|
||||
detectOpenCodePackageManager,
|
||||
type OpenCodeUpdateServiceDeps,
|
||||
} from "./service"
|
||||
|
||||
function createDeps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
|
||||
let currentVersion = "1.0.0"
|
||||
return {
|
||||
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
findReadyInstanceId: () => "workspace-1",
|
||||
canUpgradeBinary: () => true,
|
||||
fetchLatestVersion: async () => "1.1.0",
|
||||
upgradeInstance: async (_instanceId, target) => {
|
||||
upgradeBinary: async (_binary, target) => {
|
||||
currentVersion = target
|
||||
return { success: true, version: target }
|
||||
},
|
||||
|
|
@ -36,8 +43,8 @@ test("reports an available update and caches the latest version", async () => {
|
|||
assert.equal(checks, 1)
|
||||
})
|
||||
|
||||
test("keeps the update visible when no matching instance is ready", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
|
||||
test("keeps the update visible for a custom binary", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
|
||||
|
||||
assert.deepEqual(await service.getStatus(), {
|
||||
currentVersion: "1.0.0",
|
||||
|
|
@ -63,26 +70,26 @@ test("preserves the installed version when the registry check fails", async () =
|
|||
})
|
||||
})
|
||||
|
||||
test("upgrades through the matching OpenCode instance to the advertised version", async () => {
|
||||
const calls: Array<{ instanceId: string; target: string }> = []
|
||||
test("upgrades the managed OpenCode binary to the advertised version", async () => {
|
||||
const calls: Array<{ path: string; target: string }> = []
|
||||
let currentVersion = "1.0.0"
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
upgradeInstance: async (instanceId, target) => {
|
||||
calls.push({ instanceId, target })
|
||||
upgradeBinary: async (binary, target) => {
|
||||
calls.push({ path: binary.path, target })
|
||||
currentVersion = target
|
||||
return { success: true, version: target }
|
||||
},
|
||||
}))
|
||||
|
||||
assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" })
|
||||
assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }])
|
||||
assert.deepEqual(calls, [{ path: "opencode", target: "1.1.0" }])
|
||||
})
|
||||
|
||||
test("rejects success when the configured binary was not updated", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: "1.0.0" }),
|
||||
upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }),
|
||||
upgradeBinary: async (_binary, target) => ({ success: true, version: target }),
|
||||
}))
|
||||
|
||||
await assert.rejects(
|
||||
|
|
@ -100,7 +107,7 @@ test("joins concurrent upgrades for the same binary", async () => {
|
|||
})
|
||||
const service = new OpenCodeUpdateService(createDeps({
|
||||
probeBinary: () => ({ valid: true, version: currentVersion }),
|
||||
upgradeInstance: async (_instanceId, target) => {
|
||||
upgradeBinary: async (_binary, target) => {
|
||||
upgrades += 1
|
||||
await gate
|
||||
currentVersion = target
|
||||
|
|
@ -119,11 +126,40 @@ test("joins concurrent upgrades for the same binary", async () => {
|
|||
assert.equal(upgrades, 1)
|
||||
})
|
||||
|
||||
test("rejects an upgrade when no matching OpenCode instance is running", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
|
||||
test("rejects an upgrade for a custom binary", async () => {
|
||||
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
|
||||
|
||||
await assert.rejects(
|
||||
() => service.upgrade(),
|
||||
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance",
|
||||
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "unsupported_binary",
|
||||
)
|
||||
})
|
||||
|
||||
test("builds official V2 package-manager update commands", () => {
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("0.0.0-beta-123", "npm"), {
|
||||
command: "npm",
|
||||
args: ["install", "-g", "@opencode-ai/cli@0.0.0-beta-123"],
|
||||
})
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("0.0.0-beta-123", "pnpm"), {
|
||||
command: "pnpm",
|
||||
args: ["add", "-g", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@0.0.0-beta-123"],
|
||||
})
|
||||
assert.deepEqual(buildOpenCodeUpgradeCommand("0.0.0-beta-123", "bun"), {
|
||||
command: "bun",
|
||||
args: ["install", "-g", "--trust", "@opencode-ai/cli@0.0.0-beta-123"],
|
||||
})
|
||||
})
|
||||
|
||||
test("detects the package manager from the binary path or launch environment", () => {
|
||||
assert.equal(detectOpenCodePackageManager("/home/me/.local/share/pnpm/opencode2", {}), "pnpm")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\.bun\\bin\\opencode2.exe", {}), "bun")
|
||||
assert.equal(detectOpenCodePackageManager("/usr/local/bin/opencode2", { npm_config_user_agent: "yarn/1.22" }), "yarn")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", {}), "npm")
|
||||
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", { npm_config_user_agent: "pnpm/10" }), "npm")
|
||||
assert.equal(detectOpenCodePackageManager("/home/ubuntu/bin/opencode2", {}), "npm")
|
||||
})
|
||||
|
||||
test("compares monotonically numbered V2 beta builds numerically", () => {
|
||||
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true)
|
||||
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-9999", "0.0.0-beta-10000") < 0, true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { spawn } from "node:child_process"
|
||||
import { fetch } from "undici"
|
||||
import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
|
|
@ -6,7 +7,8 @@ import type { WorkspaceManager } from "../workspaces/manager"
|
|||
import { probeBinaryVersion } from "../workspaces/spawn"
|
||||
import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor"
|
||||
|
||||
const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest"
|
||||
const OPENCODE_LATEST_URL = "https://registry.npmjs.org/@opencode-ai%2fcli/beta"
|
||||
const OPENCODE_PACKAGE_NAME = "@opencode-ai/cli"
|
||||
const LATEST_VERSION_CACHE_MS = 5 * 60_000
|
||||
const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>()
|
||||
|
||||
|
|
@ -15,8 +17,8 @@ type UpgradeResult = { success: true; version: string } | { success: false; erro
|
|||
export interface OpenCodeUpdateServiceDeps {
|
||||
resolveBinary: () => ResolvedBinary
|
||||
probeBinary: typeof probeBinaryVersion
|
||||
findReadyInstanceId: (binaryPath: string) => string | undefined
|
||||
upgradeInstance: (instanceId: string, target: string) => Promise<UpgradeResult>
|
||||
canUpgradeBinary: (binary: ResolvedBinary) => boolean
|
||||
upgradeBinary: (binary: ResolvedBinary, target: string) => Promise<UpgradeResult>
|
||||
fetchLatestVersion: () => Promise<string>
|
||||
now?: () => number
|
||||
}
|
||||
|
|
@ -25,7 +27,7 @@ export class OpenCodeUpdateError extends Error {
|
|||
constructor(
|
||||
readonly code:
|
||||
| "binary_unavailable"
|
||||
| "no_ready_instance"
|
||||
| "unsupported_binary"
|
||||
| "update_check_failed"
|
||||
| "upgrade_failed"
|
||||
| "upgrade_verification_failed",
|
||||
|
|
@ -57,14 +59,14 @@ export class OpenCodeUpdateService {
|
|||
checkError: "update_check_failed",
|
||||
}
|
||||
}
|
||||
const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0
|
||||
const readyInstanceId = this.deps.findReadyInstanceId(binary.path)
|
||||
const updateAvailable = compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0
|
||||
const canUpgrade = this.deps.canUpgradeBinary(binary)
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
canUpgrade: updateAvailable && Boolean(readyInstanceId),
|
||||
canUpgrade: updateAvailable && canUpgrade,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,25 +86,24 @@ export class OpenCodeUpdateService {
|
|||
const currentVersion = this.readCurrentVersion(binary.path)
|
||||
const latestVersion = await this.readLatestVersion()
|
||||
|
||||
if (compareVersionStrings(latestVersion, currentVersion) <= 0) {
|
||||
if (compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
|
||||
return { success: true, version: currentVersion }
|
||||
}
|
||||
|
||||
const instanceId = this.deps.findReadyInstanceId(binary.path)
|
||||
if (!instanceId) {
|
||||
if (!this.deps.canUpgradeBinary(binary)) {
|
||||
throw new OpenCodeUpdateError(
|
||||
"no_ready_instance",
|
||||
"No running OpenCode instance uses the configured binary",
|
||||
"unsupported_binary",
|
||||
"Automatic updates are only available for the managed opencode2 command",
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.deps.upgradeInstance(instanceId, latestVersion)
|
||||
const result = await this.deps.upgradeBinary(binary, latestVersion)
|
||||
if (!result.success) {
|
||||
throw new OpenCodeUpdateError("upgrade_failed", result.error)
|
||||
}
|
||||
const installedVersion = this.readCurrentVersion(binary.path)
|
||||
if (compareVersionStrings(installedVersion, latestVersion) !== 0) {
|
||||
if (compareOpenCodeVersionStrings(installedVersion, latestVersion) !== 0) {
|
||||
throw new OpenCodeUpdateError(
|
||||
"upgrade_verification_failed",
|
||||
`OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`,
|
||||
|
|
@ -167,6 +168,76 @@ export async function fetchLatestOpenCodeVersion(): Promise<string> {
|
|||
return payload.version
|
||||
}
|
||||
|
||||
export type OpenCodePackageManager = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
export function compareOpenCodeVersionStrings(left: string, right: string): number {
|
||||
const leftBeta = stripTagPrefix(left)?.match(/^0\.0\.0-beta-(\d+)$/)
|
||||
const rightBeta = stripTagPrefix(right)?.match(/^0\.0\.0-beta-(\d+)$/)
|
||||
if (leftBeta && rightBeta) return Number(leftBeta[1]) - Number(rightBeta[1])
|
||||
return compareVersionStrings(left, right)
|
||||
}
|
||||
|
||||
export function detectOpenCodePackageManager(
|
||||
binaryPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): OpenCodePackageManager {
|
||||
const pathSource = binaryPath.toLowerCase()
|
||||
const launchSource = `${env.npm_config_user_agent ?? ""}\n${env.npm_execpath ?? ""}`.toLowerCase()
|
||||
if (pathSource.includes("pnpm")) return "pnpm"
|
||||
if (/[\\/]\.bun[\\/]/.test(pathSource)) return "bun"
|
||||
if (pathSource.includes("yarn")) return "yarn"
|
||||
if (/[\\/]npm[\\/]/.test(pathSource)) return "npm"
|
||||
if (launchSource.includes("pnpm")) return "pnpm"
|
||||
if (/(^|[\s/])bun(?:$|[\s/])/.test(launchSource)) return "bun"
|
||||
if (launchSource.includes("yarn")) return "yarn"
|
||||
return "npm"
|
||||
}
|
||||
|
||||
export function buildOpenCodeUpgradeCommand(
|
||||
version: string,
|
||||
packageManager: OpenCodePackageManager,
|
||||
): { command: string; args: string[] } {
|
||||
const packageSpec = `${OPENCODE_PACKAGE_NAME}@${version}`
|
||||
if (packageManager === "pnpm") {
|
||||
return { command: "pnpm", args: ["add", "-g", `--allow-build=${OPENCODE_PACKAGE_NAME}`, packageSpec] }
|
||||
}
|
||||
if (packageManager === "bun") {
|
||||
return { command: "bun", args: ["install", "-g", "--trust", packageSpec] }
|
||||
}
|
||||
if (packageManager === "yarn") {
|
||||
return { command: "yarn", args: ["global", "add", packageSpec] }
|
||||
}
|
||||
return { command: "npm", args: ["install", "-g", packageSpec] }
|
||||
}
|
||||
|
||||
export function installOpenCodeCli(
|
||||
binary: ResolvedBinary,
|
||||
version: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<UpgradeResult> {
|
||||
const upgrade = buildOpenCodeUpgradeCommand(version, detectOpenCodePackageManager(binary.path, env))
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(upgrade.command, upgrade.args, {
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
child.once("error", (error) => resolve({ success: false, error: error.message }))
|
||||
child.once("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolve({ success: false, error: `OpenCode update stopped by signal ${signal}` })
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
resolve({ success: false, error: `OpenCode update exited with code ${code ?? "unknown"}` })
|
||||
return
|
||||
}
|
||||
resolve({ success: true, version })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function createOpenCodeUpdateService(
|
||||
settings: SettingsService,
|
||||
workspaceManager: WorkspaceManager,
|
||||
|
|
@ -178,9 +249,8 @@ export function createOpenCodeUpdateService(
|
|||
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) }
|
||||
},
|
||||
probeBinary: probeBinaryVersion,
|
||||
// The native V2 client has no self-upgrade operation.
|
||||
findReadyInstanceId: () => undefined,
|
||||
canUpgradeBinary: () => binaryResolver.resolveDefault().path === "opencode2",
|
||||
fetchLatestVersion: fetchLatestOpenCodeVersion,
|
||||
upgradeInstance: async () => ({ success: false, error: "OpenCode V2 does not expose self-upgrade" }),
|
||||
upgradeBinary: installOpenCodeCli,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,70 +21,74 @@ const noopLogger: Logger = {
|
|||
} as unknown as Logger
|
||||
|
||||
function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record<string, unknown>) {
|
||||
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
|
||||
const { properties, ...nativeEvent } = event
|
||||
const wrapped = properties as { info?: Record<string, unknown> } | undefined
|
||||
const data = event.data ?? (wrapped?.info
|
||||
? { ...wrapped.info, sessionID: wrapped.info.sessionID ?? wrapped.info.id }
|
||||
: properties)
|
||||
bus.publish({ type: "instance.event", instanceId, event: { ...nativeEvent, type, data } as InstanceStreamEvent })
|
||||
}
|
||||
|
||||
/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */
|
||||
/** Publish session lifecycle events using the native V2 data envelope. */
|
||||
function publishSession(
|
||||
bus: EventBus,
|
||||
instanceId: string,
|
||||
eventType: "session.updated" | "session.created" | "session.deleted",
|
||||
info: Record<string, unknown>,
|
||||
) {
|
||||
publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } })
|
||||
publishInstanceEvent(bus, instanceId, {
|
||||
type: eventType === "session.updated" ? "session.created" : eventType,
|
||||
data: { ...info, sessionID: info.sessionID ?? info.id },
|
||||
})
|
||||
}
|
||||
|
||||
describe("AutoAcceptManager session tree", () => {
|
||||
it("ingests session.updated to build the parent chain", () => {
|
||||
it("does not apply Yolo policy to a session unknown to that logical workspace", 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: "master", parentID: null })
|
||||
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
manager.toggle("inst", "child")
|
||||
assert.equal(manager.isEnabled("inst", "child"), true)
|
||||
assert.equal(manager.isEnabled("inst", "master"), true)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("treats a session with revert as a fork root", () => {
|
||||
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" },
|
||||
manager.toggle("wrong-owner", "session")
|
||||
publishInstanceEvent(bus, "wrong-owner", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "permission", sessionID: "session" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
manager.toggle("inst", "fork")
|
||||
assert.equal(manager.isEnabled("inst", "fork"), true)
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
|
||||
publishSession(bus, "wrong-owner", "session.updated", { id: "session", parentID: null })
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 1)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("session.deleted removes the tree entry but keeps the toggle", () => {
|
||||
it("does not auto-reply when API hydration rejects cross-workspace ownership", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
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")
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
manager.toggle("inst", "master")
|
||||
publishSession(bus, "inst", "session.deleted", { id: "master" })
|
||||
|
||||
// toggle is independent of the tree (survives deletion)
|
||||
assert.equal(manager.isEnabled("inst", "master"), true)
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "foreign-permission", sessionID: "foreign-session" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager persistence", () => {
|
||||
|
|
@ -129,12 +133,13 @@ describe("AutoAcceptManager persistence", () => {
|
|||
const writes: unknown[][] = []
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist(...args) { writes.push(args); await gate },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
const toggle = manager.toggle("inst", "root")
|
||||
await flushMicrotasks()
|
||||
assert.deepEqual(writes, [["inst", "root", true, undefined]])
|
||||
assert.deepEqual(writes, [["inst", "root", true]])
|
||||
assert.equal(manager.isEnabled("inst", "root"), false)
|
||||
assert.equal(changes.length, 0)
|
||||
release()
|
||||
|
|
@ -148,6 +153,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
const writes: boolean[] = []
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled) },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
|
|
@ -181,6 +187,20 @@ describe("AutoAcceptManager persistence", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("rejects a persisted toggle when the native session belongs to another logical workspace", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
let writes = 0
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "foreign", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return null },
|
||||
async persist() { writes += 1 },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
await assert.rejects(Promise.resolve(manager.toggle("inst", "foreign")), /does not belong to workspace/)
|
||||
assert.equal(writes, 0)
|
||||
assert.equal(manager.isEnabled("inst", "foreign"), false)
|
||||
})
|
||||
|
||||
it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const changes: Record<string, unknown>[] = []
|
||||
|
|
@ -190,6 +210,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
let writes = 0
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist() { writes += 1; await gate },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
|
|
@ -211,8 +232,8 @@ describe("AutoAcceptManager persistence", () => {
|
|||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() {
|
||||
return [
|
||||
{ id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
|
||||
{ id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
|
||||
{ id: "parent", parentId: null, yoloEnabled: false },
|
||||
{ id: "child", parentId: null, yoloEnabled: true },
|
||||
]
|
||||
},
|
||||
async persist(...args) { writes.push(args) },
|
||||
|
|
@ -224,8 +245,8 @@ describe("AutoAcceptManager persistence", () => {
|
|||
await flushMicrotasks()
|
||||
assert.equal(manager.isEnabled("inst", "parent"), true)
|
||||
assert.deepEqual(writes, [
|
||||
["inst", "parent", true, "workspace"],
|
||||
["inst", "child", false, "workspace"],
|
||||
["inst", "parent", true],
|
||||
["inst", "child", false],
|
||||
])
|
||||
manager.stop()
|
||||
})
|
||||
|
|
@ -240,11 +261,12 @@ describe("AutoAcceptManager persistence", () => {
|
|||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() {
|
||||
return [
|
||||
{ id: "grandparent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
|
||||
{ id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
|
||||
{ id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
|
||||
{ id: "grandparent", parentId: null, yoloEnabled: false },
|
||||
{ id: "parent", parentId: null, yoloEnabled: false },
|
||||
{ id: "child", parentId: null, yoloEnabled: true },
|
||||
]
|
||||
},
|
||||
async loadSession() { return { id: "child", parentId: null, yoloEnabled: true } },
|
||||
async persist(...args) {
|
||||
writes.push(args)
|
||||
if (writes.length === 1) await firstGate
|
||||
|
|
@ -268,21 +290,6 @@ describe("AutoAcceptManager persistence", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("allows a queued toggle to continue after an earlier persistence failure", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
let attempts = 0
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async persist() { if (++attempts === 1) throw new Error("write failed") },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
const first = manager.toggle("inst", "root")
|
||||
const second = manager.toggle("inst", "root")
|
||||
await assert.rejects(Promise.resolve(first), /write failed/)
|
||||
assert.equal(await second, true)
|
||||
assert.equal(manager.isEnabled("inst", "root"), true)
|
||||
})
|
||||
|
||||
it("does not restore a late hydration after workspace cleanup", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
let release!: () => void
|
||||
|
|
@ -301,109 +308,17 @@ describe("AutoAcceptManager persistence", () => {
|
|||
})
|
||||
|
||||
describe("AutoAcceptManager permission interception", () => {
|
||||
it("auto-replies to a v2 permission on an enabled family", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const accepted: Record<string, unknown>[] = []
|
||||
bus.on("yolo.autoAccepted", (e) => accepted.push(e))
|
||||
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" })
|
||||
manager.toggle("inst", "child") // enable the whole family root
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] },
|
||||
})
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
const call = replier.calls[0]
|
||||
assert.equal(call.instanceId, "inst")
|
||||
assert.equal(call.permissionId, "perm-1")
|
||||
assert.equal(call.sessionId, "child")
|
||||
assert.equal(call.source, "v2")
|
||||
assert.equal(call.reply, "once")
|
||||
assert.equal(accepted.length, 1)
|
||||
assert.equal((accepted[0] as any).permissionId, "perm-1")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("auto-replies to a legacy permission.asked event", async () => {
|
||||
it("replies once when duplicate logical workspaces receive the same native permission", 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: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm-2", sessionID: "solo", type: "bash" },
|
||||
})
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].source, "legacy")
|
||||
assert.equal(replier.calls[0].permissionId, "perm-2")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("does not reply when the family is disabled", 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: "solo", parentID: null })
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-3", sessionID: "solo" },
|
||||
})
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("ignores permission events without an id or sessionID", 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: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } })
|
||||
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } })
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("deduplicates repeated emission of the same permission", 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: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-dup", sessionID: "solo" },
|
||||
for (const instanceId of ["first", "second"]) {
|
||||
publishSession(bus, instanceId, "session.updated", { id: "session", parentID: null })
|
||||
manager.toggle(instanceId, "session")
|
||||
publishInstanceEvent(bus, instanceId, {
|
||||
type: "permission.asked",
|
||||
properties: { id: "permission", sessionID: "session" },
|
||||
})
|
||||
}
|
||||
await flushMicrotasks()
|
||||
|
|
@ -412,127 +327,9 @@ describe("AutoAcceptManager permission interception", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("clears in-flight tracking after the reply resolves so it can retry", 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: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-retry", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-retry", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 2)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager state events", () => {
|
||||
it("publishes yolo.stateChanged with the new enabled value on toggle", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const changes: Record<string, unknown>[] = []
|
||||
bus.on("yolo.stateChanged", (e) => changes.push(e))
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
|
||||
manager.toggle("inst", "master")
|
||||
manager.toggle("inst", "master")
|
||||
|
||||
assert.equal(changes.length, 2)
|
||||
assert.equal((changes[0] as any).enabled, true)
|
||||
assert.equal((changes[1] as any).enabled, false)
|
||||
assert.equal((changes[0] as any).sessionId, "master")
|
||||
assert.equal((changes[0] as any).instanceId, "inst")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager lifecycle", () => {
|
||||
it("clearInstance drops tree and enabled state for the instance", () => {
|
||||
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 })
|
||||
manager.toggle("inst", "master")
|
||||
manager.clearInstance("inst")
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("clears state when the workspace stops", () => {
|
||||
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 })
|
||||
manager.toggle("inst", "master")
|
||||
bus.publish({ type: "workspace.stopped", workspaceId: "inst" })
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("stop() unsubscribes so no further events are processed", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
manager.stop()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "p", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager pending permissions drain", () => {
|
||||
it("drains a pending permission that arrived before enable", 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: "solo", parentID: null })
|
||||
|
||||
// permission arrives while yolo is OFF
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-pending", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
// enabling yolo should drain the pending permission
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].permissionId, "perm-pending")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("drains pending permissions for the same family only", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
|
|
@ -562,32 +359,6 @@ describe("AutoAcceptManager pending permissions drain", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("does not re-drain already-auto-accepted permissions", 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: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-1", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 1)
|
||||
|
||||
// toggling off then on should not re-drain the already-replied permission
|
||||
manager.toggle("inst", "solo") // off
|
||||
manager.toggle("inst", "solo") // on — drain runs but pending set is empty
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("re-drains pending when late session ancestry joins an enabled family", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
|
|
@ -652,108 +423,6 @@ describe("AutoAcceptManager permission replied cleanup", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("removes a pending permission on legacy permission.replied", 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: "solo", parentID: null })
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm-y", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.replied",
|
||||
properties: { requestID: "perm-y" },
|
||||
})
|
||||
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager clearInstance clears pending", () => {
|
||||
it("drops pending permissions on clearInstance", 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: "solo", parentID: null })
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-z", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
manager.clearInstance("inst")
|
||||
|
||||
// re-create session and enable — pending set should be empty
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager permission.updated source inference", () => {
|
||||
it("preserves the original v2 source when permission.updated arrives", 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: "solo", parentID: null })
|
||||
// yolo is OFF — permission goes to pending with source "v2"
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-v2", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
// enable yolo, then send permission.updated — should keep source "v2"
|
||||
manager.toggle("inst", "solo")
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.updated",
|
||||
properties: { id: "perm-v2", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].source, "v2")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("skips permission.updated for a permission not in pending", 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: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
// permission.updated for a permission that was never asked (not in pending)
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.updated",
|
||||
properties: { id: "perm-unknown", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager replier failure handling", () => {
|
||||
|
|
@ -823,23 +492,6 @@ describe("AutoAcceptManager replier failure handling", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager workspace.error cleanup", () => {
|
||||
it("clears state when the workspace errors", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
assert.equal(manager.isEnabled("inst", "solo"), true)
|
||||
|
||||
bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any })
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "solo"), false)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager session.deleted clears pending", () => {
|
||||
it("removes pending permissions for a deleted session", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
|
|
|
|||
|
|
@ -16,15 +16,10 @@ import { AutoAcceptStore, type AutoAcceptSessionInfo } from "./auto-accept-store
|
|||
* so the UI stays a pure view
|
||||
*/
|
||||
|
||||
export type PermissionSource = "v2" | "legacy"
|
||||
export type PermissionReplyValue = "once"
|
||||
|
||||
export interface AutoAcceptReply {
|
||||
instanceId: string
|
||||
permissionId: string
|
||||
sessionId: string
|
||||
source: PermissionSource
|
||||
reply: PermissionReplyValue
|
||||
}
|
||||
|
||||
export type PermissionReplier = (reply: AutoAcceptReply) => Promise<void>
|
||||
|
|
@ -32,7 +27,6 @@ export type PermissionReplier = (reply: AutoAcceptReply) => Promise<void>
|
|||
interface PendingPermission {
|
||||
permissionId: string
|
||||
sessionId: string
|
||||
source: PermissionSource
|
||||
}
|
||||
|
||||
interface AutoAcceptManagerDeps {
|
||||
|
|
@ -44,33 +38,32 @@ interface AutoAcceptManagerDeps {
|
|||
|
||||
export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo {
|
||||
yoloEnabled: boolean
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export interface AutoAcceptPersistence {
|
||||
loadSessions(instanceId: string): Promise<PersistedAutoAcceptSession[]>
|
||||
persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise<void>
|
||||
loadSession?(instanceId: string, sessionId: string): Promise<PersistedAutoAcceptSession | null>
|
||||
persist(instanceId: string, rootSessionId: string, enabled: boolean): Promise<void>
|
||||
}
|
||||
|
||||
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"])
|
||||
|
||||
export class AutoAcceptManager {
|
||||
private static readonly MAX_REPLY_ATTEMPTS = 3
|
||||
private readonly store = new AutoAcceptStore()
|
||||
/** instanceId:permissionId entries currently being replied, to dedupe re-emissions */
|
||||
/** Native permission ids currently being replied, including duplicate logical workspace emissions. */
|
||||
private readonly inFlight = new Set<string>()
|
||||
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
|
||||
private readonly pending = new Map<string, Map<string, PendingPermission>>()
|
||||
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
|
||||
/** Native permission id -> failure count, to stop retrying stuck permissions. */
|
||||
private readonly replyAttempts = new Map<string, number>()
|
||||
private readonly hydratedInstances = new Set<string>()
|
||||
private readonly hydration = new Map<string, Promise<void>>()
|
||||
private readonly queuedEvents = new Map<string, InstanceStreamPayload[]>()
|
||||
private readonly instanceGeneration = new Map<string, number>()
|
||||
private readonly sessionWorkspaces = new Map<string, Map<string, string>>()
|
||||
private readonly mutations = new Map<string, Promise<boolean>>()
|
||||
private unsubscribe?: () => void
|
||||
|
||||
|
|
@ -134,12 +127,9 @@ export class AutoAcceptManager {
|
|||
const pending = this.deps.persistence.loadSessions(instanceId).then((sessions) => {
|
||||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return
|
||||
this.store.clearInstance(instanceId)
|
||||
const workspaces = new Map<string, string>()
|
||||
for (const session of sessions) {
|
||||
this.store.upsertSession(instanceId, session)
|
||||
if (session.workspaceId) workspaces.set(session.id, session.workspaceId)
|
||||
}
|
||||
this.sessionWorkspaces.set(instanceId, workspaces)
|
||||
for (const session of sessions) {
|
||||
if (!session.yoloEnabled || this.store.familyRoot(instanceId, session.id) !== session.id) continue
|
||||
this.store.setEnabled(instanceId, session.id, true)
|
||||
|
|
@ -177,6 +167,12 @@ export class AutoAcceptManager {
|
|||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
|
||||
return this.store.isEnabled(instanceId, sessionId)
|
||||
}
|
||||
const session = await this.deps.persistence!.loadSession?.(instanceId, sessionId)
|
||||
if (!session) throw new Error(`Session ${sessionId} does not belong to workspace ${instanceId}`)
|
||||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
|
||||
return this.store.isEnabled(instanceId, sessionId)
|
||||
}
|
||||
this.store.upsertSession(instanceId, session)
|
||||
const rootSessionId = this.store.familyRoot(instanceId, sessionId)
|
||||
const traversedRootSessionIds = new Set([rootSessionId])
|
||||
const enabled = !this.store.isEnabled(instanceId, rootSessionId)
|
||||
|
|
@ -184,7 +180,6 @@ export class AutoAcceptManager {
|
|||
instanceId,
|
||||
rootSessionId,
|
||||
enabled,
|
||||
this.sessionWorkspaces.get(instanceId)?.get(rootSessionId),
|
||||
)
|
||||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
|
||||
return this.store.isEnabled(instanceId, rootSessionId)
|
||||
|
|
@ -197,14 +192,12 @@ export class AutoAcceptManager {
|
|||
instanceId,
|
||||
currentRootSessionId,
|
||||
enabled,
|
||||
this.sessionWorkspaces.get(instanceId)?.get(currentRootSessionId),
|
||||
)
|
||||
if (enabled) {
|
||||
await this.deps.persistence!.persist(
|
||||
instanceId,
|
||||
persistedRootSessionId,
|
||||
false,
|
||||
this.sessionWorkspaces.get(instanceId)?.get(persistedRootSessionId),
|
||||
)
|
||||
}
|
||||
persistedRootSessionId = currentRootSessionId
|
||||
|
|
@ -236,29 +229,25 @@ export class AutoAcceptManager {
|
|||
this.hydratedInstances.delete(instanceId)
|
||||
this.hydration.delete(instanceId)
|
||||
this.queuedEvents.delete(instanceId)
|
||||
this.sessionWorkspaces.delete(instanceId)
|
||||
this.mutations.delete(instanceId)
|
||||
this.store.clearInstance(instanceId)
|
||||
this.pending.delete(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(this.inFlight.keys())) {
|
||||
if (key.startsWith(prefix)) this.inFlight.delete(key)
|
||||
}
|
||||
for (const key of Array.from(this.replyAttempts.keys())) {
|
||||
if (key.startsWith(prefix)) this.replyAttempts.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void {
|
||||
if (!event || typeof event.type !== "string") return
|
||||
|
||||
if (SESSION_UPSERT_TYPES.has(event.type)) {
|
||||
this.ingestSession(instanceId, event.properties)
|
||||
this.ingestSession(instanceId, event.data)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.forked") {
|
||||
this.ingestSessionForked(instanceId, event.data)
|
||||
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)
|
||||
const data = event.data as SessionProperties | undefined
|
||||
const id = readString(data?.sessionID) ?? readString(data?.id)
|
||||
if (id) {
|
||||
this.store.removeSession(instanceId, id)
|
||||
this.removePendingForSession(instanceId, id)
|
||||
|
|
@ -266,38 +255,42 @@ export class AutoAcceptManager {
|
|||
return
|
||||
}
|
||||
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
|
||||
this.handlePermissionReplied(instanceId, event.properties)
|
||||
this.handlePermissionReplied(instanceId, event.data)
|
||||
return
|
||||
}
|
||||
if (PERMISSION_ASK_TYPES.has(event.type)) {
|
||||
this.handlePermissionRequest(instanceId, event.type, event.properties)
|
||||
this.handlePermissionRequest(instanceId, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
private ingestSession(instanceId: string, properties: unknown): void {
|
||||
// OpenCode wraps session records under `properties.info` for
|
||||
// session.created/updated/deleted (see SDK EventSessionUpdated). Accept a
|
||||
// flat fallback only for defensive compatibility.
|
||||
const info = (properties as { info?: SessionProperties } | SessionProperties | undefined)
|
||||
const session = (info && typeof info === "object" && "info" in info ? info.info : info) as
|
||||
| SessionProperties
|
||||
| undefined
|
||||
if (!session || typeof session.id !== "string") return
|
||||
private ingestSession(instanceId: string, data: unknown): void {
|
||||
const session = data as SessionProperties | undefined
|
||||
const sessionId = readString(session?.sessionID) ?? readString(session?.id)
|
||||
if (!session || !sessionId) 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 })
|
||||
if (typeof session.workspaceID === "string" && session.workspaceID) {
|
||||
const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map<string, string>()
|
||||
workspaces.set(session.id, session.workspaceID)
|
||||
this.sessionWorkspaces.set(instanceId, workspaces)
|
||||
}
|
||||
this.store.upsertSession(instanceId, { id: sessionId, parentId, fork: session.fork })
|
||||
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).
|
||||
this.drainPending(instanceId, session.id)
|
||||
// ForInstance trigger from the previous UI implementation (#497).
|
||||
this.drainPending(instanceId, sessionId)
|
||||
}
|
||||
|
||||
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.upsertSession(instanceId, {
|
||||
id: sessionId,
|
||||
parentId,
|
||||
fork: { sessionID: parentId, boundary: value.boundary },
|
||||
})
|
||||
this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId))
|
||||
this.drainPending(instanceId, sessionId)
|
||||
}
|
||||
|
||||
private persistRootMigration(instanceId: string, before: readonly string[], after: readonly string[]): void {
|
||||
|
|
@ -312,14 +305,14 @@ export class AutoAcceptManager {
|
|||
for (const rootSessionId of added) {
|
||||
if (!enabledRoots.has(rootSessionId)) continue
|
||||
await this.deps.persistence!.persist(
|
||||
instanceId, rootSessionId, true, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId),
|
||||
instanceId, rootSessionId, true,
|
||||
)
|
||||
}
|
||||
for (const rootSessionId of removed) {
|
||||
if (enabledRoots.has(rootSessionId)) continue
|
||||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false
|
||||
await this.deps.persistence!.persist(
|
||||
instanceId, rootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId),
|
||||
instanceId, rootSessionId, false,
|
||||
)
|
||||
}
|
||||
return false
|
||||
|
|
@ -333,29 +326,36 @@ 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")
|
||||
this.addPending(instanceId, { permissionId, sessionId })
|
||||
|
||||
// `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)) {
|
||||
if (!this.store.hasSession(instanceId, sessionId)) {
|
||||
void this.hydrateSession(instanceId, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
this.addPending(instanceId, { permissionId, sessionId, source })
|
||||
|
||||
if (!this.store.isEnabled(instanceId, sessionId)) return
|
||||
this.tryAutoAccept(instanceId, permissionId, sessionId, source)
|
||||
this.tryAutoAccept(instanceId, permissionId, sessionId)
|
||||
}
|
||||
|
||||
private async hydrateSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
private handlePermissionReplied(instanceId: string, properties: unknown): void {
|
||||
|
|
@ -373,27 +373,26 @@ export class AutoAcceptManager {
|
|||
instanceId: string,
|
||||
permissionId: string,
|
||||
sessionId: string,
|
||||
source: PermissionSource,
|
||||
): void {
|
||||
const key = `${instanceId}:${permissionId}`
|
||||
const key = permissionId
|
||||
if (this.inFlight.has(key)) return
|
||||
const attempts = this.replyAttempts.get(key) ?? 0
|
||||
if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return
|
||||
this.inFlight.add(key)
|
||||
this.replyAttempts.set(key, attempts + 1)
|
||||
|
||||
const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" }
|
||||
const reply: AutoAcceptReply = { instanceId, permissionId, sessionId }
|
||||
|
||||
void this.deps.replier(reply)
|
||||
.then(() => {
|
||||
this.replyAttempts.delete(key)
|
||||
this.removePending(instanceId, permissionId)
|
||||
this.removePendingFromAllInstances(permissionId)
|
||||
this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed")
|
||||
if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) {
|
||||
this.removePending(instanceId, permissionId)
|
||||
this.removePendingFromAllInstances(permissionId)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -407,8 +406,8 @@ export class AutoAcceptManager {
|
|||
if (!instancePending || instancePending.size === 0) return
|
||||
const root = this.store.familyRoot(instanceId, sessionId)
|
||||
for (const entry of Array.from(instancePending.values())) {
|
||||
if (this.store.familyRoot(instanceId, entry.sessionId) === root) {
|
||||
this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source)
|
||||
if (this.store.hasSession(instanceId, entry.sessionId) && this.store.familyRoot(instanceId, entry.sessionId) === root) {
|
||||
this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -425,18 +424,22 @@ export class AutoAcceptManager {
|
|||
private removePending(instanceId: string, permissionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (instancePending?.delete(permissionId)) {
|
||||
this.replyAttempts.delete(`${instanceId}:${permissionId}`)
|
||||
this.replyAttempts.delete(permissionId)
|
||||
if (instancePending.size === 0) this.pending.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private removePendingFromAllInstances(permissionId: string): void {
|
||||
for (const instanceId of Array.from(this.pending.keys())) this.removePending(instanceId, permissionId)
|
||||
}
|
||||
|
||||
private removePendingForSession(instanceId: string, sessionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (!instancePending) return
|
||||
for (const [permId, entry] of Array.from(instancePending)) {
|
||||
if (entry.sessionId === sessionId) {
|
||||
instancePending.delete(permId)
|
||||
this.replyAttempts.delete(`${instanceId}:${permId}`)
|
||||
this.replyAttempts.delete(permId)
|
||||
}
|
||||
}
|
||||
if (instancePending.size === 0) this.pending.delete(instanceId)
|
||||
|
|
@ -445,15 +448,15 @@ export class AutoAcceptManager {
|
|||
|
||||
interface InstanceStreamPayload {
|
||||
type?: string
|
||||
properties?: Record<string, unknown>
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
interface SessionProperties {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
parentID?: string | null
|
||||
parentId?: string | null
|
||||
revert?: unknown
|
||||
workspaceID?: string
|
||||
fork?: unknown
|
||||
}
|
||||
|
||||
interface PermissionProperties {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ import { describe, it } from "node:test"
|
|||
import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store"
|
||||
|
||||
describe("resolveFamilyRoot", () => {
|
||||
it("returns the session id itself when no info is known", () => {
|
||||
assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan")
|
||||
})
|
||||
|
||||
it("keeps a loaded child as root when its parent is missing", () => {
|
||||
const root = resolveFamilyRoot("child", (id) =>
|
||||
id === "child" ? { id: "child", parentId: "parent" } : undefined,
|
||||
|
|
@ -15,20 +11,10 @@ describe("resolveFamilyRoot", () => {
|
|||
assert.equal(root, "child")
|
||||
})
|
||||
|
||||
it("resolves to the master session when the full parent chain is loaded", () => {
|
||||
const root = resolveFamilyRoot("grandchild", (id) => {
|
||||
if (id === "grandchild") return { id: "grandchild", parentId: "child" }
|
||||
if (id === "child") return { id: "child", parentId: "master" }
|
||||
if (id === "master") return { id: "master", parentId: null }
|
||||
return undefined
|
||||
})
|
||||
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
|
||||
})
|
||||
|
|
@ -47,11 +33,6 @@ describe("resolveFamilyRoot", () => {
|
|||
})
|
||||
|
||||
describe("AutoAcceptStore inheritance", () => {
|
||||
it("is disabled by default for an unknown session", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
assert.equal(store.isEnabled("inst", "s1"), false)
|
||||
})
|
||||
|
||||
it("enabling a parent enables every descendant that resolves to it", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
|
|
@ -65,26 +46,13 @@ describe("AutoAcceptStore inheritance", () => {
|
|||
assert.equal(store.isEnabled("inst", "grandchild"), true)
|
||||
})
|
||||
|
||||
it("enabling a child also covers the parent family root and siblings", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child-a", parentId: "master" })
|
||||
store.upsertSession("inst", { id: "child-b", parentId: "master" })
|
||||
|
||||
store.setEnabled("inst", "child-a", true)
|
||||
|
||||
assert.equal(store.isEnabled("inst", "child-a"), true)
|
||||
assert.equal(store.isEnabled("inst", "child-b"), true)
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
})
|
||||
|
||||
it("a fork session is isolated: enabling it does not enable its parent", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
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)
|
||||
|
|
@ -93,30 +61,6 @@ describe("AutoAcceptStore inheritance", () => {
|
|||
assert.equal(store.isEnabled("inst", "master"), false)
|
||||
})
|
||||
|
||||
it("disabling the family root clears the setting for all descendants", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child", parentId: "master" })
|
||||
|
||||
store.setEnabled("inst", "child", true)
|
||||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
|
||||
store.setEnabled("inst", "master", false)
|
||||
assert.equal(store.isEnabled("inst", "child"), false)
|
||||
assert.equal(store.isEnabled("inst", "master"), false)
|
||||
})
|
||||
|
||||
it("toggle flips the resolved family-root state and reports the new value", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child", parentId: "master" })
|
||||
|
||||
assert.equal(store.toggle("inst", "child"), true)
|
||||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
assert.equal(store.toggle("inst", "master"), false)
|
||||
assert.equal(store.isEnabled("inst", "child"), false)
|
||||
})
|
||||
|
||||
it("keeps per-instance state independent", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst-a", { id: "root", parentId: null })
|
||||
|
|
@ -141,15 +85,6 @@ describe("AutoAcceptStore session tree maintenance", () => {
|
|||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
})
|
||||
|
||||
it("removing a session does not clear an enabled family root", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.setEnabled("inst", "master", true)
|
||||
store.removeSession("inst", "master")
|
||||
// the toggle is independent of the session tree: it survives session deletion
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
})
|
||||
|
||||
it("clearInstance drops both tree and enabled state", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
|
|
@ -160,7 +95,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 +103,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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -97,6 +96,10 @@ export class AutoAcceptStore {
|
|||
this.sessions.get(instanceId)?.delete(sessionId)
|
||||
}
|
||||
|
||||
hasSession(instanceId: string, sessionId: string): boolean {
|
||||
return this.sessions.get(instanceId)?.has(sessionId) ?? false
|
||||
}
|
||||
|
||||
clearInstance(instanceId: string): void {
|
||||
this.sessions.delete(instanceId)
|
||||
this.enabled.delete(instanceId)
|
||||
|
|
@ -114,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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,30 +2,28 @@ import assert from "node:assert/strict"
|
|||
import { describe, it } from "node:test"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
import type { Logger } from "../logger"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import { createOpencodePermissionReplier } from "./opencode-replier"
|
||||
|
||||
describe("createOpencodePermissionReplier", () => {
|
||||
it("uses the native permission reply input", async () => {
|
||||
it("does not reply across logical workspace ownership", async () => {
|
||||
const calls: Array<Record<string, unknown>> = []
|
||||
const client = {
|
||||
session: { get: async () => ({ location: { directory: "/other" } }) },
|
||||
permission: { reply: async (input: Record<string, unknown>) => { 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 })
|
||||
const replier = createOpencodePermissionReplier({ workspaceManager })
|
||||
|
||||
await replier({
|
||||
await assert.rejects(replier({
|
||||
instanceId: "instance",
|
||||
sessionId: "session",
|
||||
sessionId: "foreign-session",
|
||||
permissionId: "permission",
|
||||
source: "legacy",
|
||||
reply: "once",
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [{ sessionID: "session", requestID: "permission", reply: "once" }])
|
||||
}), /does not belong/)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import { createInstanceClient } from "../workspaces/instance-client"
|
||||
import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager"
|
||||
|
||||
interface OpencodeReplierDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,10 +17,15 @@ 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,
|
||||
reply: reply.reply,
|
||||
reply: "once",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}
|
||||
const settings = {
|
||||
getOwner: () => owner,
|
||||
|
|
@ -18,21 +18,48 @@ function createHarness() {
|
|||
return owner
|
||||
},
|
||||
} as unknown as SettingsService
|
||||
const workspaceManager = { get: () => ({ path: "/repo" }) } as unknown as WorkspaceManager
|
||||
const listInputs: Record<string, unknown>[] = []
|
||||
const workspaceManager = {
|
||||
get: () => ({ path: "/repo" }),
|
||||
getServiceDirectory: () => serviceDirectory,
|
||||
ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo" || directory === "/worktree",
|
||||
} as unknown as WorkspaceManager
|
||||
const client = {
|
||||
session: {
|
||||
async list(input: Record<string, unknown>) {
|
||||
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,
|
||||
fork: sessionID === "worktree" ? {
|
||||
sessionID: "root",
|
||||
boundary: { type: "through", messageID: "message" },
|
||||
} : undefined,
|
||||
location: {
|
||||
directory: sessionID === "foreign" ? "/other" : sessionID === "worktree" ? "/worktree" : "/repo",
|
||||
workspaceID: "workspace",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -42,23 +69,51 @@ 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,
|
||||
workspaceId: "workspace",
|
||||
fork: undefined,
|
||||
yoloEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "second-page",
|
||||
parentId: null,
|
||||
fork: undefined,
|
||||
yoloEnabled: false,
|
||||
},
|
||||
])
|
||||
assert.deepEqual(listInputs, [
|
||||
{ directory: "/repo", limit: 10_000, cursor: undefined },
|
||||
{ directory: "/repo", limit: 10_000, cursor: "page-2" },
|
||||
])
|
||||
})
|
||||
|
||||
it("loads an exact session only when its native location belongs to the logical workspace", async () => {
|
||||
const { persistence } = createHarness()
|
||||
assert.equal((await persistence.loadSession!("instance", "root"))?.id, "root")
|
||||
assert.equal(await persistence.loadSession!("instance", "foreign"), null)
|
||||
})
|
||||
|
||||
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" } },
|
||||
yoloEnabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import { createInstanceClient } from "../workspaces/instance-client"
|
||||
|
|
@ -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,11 +43,24 @@ 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,
|
||||
fork: session.fork,
|
||||
yoloEnabled: sessionState(settings, session.id).yoloEnabled === true,
|
||||
})
|
||||
const updateYolo = (
|
||||
sessionId: string,
|
||||
enabled: boolean,
|
||||
|
|
@ -60,13 +78,30 @@ export function createOpencodeYoloPersistence(
|
|||
|
||||
return {
|
||||
async loadSessions(instanceId): Promise<PersistedAutoAcceptSession[]> {
|
||||
return (await listSessions(instanceId)).map((session) => ({
|
||||
id: session.id,
|
||||
parentId: session.parentID ?? null,
|
||||
revert: session.revert,
|
||||
workspaceId: session.location.workspaceID,
|
||||
yoloEnabled: sessionState(settings, session.id).yoloEnabled === true,
|
||||
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<PersistedAutoAcceptSession | null> {
|
||||
try {
|
||||
const session = await (await clientFor(instanceId)).session.get({ sessionID: sessionId })
|
||||
if (!(await workspaceManager.ownsDirectory(instanceId, session.location.directory))) return null
|
||||
return persistedSession(session)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
persist(_instanceId, rootSessionId, enabled): Promise<void> {
|
||||
return updateYolo(rootSessionId, enabled)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,15 @@ function logger(): Logger {
|
|||
return value as unknown as Logger
|
||||
}
|
||||
|
||||
async function harness(sessionDirectory = "/repo/worktree") {
|
||||
async function harness(
|
||||
sessionDirectory = "/repo/worktree",
|
||||
activeSessions: Record<string, { type: "running" }> = {},
|
||||
sessionLocations: Record<string, string | Error> = {},
|
||||
workspacePath = "/repo",
|
||||
serviceDirectory = workspacePath,
|
||||
pathMappings: Record<string, string> = {},
|
||||
ptyDirectories: Record<string, string | Error> = {},
|
||||
) {
|
||||
const upstream = Fastify()
|
||||
apps.push(upstream)
|
||||
let requests = 0
|
||||
|
|
@ -27,57 +35,78 @@ async function harness(sessionDirectory = "/repo/worktree") {
|
|||
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)
|
||||
return { id: sessionID, location: { directory: sessionDirectory } } as SessionInfo
|
||||
const location = sessionLocations[sessionID] ?? sessionDirectory
|
||||
if (location instanceof Error) throw location
|
||||
return { id: sessionID, location: { directory: location } } as SessionInfo
|
||||
},
|
||||
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) => {
|
||||
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", () => {
|
||||
it("preserves an owned worktree for session list and create", async () => {
|
||||
const { app } = await harness()
|
||||
const listed = await app.inject({
|
||||
method: "GET",
|
||||
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 created = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session",
|
||||
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" })
|
||||
})
|
||||
|
||||
it("defaults session list and create to the workspace root", async () => {
|
||||
const { app } = await harness()
|
||||
const listed = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session" })
|
||||
assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Frepo")
|
||||
|
||||
const created = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session", payload: { title: "test" } })
|
||||
assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo" })
|
||||
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("rejects arbitrary locations instead of overwriting them", async () => {
|
||||
|
|
@ -97,15 +126,28 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.doesNotMatch(bodyResponse.body, /internal-secret/)
|
||||
})
|
||||
|
||||
it("accepts owned and rejects unowned native shell and pty cwd values", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
for (const route of ["shell", "pty"]) {
|
||||
const accepted = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}`, payload: { cwd: "/repo/worktree" } })
|
||||
const rejected = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}`, payload: { cwd: "/other" } })
|
||||
assert.equal(accepted.statusCode, 200)
|
||||
assert.equal(rejected.statusCode, 403)
|
||||
}
|
||||
assert.equal(requestCount(), 2)
|
||||
it("filters PTYs and rejects foreign PTY access", 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)
|
||||
|
||||
assert.equal((await app.inject({
|
||||
method: "GET",
|
||||
url: "/workspaces/workspace/instance/api/pty/foreign?location%5Bdirectory%5D=%2Frepo%2Fworktree",
|
||||
})).statusCode, 403)
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("strips browser session and hop-by-hop headers in both directions", async () => {
|
||||
|
|
@ -118,6 +160,9 @@ describe("instance proxy location enforcement", () => {
|
|||
connection: "keep-alive, x-remove-me",
|
||||
cookie: "codenomad_session=browser-secret; other=value",
|
||||
"x-forwarded-for": "203.0.113.1",
|
||||
"x-opencode-directory": "/other",
|
||||
"x-opencode-workspace": "foreign-workspace",
|
||||
"x-opencode-routing-test": "foreign-route",
|
||||
"x-remove-me": "secret",
|
||||
},
|
||||
})
|
||||
|
|
@ -126,18 +171,13 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.equal(headers.cookie, undefined)
|
||||
assert.doesNotMatch(headers.connection ?? "", /x-remove-me/i)
|
||||
assert.equal(headers["x-forwarded-for"], undefined)
|
||||
assert.equal(headers["x-opencode-directory"], undefined)
|
||||
assert.equal(headers["x-opencode-workspace"], undefined)
|
||||
assert.equal(headers["x-opencode-routing-test"], undefined)
|
||||
assert.equal(headers["x-remove-me"], undefined)
|
||||
assert.equal(response.headers["set-cookie"], undefined)
|
||||
})
|
||||
|
||||
it("authorizes location-less session routes through the shared client", async () => {
|
||||
const { app, sessionGets, requestCount } = await harness()
|
||||
const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/session-1/message" })
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(sessionGets, ["session-1"])
|
||||
assert.equal(requestCount(), 1)
|
||||
})
|
||||
|
||||
it("rejects sessions owned by another workspace", async () => {
|
||||
const { app, requestCount } = await harness("/other")
|
||||
const response = await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/session/session-2" })
|
||||
|
|
@ -145,6 +185,172 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.equal(requestCount(), 0)
|
||||
assert.doesNotMatch(response.body, /internal-secret/)
|
||||
})
|
||||
|
||||
it("rejects deletion through a double-encoded alias of a foreign session", async () => {
|
||||
const { app, sessionGets, requestCount } = await harness("/repo/worktree", {}, {
|
||||
"foreign%25session": "/other",
|
||||
})
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/workspaces/workspace/instance/api/session/foreign%2525session",
|
||||
})
|
||||
assert.equal(response.statusCode, 403)
|
||||
assert.deepEqual(sessionGets, ["foreign%25session"])
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("filters active sessions to the workspace without failing on stale ids", async () => {
|
||||
const active = { owned: { type: "running" as const }, foreign: { type: "running" as const }, stale: { type: "running" as const } }
|
||||
const { app, sessionGets, requestCount } = await harness("/repo/worktree", active, {
|
||||
foreign: "/other",
|
||||
stale: new Error("missing"),
|
||||
})
|
||||
const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/active" })
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(JSON.parse(response.body), { owned: { type: "running" } })
|
||||
assert.deepEqual(sessionGets.sort(), ["foreign", "owned", "stale"])
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("blocks global routes through a workspace", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
for (const route of ["global/dispose", "global/config", "global/upgrade"]) {
|
||||
const response = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/${route}` })
|
||||
assert.equal(response.statusCode, 403)
|
||||
}
|
||||
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)
|
||||
}
|
||||
assert.equal((await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/service/stop",
|
||||
payload: { instanceID: "instance-1" },
|
||||
})).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/permission/saved" })).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/permission/saved/global-rule" })).statusCode, 403)
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
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("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",
|
||||
payload: { text: "read this", files: [{ uri: "file:///%ZZ" }] },
|
||||
})
|
||||
assert.equal(malformed.statusCode, 400)
|
||||
|
||||
const foreign = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/session-1/prompt",
|
||||
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/notes.txt" },
|
||||
{ uri: "file:///repo/worktree/notes.txt" },
|
||||
{ uri: "file:///C:/repo/notes.txt" },
|
||||
] },
|
||||
})
|
||||
assert.equal(owned.statusCode, 200)
|
||||
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)
|
||||
})
|
||||
|
||||
it("defaults and validates only schema-defined imported session locations", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
const accepted = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/import",
|
||||
payload: {
|
||||
info: { id: "session-1", metadata: { location: { directory: "/other" } } },
|
||||
messages: [{
|
||||
type: "location-switched",
|
||||
location: { directory: "/repo/worktree" },
|
||||
previous: { location: null },
|
||||
metadata: { location: { directory: "/other" } },
|
||||
content: [{ type: "tool", state: { input: { location: "/other" } } }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
assert.equal(accepted.statusCode, 200)
|
||||
const body = JSON.parse(accepted.body).body
|
||||
assert.deepEqual(body.location, { directory: "/repo" })
|
||||
assert.deepEqual(body.info.location, { directory: "/repo" })
|
||||
assert.deepEqual(body.messages[0].previous.location, { directory: "/repo" })
|
||||
assert.deepEqual(body.messages[0].metadata.location, { directory: "/other" })
|
||||
assert.equal(body.messages[0].content[0].state.input.location, "/other")
|
||||
|
||||
const rejected = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/import",
|
||||
payload: {
|
||||
info: { id: "session-2", location: { directory: "/repo" } },
|
||||
messages: [{ type: "location-switched", location: { directory: "/repo/worktree" }, previous: { location: { directory: "/other" } } }],
|
||||
},
|
||||
})
|
||||
assert.equal(rejected.statusCode, 403)
|
||||
assert.equal(requestCount(), 1)
|
||||
})
|
||||
|
||||
it("never sends workspace credentials to an encoded or backslash foreign origin", async () => {
|
||||
const foreign = Fastify()
|
||||
apps.push(foreign)
|
||||
const credentials: unknown[] = []
|
||||
foreign.all("/*", async (request) => credentials.push(request.headers.authorization))
|
||||
await foreign.listen({ host: "127.0.0.1", port: 0 })
|
||||
const address = foreign.server.address()
|
||||
assert.ok(address && typeof address === "object")
|
||||
|
||||
const { app, requestCount } = await harness()
|
||||
for (const prefix of ["%2F%2F", "%5C%5C"]) {
|
||||
const proxyResponse: Awaited<ReturnType<typeof app.inject>> = await app.inject({
|
||||
method: "GET",
|
||||
url: `/workspaces/workspace/instance/${prefix}127.0.0.1:${address.port}/steal`,
|
||||
})
|
||||
assert.equal(proxyResponse.statusCode, 400)
|
||||
assert.doesNotMatch(proxyResponse.body, /internal-secret/)
|
||||
}
|
||||
assert.equal(requestCount(), 0)
|
||||
assert.deepEqual(credentials, [])
|
||||
})
|
||||
})
|
||||
|
||||
it("redacts secret-bearing fields recursively", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -33,7 +33,6 @@ import { registerUsageRoutes } from "./routes/usage"
|
|||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import type { AutoAcceptManager } from "../permissions/auto-accept-manager"
|
||||
import type { OpencodeYoloPersistence } from "../permissions/opencode-yolo-metadata"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
|
|
@ -64,7 +63,6 @@ interface HttpServerDeps {
|
|||
clientConnectionManager: ClientConnectionManager
|
||||
remoteProxySessionManager: RemoteProxySessionManager
|
||||
yoloManager: AutoAcceptManager
|
||||
sessionMetadataPersistence: OpencodeYoloPersistence
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
|
|
@ -365,8 +363,12 @@ export interface InstanceProxyWorkspaceManager {
|
|||
get(id: string): ReturnType<WorkspaceManager["get"]>
|
||||
getSharedServiceEndpoint(id: string): ReturnType<WorkspaceManager["getSharedServiceEndpoint"]>
|
||||
getInstanceAuthorizationHeader(id: string): string | undefined
|
||||
getServiceDirectory?(id: string): string | undefined
|
||||
getServiceDirectoryForPath?(id: string, directory: string): Promise<string | undefined>
|
||||
getServicePathForPath?(id: string, candidate: string): Promise<string | undefined>
|
||||
getSharedServiceClient(): Promise<OpenCodeClient>
|
||||
ownsDirectory(id: string, directory: string): Promise<boolean>
|
||||
ownsPath(id: string, candidate: string): Promise<boolean>
|
||||
}
|
||||
|
||||
interface InstanceProxyDeps {
|
||||
|
|
@ -563,23 +565,134 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const targetUrl = appendIncomingQuery(new URL(normalizedSuffix, endpoint.url), request.raw.url ?? "")
|
||||
const requestLocations = readRequestDirectories(targetUrl, request.body)
|
||||
readNativeCwd(targetUrl, request.body, requestLocations)
|
||||
const rawInstancePath = (request.raw.url ?? "").split("?", 1)[0]?.match(/\/instance(?:\/(.*))?$/)?.[1] ?? ""
|
||||
if (/\\|%2f|%5c/i.test(rawInstancePath) || hasDotSegment(rawInstancePath)) {
|
||||
reply.code(400).send({ error: "Invalid workspace instance path" })
|
||||
return
|
||||
}
|
||||
const targetUrl = buildInstanceTargetUrl(endpoint.url, args.pathSuffix)
|
||||
if (!targetUrl) {
|
||||
reply.code(400).send({ error: "Invalid workspace instance path" })
|
||||
return
|
||||
}
|
||||
appendIncomingQuery(targetUrl, request.raw.url ?? "")
|
||||
const pathname = decodeURIComponent(targetUrl.pathname)
|
||||
if (!isAllowedInstanceApiRoute(request.method, pathname)) {
|
||||
reply.code(403).send({ error: "OpenCode route is not available through a workspace" })
|
||||
return
|
||||
}
|
||||
if (pathname.replace(/\/+$/, "") === "/api/session/active") {
|
||||
if (request.method !== "GET") {
|
||||
reply.code(405).send({ error: "Method not allowed" })
|
||||
return
|
||||
}
|
||||
const client = await workspaceManager.getSharedServiceClient()
|
||||
const active = await client.session.active()
|
||||
const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => {
|
||||
try {
|
||||
const session = await client.session.get({ sessionID: sessionId })
|
||||
return await workspaceManager.ownsDirectory(workspaceId, session.location.directory) ? [sessionId, status] as const : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
reply.send(Object.fromEntries(entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null)))
|
||||
return
|
||||
}
|
||||
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<typeof project> => 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
|
||||
readNativeCwd(targetUrl, imported.body, requestLocations)
|
||||
const promptFiles = readPromptFilePaths(pathname, request.method, imported.body)
|
||||
if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))) {
|
||||
reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translatedDirectories = new Map<string, string>()
|
||||
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<string, string>()
|
||||
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 sessionId = getSessionRouteId(targetUrl.pathname)
|
||||
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" })
|
||||
|
|
@ -587,7 +700,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
}
|
||||
|
||||
const body = applyDefaultWorkspaceLocation(targetUrl, request.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")
|
||||
|
|
@ -685,7 +798,8 @@ function sanitizeInstanceProxyRequestHeaders(
|
|||
|
||||
const result: Record<string, string | string[] | undefined> = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!blocked.has(key.toLowerCase())) result[key] = value
|
||||
const normalized = key.toLowerCase()
|
||||
if (!blocked.has(normalized) && !normalized.startsWith("x-opencode-")) result[key] = value
|
||||
}
|
||||
if (authorization) result.authorization = authorization
|
||||
return result
|
||||
|
|
@ -711,6 +825,10 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works
|
|||
return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) {
|
||||
return (await Promise.all(paths.map((candidate) => manager.ownsPath(workspaceId, candidate)))).every(Boolean)
|
||||
}
|
||||
|
||||
function applyDefaultWorkspaceLocation(
|
||||
targetUrl: URL,
|
||||
body: unknown,
|
||||
|
|
@ -735,13 +853,263 @@ function applyDefaultWorkspaceLocation(
|
|||
}
|
||||
|
||||
function getSessionRouteId(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/api\/session\/([^/]+)(?:\/|$)/)
|
||||
const match = pathname.match(/^\/api\/(?:experimental\/)?session\/([^/]+)(?:\/|$)/)
|
||||
if (!match || match[1] === "active" || match[1] === "import") return null
|
||||
try {
|
||||
return decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return 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) || hasDotSegment(suffix)) return null
|
||||
const targetUrl = new URL(endpoint)
|
||||
const origin = targetUrl.origin
|
||||
targetUrl.pathname = normalizeInstanceSuffix(suffix).split("/").map(encodeURIComponent).join("/")
|
||||
targetUrl.search = ""
|
||||
targetUrl.hash = ""
|
||||
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|plugin|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)$/],
|
||||
["GET", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/],
|
||||
["DELETE", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/],
|
||||
["POST", /^\/api\/integration\/[^/]+\/connect\/oauth\/[^/]+\/complete$/],
|
||||
["GET", /^\/api\/session(?:\/active)?$/],
|
||||
["POST", /^\/api\/session(?:\/import)?$/],
|
||||
["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/],
|
||||
["DELETE", /^\/api\/session\/[^/]+$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|fork)$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/revert\/stage$/],
|
||||
["PUT", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/],
|
||||
["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<string, string>,
|
||||
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<string, unknown>
|
||||
return typeof location.directory === "string" && replacements.has(location.directory)
|
||||
? { ...location, directory: replacements.get(location.directory) }
|
||||
: value
|
||||
}
|
||||
const input = { ...(body as Record<string, unknown>) }
|
||||
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<string, unknown>), location: replaceLocation((input.info as Record<string, unknown>).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<string, unknown>
|
||||
if (message.type !== "location-switched") return value
|
||||
const next: Record<string, unknown> = { ...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<string, unknown>),
|
||||
location: replaceLocation((message.previous as Record<string, unknown>).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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return result
|
||||
const files = (body as Record<string, unknown>).files
|
||||
if (files === undefined) return result
|
||||
if (!Array.isArray(files)) return { paths: [], invalid: true }
|
||||
|
||||
for (const file of files) {
|
||||
if (!file || typeof file !== "object" || Array.isArray(file) || Buffer.isBuffer(file)) {
|
||||
result.invalid = true
|
||||
continue
|
||||
}
|
||||
const uri = (file as Record<string, unknown>).uri
|
||||
if (typeof uri !== "string" || !uri.trim()) {
|
||||
result.invalid = true
|
||||
continue
|
||||
}
|
||||
const parsed = parsePromptFileUri(uri)
|
||||
if (parsed.invalid) result.invalid = true
|
||||
else if (parsed.path) result.paths.push(parsed.path)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function parsePromptFileUri(value: string): { path?: string; invalid: boolean } {
|
||||
if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return { path: value, invalid: value.includes("\0") }
|
||||
let uri: URL
|
||||
try {
|
||||
uri = new URL(value)
|
||||
} catch {
|
||||
return { invalid: true }
|
||||
}
|
||||
if (["data:", "http:", "https:"].includes(uri.protocol)) return { invalid: false }
|
||||
if (uri.protocol !== "file:" || (uri.hostname && uri.hostname !== "localhost") || uri.search || uri.hash || /%2f|%5c/i.test(uri.pathname)) {
|
||||
return { invalid: true }
|
||||
}
|
||||
try {
|
||||
const decoded = decodeURIComponent(uri.pathname)
|
||||
const localPath = /^\/[A-Za-z]:\//.test(decoded) ? decoded.slice(1) : decoded
|
||||
return { path: localPath, invalid: !localPath || localPath.includes("\0") }
|
||||
} catch {
|
||||
return { invalid: true }
|
||||
}
|
||||
}
|
||||
|
||||
function replacePromptFileUris(body: unknown, replacements: ReadonlyMap<string, string>): unknown {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body
|
||||
const input = body as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) {
|
||||
result.invalid = true
|
||||
return result
|
||||
}
|
||||
|
||||
const input = body as Record<string, unknown>
|
||||
const addLocation = (owner: Record<string, unknown>, key: string) => {
|
||||
const value = owner[key]
|
||||
if (value === null || value === undefined) {
|
||||
owner[key] = { directory }
|
||||
result.directories.push(directory)
|
||||
return
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) {
|
||||
result.invalid = true
|
||||
return
|
||||
}
|
||||
const location = value as Record<string, unknown>
|
||||
if (location.directory === null || location.directory === undefined) location.directory = directory
|
||||
if (typeof location.directory === "string" && location.directory.trim()) result.directories.push(location.directory)
|
||||
else result.invalid = true
|
||||
}
|
||||
|
||||
addLocation(input, "location")
|
||||
if (input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info)) {
|
||||
addLocation(input.info as Record<string, unknown>, "location")
|
||||
}
|
||||
|
||||
if (Array.isArray(input.messages)) {
|
||||
for (const value of input.messages) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) continue
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type !== "location-switched") continue
|
||||
addLocation(message, "location")
|
||||
if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) {
|
||||
addLocation(message.previous as Record<string, unknown>, "location")
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
|
|
|
|||
117
packages/server/src/server/routes/events.test.ts
Normal file
117
packages/server/src/server/routes/events.test.ts
Normal file
|
|
@ -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<typeof reply.raw.write>) => {
|
||||
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<void> {
|
||||
const deadline = Date.now() + 1_000
|
||||
while (!check()) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for SSE route")
|
||||
await new Promise<void>((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<void>((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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof setInterval> | undefined
|
||||
let drainTimeout: ReturnType<typeof setTimeout> | 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) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface RouteDeps {
|
|||
}
|
||||
|
||||
function statusCode(error: OpenCodeUpdateError): number {
|
||||
if (error.code === "no_ready_instance") return 409
|
||||
if (error.code === "unsupported_binary") return 409
|
||||
if (error.code === "binary_unavailable") return 422
|
||||
return 502
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("workspace routes", () => {
|
|||
payload: { requestId: "restore-request" },
|
||||
})
|
||||
assert.equal(cancelled.statusCode, 204)
|
||||
assert.deepEqual(calls.at(-1), ["cancel", "restore-request"])
|
||||
assert.deepEqual(calls[calls.length - 1], ["cancel", "restore-request"])
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,59 +3,59 @@ import { execFileSync } from "node:child_process"
|
|||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { it } from "node:test"
|
||||
import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client"
|
||||
import Fastify from "fastify"
|
||||
import type { WorkspaceDescriptor } from "../../api-types"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import { registerWorktreeRoutes } from "./worktrees"
|
||||
|
||||
describe("worktree routes", () => {
|
||||
it("resolves a session move target from the exact Git slug and ignores client paths", async () => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-"))
|
||||
const repo = path.join(temp, "repo")
|
||||
const linked = path.join(temp, "feature-worktree")
|
||||
const app = Fastify({ logger: false })
|
||||
it("reserves the physical worktree and rejects a HEAD change immediately before deletion", async () => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-"))
|
||||
const repo = path.join(temp, "repo")
|
||||
const linked = path.join(temp, "feature-worktree")
|
||||
const app = Fastify({ logger: false })
|
||||
|
||||
try {
|
||||
mkdirSync(repo, { recursive: true })
|
||||
execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" })
|
||||
try {
|
||||
mkdirSync(repo, { recursive: true })
|
||||
execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" })
|
||||
|
||||
const current: SessionInfo = {
|
||||
id: "root-session",
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: repo },
|
||||
}
|
||||
const locationCalls: string[] = []
|
||||
const moveCalls: Array<{ sessionID: string; directory: string; workspaceID?: string }> = []
|
||||
const client = {
|
||||
location: {
|
||||
get: async ({ location }: { location?: { directory?: string } }) => {
|
||||
const directory = location?.directory ?? repo
|
||||
locationCalls.push(directory)
|
||||
return {
|
||||
directory,
|
||||
workspaceID: path.resolve(directory) === path.resolve(linked) ? "native-feature" : undefined,
|
||||
project: { id: "project", directory: repo, canonical: repo },
|
||||
}
|
||||
},
|
||||
const current: SessionInfo = {
|
||||
id: "session",
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: linked, workspaceID: "native-feature" },
|
||||
}
|
||||
let lists = 0
|
||||
const client = {
|
||||
location: {
|
||||
get: async ({ location }: { location?: { directory?: string } }) => ({
|
||||
directory: location?.directory ?? repo,
|
||||
workspaceID: path.resolve(location?.directory ?? repo) === path.resolve(linked) ? "native-feature" : undefined,
|
||||
project: { id: "project", directory: repo, canonical: repo },
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
list: async () => {
|
||||
if (++lists === 3) {
|
||||
execFileSync("git", ["-C", linked, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "replace head"], { stdio: "ignore" })
|
||||
}
|
||||
return { data: [structuredClone(current)], cursor: {} }
|
||||
},
|
||||
session: {
|
||||
list: async () => ({ data: [structuredClone(current)], cursor: {} }),
|
||||
active: async () => ({}),
|
||||
move: async (input: { sessionID: string; directory: string; workspaceID?: string }) => {
|
||||
moveCalls.push(input)
|
||||
current.location = { directory: input.directory, workspaceID: input.workspaceID }
|
||||
},
|
||||
get: async () => structuredClone(current),
|
||||
active: async () => ({}),
|
||||
move: async ({ directory, workspaceID }: { directory: string; workspaceID?: string }) => {
|
||||
current.location = { directory, workspaceID }
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
get: async () => structuredClone(current),
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
let reserved = ""
|
||||
let released = false
|
||||
const manager = {
|
||||
get: () => ({
|
||||
id: "workspace",
|
||||
path: repo,
|
||||
status: "ready",
|
||||
|
|
@ -64,83 +64,25 @@ describe("worktree routes", () => {
|
|||
binaryLabel: "opencode",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}
|
||||
const manager = {
|
||||
get: (id: string) => id === workspace.id ? workspace : undefined,
|
||||
reserveWorktreeDeletion: async () => () => {},
|
||||
getSharedServiceClient: async () => client,
|
||||
} as unknown as WorkspaceManager
|
||||
registerWorktreeRoutes(app, { workspaceManager: manager })
|
||||
}),
|
||||
reserveWorktreeDeletion: async (directory: string) => {
|
||||
reserved = directory
|
||||
return () => { released = true }
|
||||
},
|
||||
getSharedServiceClient: async () => client,
|
||||
} as unknown as WorkspaceManager
|
||||
registerWorktreeRoutes(app, { workspaceManager: manager })
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/workspaces/workspace/sessions/root-session/worktree",
|
||||
payload: { worktreeSlug: "feature", directory: "C:/evil", workspaceID: "evil" },
|
||||
})
|
||||
const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/feature" })
|
||||
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.equal(path.resolve(locationCalls[1] ?? ""), path.resolve(linked))
|
||||
assert.equal(path.resolve(moveCalls[0]?.directory ?? ""), path.resolve(linked))
|
||||
assert.equal(moveCalls[0]?.workspaceID, "native-feature")
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses to remove a worktree open as another workspace", async () => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-"))
|
||||
const repo = path.join(temp, "repo")
|
||||
const linked = path.join(temp, "feature-worktree")
|
||||
const app = Fastify({ logger: false })
|
||||
|
||||
try {
|
||||
mkdirSync(repo, { recursive: true })
|
||||
execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" })
|
||||
|
||||
const workspaceFolder = path.join(repo, "apps", "web")
|
||||
const linkedWorkspaceFolder = path.join(linked, "apps", "web")
|
||||
mkdirSync(workspaceFolder, { recursive: true })
|
||||
mkdirSync(linkedWorkspaceFolder, { recursive: true })
|
||||
const workspace = workspaceDescriptor("workspace", workspaceFolder)
|
||||
const linkedWorkspace = workspaceDescriptor("linked-workspace", linkedWorkspaceFolder)
|
||||
const manager = {
|
||||
get: (id: string) => id === workspace.id ? workspace : undefined,
|
||||
list: () => [workspace, linkedWorkspace],
|
||||
reserveWorktreeDeletion: async () => {
|
||||
throw new Error("Worktree is open as another workspace")
|
||||
},
|
||||
getSharedServiceClient: async () => {
|
||||
throw new Error("OpenCode client must not be requested")
|
||||
},
|
||||
} as unknown as WorkspaceManager
|
||||
registerWorktreeRoutes(app, { workspaceManager: manager })
|
||||
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/workspaces/workspace/worktrees/feature",
|
||||
})
|
||||
|
||||
assert.equal(response.statusCode, 409)
|
||||
assert.deepEqual(response.json(), { error: "Worktree is open as another workspace" })
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function workspaceDescriptor(id: string, directory: string): WorkspaceDescriptor {
|
||||
return {
|
||||
id,
|
||||
path: directory,
|
||||
status: "ready",
|
||||
proxyPath: `/workspaces/${id}/instance`,
|
||||
binaryId: "opencode",
|
||||
binaryLabel: "opencode",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
assert.equal(response.statusCode, 409)
|
||||
assert.equal(path.resolve(reserved), path.resolve(linked))
|
||||
assert.equal(released, true)
|
||||
assert.equal(path.resolve(current.location.directory), path.resolve(repo))
|
||||
const inventory = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], { encoding: "utf8" })
|
||||
assert.ok(inventory.replace(/\\/g, "/").includes(linked.replace(/\\/g, "/")))
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
failClosed: true,
|
||||
})
|
||||
return refreshed.some((worktree) => worktree.slug === worktreeSlug
|
||||
&& worktree.registeredDirectory === target.registeredDirectory)
|
||||
&& worktree.registeredDirectory === target.registeredDirectory
|
||||
&& worktree.head === target.head)
|
||||
},
|
||||
})
|
||||
const response: WorktreeSessionMoveResponse = { ...moved, worktreeSlug }
|
||||
|
|
@ -185,12 +186,27 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
try {
|
||||
const client = await deps.workspaceManager.getSharedServiceClient()
|
||||
const isTargetRegistered = async () => {
|
||||
const refreshed = await strictWorktrees({
|
||||
repoRoot,
|
||||
workspaceFolder: workspace.path,
|
||||
logger: request.log,
|
||||
failClosed: true,
|
||||
})
|
||||
return refreshed.some((worktree) => worktree.slug === slug
|
||||
&& worktree.kind === "worktree"
|
||||
&& worktree.registeredDirectory === match.registeredDirectory
|
||||
&& worktree.head === match.head)
|
||||
}
|
||||
await removeProjectWorktree({
|
||||
client,
|
||||
projectDirectory: workspace.path,
|
||||
targetDirectory: match.registeredDirectory ?? match.directory,
|
||||
rootDirectory: worktrees.find((worktree) => worktree.kind === "root")!.directory,
|
||||
remove: async () => {
|
||||
if (!await isTargetRegistered()) {
|
||||
throw new ProjectSessionError("Worktree changed before deletion", 409)
|
||||
}
|
||||
try {
|
||||
await removeWorktree({
|
||||
workspaceFolder: workspace.path,
|
||||
|
|
@ -202,18 +218,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to remove worktree", 409)
|
||||
}
|
||||
},
|
||||
isTargetRegistered: async () => {
|
||||
const refreshed = await strictWorktrees({
|
||||
repoRoot,
|
||||
workspaceFolder: workspace.path,
|
||||
logger: request.log,
|
||||
failClosed: true,
|
||||
})
|
||||
return refreshed.some((worktree) => worktree.slug === slug
|
||||
&& worktree.kind === "worktree"
|
||||
&& worktree.registeredDirectory === match.registeredDirectory
|
||||
&& worktree.head === match.head)
|
||||
},
|
||||
isTargetRegistered,
|
||||
})
|
||||
invalidateWorktreeDirectoryCache(workspace.id)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -25,4 +25,15 @@ describe("BinaryResolver", () => {
|
|||
} as unknown as SettingsService
|
||||
assert.equal(new BinaryResolver(settings).resolveDefault().path, "opencode2")
|
||||
})
|
||||
|
||||
it("upgrades the legacy bare opencode default to opencode2", () => {
|
||||
const settings = {
|
||||
getOwner(scope: string, owner: string) {
|
||||
if (scope === "config" && owner === "server") return { opencodeBinary: "opencode" }
|
||||
return {}
|
||||
},
|
||||
} as unknown as SettingsService
|
||||
|
||||
assert.equal(new BinaryResolver(settings).resolveDefault().path, "opencode2")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export class BinaryResolver {
|
|||
resolveDefault(): ResolvedBinary {
|
||||
const binaries = this.list()
|
||||
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||
const path = configuredDefault ?? "opencode2"
|
||||
const path = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault
|
||||
|
||||
const entry = binaries.find((b) => b.path === path)
|
||||
return {
|
||||
|
|
|
|||
29
packages/server/src/settings/migrate.test.ts
Normal file
29
packages/server/src/settings/migrate.test.ts
Normal file
|
|
@ -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("preserves all configured 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, { OPENCODE_DB: "/legacy/opencode.db", KEEP_ME: "yes" })
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = { ...envVars }
|
||||
}
|
||||
const listeningMode = preferences.listeningMode
|
||||
if (typeof listeningMode === "string") {
|
||||
|
|
|
|||
58
packages/server/src/settings/service.test.ts
Normal file
58
packages/server/src/settings/service.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SettingsService } from "./service"
|
||||
|
||||
function serviceWithStore(store: Record<string, unknown>) {
|
||||
const service = Object.create(SettingsService.prototype) as SettingsService
|
||||
Object.assign(service as any, { configStore: store, eventBus: undefined })
|
||||
return service
|
||||
}
|
||||
|
||||
describe("SettingsService config persistence", () => {
|
||||
it("normalizes and persists a document patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
get: () => ({ server: { logLevel: "info" } }),
|
||||
replace: (value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatch: () => assert.fail("must not persist an intermediate document"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchDoc("config", { ui: { theme: "dark" } })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } })
|
||||
})
|
||||
|
||||
it("normalizes and persists a server-owner patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({ logLevel: "info", sidecars: [] }),
|
||||
replaceOwner: (_owner: string, value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatchOwner: () => assert.fail("must not persist an intermediate owner"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] })
|
||||
})
|
||||
|
||||
it("does not report a persisted patch as failed when an event listener throws", () => {
|
||||
let warnings = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({}),
|
||||
mergePatchOwner: (_owner: string, patch: unknown) => patch,
|
||||
})
|
||||
Object.assign(service as any, {
|
||||
eventBus: { publish: () => { throw new Error("listener failed") } },
|
||||
logger: { warn: () => { warnings += 1 } },
|
||||
})
|
||||
|
||||
assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" })
|
||||
assert.equal(warnings, 1)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
|||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
import { applyMergePatch } from "./merge-patch"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
|
|
@ -67,8 +68,16 @@ export class SettingsService {
|
|||
private readonly logger: Logger,
|
||||
) {
|
||||
migrateSettingsLayout(location, logger)
|
||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||
this.configStore = new YamlDocStore(
|
||||
location.configYamlPath,
|
||||
logger.child({ component: "settings-config" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
this.stateStore = new YamlDocStore(
|
||||
location.stateYamlPath,
|
||||
logger.child({ component: "settings-state" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
|
|
@ -85,9 +94,12 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
|
|
@ -104,10 +116,16 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
? this.configStore.replaceOwner(
|
||||
owner,
|
||||
normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc),
|
||||
)
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
|
|
@ -123,6 +141,10 @@ export class SettingsService {
|
|||
owner,
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
try {
|
||||
this.eventBus.publish(payload)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { YamlDocStore } from "./yaml-doc-store"
|
||||
|
||||
describe("YamlDocStore", () => {
|
||||
it("reports persistence failures without replacing the cached document", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const parent = path.join(root, "settings")
|
||||
const file = path.join(parent, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.rmSync(parent, { recursive: true })
|
||||
fs.writeFileSync(parent, "blocks directory creation")
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the live document intact when atomic replacement fails", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
const renameSync = fs.renameSync
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
;(fs as any).renameSync = () => { throw new Error("replacement failed") }
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 1/)
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
|
||||
;(fs as any).renameSync = renameSync
|
||||
store.replace({ version: 2 })
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
;(fs as any).renameSync = renameSync
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves private file permissions", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.chmodSync(file, 0o664)
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o664)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const target = path.join(root, "target.yaml")
|
||||
const intermediate = path.join(root, "current.yaml")
|
||||
const link = path.join(root, "config.yaml")
|
||||
fs.writeFileSync(target, "version: 1\n")
|
||||
fs.symlinkSync(target, intermediate)
|
||||
fs.symlinkSync(intermediate, link)
|
||||
const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true)
|
||||
assert.match(fs.readFileSync(target, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||
|
|
@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc {
|
|||
return input
|
||||
}
|
||||
|
||||
function resolveWriteDestination(filePath: string): string {
|
||||
let current = path.resolve(filePath)
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (true) {
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.lstatSync(current)
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return current
|
||||
throw error
|
||||
}
|
||||
if (!stat.isSymbolicLink()) return current
|
||||
if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`)
|
||||
seen.add(current)
|
||||
current = path.resolve(path.dirname(current), fs.readlinkSync(current))
|
||||
}
|
||||
}
|
||||
|
||||
export class YamlDocStore {
|
||||
private cache: SettingsDoc = {}
|
||||
private loaded = false
|
||||
|
|
@ -25,6 +45,7 @@ export class YamlDocStore {
|
|||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly options: { throwOnPersistError?: boolean } = {},
|
||||
) {}
|
||||
|
||||
load(): SettingsDoc {
|
||||
|
|
@ -58,9 +79,17 @@ export class YamlDocStore {
|
|||
|
||||
replace(next: unknown): SettingsDoc {
|
||||
const normalized = normalizeDoc(next)
|
||||
const previousCache = this.cache
|
||||
const previousLoaded = this.loaded
|
||||
this.cache = normalized
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
try {
|
||||
this.persist()
|
||||
} catch (error) {
|
||||
this.cache = previousCache
|
||||
this.loaded = previousLoaded
|
||||
throw error
|
||||
}
|
||||
return this.cache
|
||||
}
|
||||
|
||||
|
|
@ -99,12 +128,27 @@ export class YamlDocStore {
|
|||
}
|
||||
|
||||
private persist() {
|
||||
let tempPath: string | undefined
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
const destination = resolveWriteDestination(this.filePath)
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||
const yaml = stringifyYaml(this.cache as any)
|
||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600
|
||||
tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp`
|
||||
fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode })
|
||||
if (process.platform !== "win32") fs.chmodSync(tempPath, mode)
|
||||
fs.renameSync(tempPath, destination)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||
if (this.options.throwOnPersistError) throw error
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
packages/server/src/sidecars/manager.test.ts
Normal file
47
packages/server/src/sidecars/manager.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SideCarManager } from "./manager"
|
||||
|
||||
const existing = {
|
||||
id: "existing",
|
||||
kind: "port",
|
||||
name: "Existing",
|
||||
port: 65534,
|
||||
insecure: true,
|
||||
prefixMode: "strip",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}
|
||||
|
||||
function manager(sidecars: unknown[] = []) {
|
||||
return new SideCarManager({
|
||||
settings: {
|
||||
getOwner: () => ({ sidecars }),
|
||||
mergePatchOwner: () => { throw new Error("disk full") },
|
||||
} as any,
|
||||
eventBus: { publish() {} } as any,
|
||||
logger: { warn() {} } as any,
|
||||
})
|
||||
}
|
||||
|
||||
describe("SideCarManager persistence rollback", () => {
|
||||
it("restores create, update, and delete state when persistence fails", async () => {
|
||||
const createManager = manager()
|
||||
await assert.rejects(createManager.create({
|
||||
kind: "port",
|
||||
name: "New",
|
||||
port: 65533,
|
||||
insecure: true,
|
||||
prefixMode: "strip",
|
||||
}))
|
||||
assert.deepEqual(await createManager.list(), [])
|
||||
|
||||
const updateManager = manager([existing])
|
||||
await assert.rejects(updateManager.update(existing.id, { name: "Changed" }))
|
||||
assert.equal((await updateManager.get(existing.id))?.name, existing.name)
|
||||
|
||||
const deleteManager = manager([existing])
|
||||
await assert.rejects(deleteManager.delete(existing.id))
|
||||
assert.equal((await deleteManager.get(existing.id))?.id, existing.id)
|
||||
})
|
||||
})
|
||||
|
|
@ -82,7 +82,13 @@ export class SideCarManager {
|
|||
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.delete(record.id)
|
||||
this.runtime.delete(record.id)
|
||||
throw error
|
||||
}
|
||||
await this.refreshPortSideCar(record.id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
|
@ -98,13 +104,19 @@ export class SideCarManager {
|
|||
): Promise<SideCar> {
|
||||
const record = this.requireConfig(id)
|
||||
|
||||
const previous = { ...record }
|
||||
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||
record.port = typeof input.port === "number" ? input.port : record.port
|
||||
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||
record.updatedAt = new Date().toISOString()
|
||||
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.set(id, previous)
|
||||
throw error
|
||||
}
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
|
@ -113,9 +125,16 @@ export class SideCarManager {
|
|||
const record = this.configs.get(id)
|
||||
if (!record) return false
|
||||
|
||||
const runtime = this.runtime.get(id)
|
||||
this.configs.delete(id)
|
||||
this.runtime.delete(id)
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.set(id, record)
|
||||
if (runtime) this.runtime.set(id, runtime)
|
||||
throw error
|
||||
}
|
||||
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
|
|
@ -11,13 +11,13 @@ describe("listWorktrees", () => {
|
|||
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-"))
|
||||
const repoRoot = path.join(temp, "repo")
|
||||
const workspaceFolder = path.join(repoRoot, "proj-1")
|
||||
const linkedDirectory = path.join(temp, "feature-worktree")
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", "--initial-branch=main", repoRoot])
|
||||
mkdirSync(workspaceFolder, { recursive: true })
|
||||
execFileSync("git", ["init", "-b", "main", repoRoot], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" })
|
||||
execFileSync("git", ["-C", repoRoot, "worktree", "add", "-b", "feature", linkedDirectory], { stdio: "ignore" })
|
||||
writeFileSync(path.join(repoRoot, "README.md"), "test\n")
|
||||
execFileSync("git", ["-C", repoRoot, "add", "README.md"])
|
||||
execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"])
|
||||
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder })
|
||||
|
||||
|
|
@ -25,11 +25,7 @@ describe("listWorktrees", () => {
|
|||
assert.equal(worktrees[0]?.directory, workspaceFolder)
|
||||
assert.equal(worktrees[0]?.kind, "root")
|
||||
assert.equal(worktrees[0]?.branch, "main")
|
||||
assert.equal(path.resolve(worktrees[0]?.registeredDirectory ?? ""), path.resolve(repoRoot))
|
||||
assert.notEqual(worktrees[0]?.directory, repoRoot)
|
||||
const linked = worktrees.find(({ slug }) => slug === "feature")
|
||||
assert.equal(path.resolve(linked?.directory ?? ""), path.resolve(linkedDirectory, "proj-1"))
|
||||
assert.equal(path.resolve(linked?.registeredDirectory ?? ""), path.resolve(linkedDirectory))
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -56,10 +64,6 @@ describe("buildWindowsSpawnSpec", () => {
|
|||
assert.equal(buildWindowsSpawnSpec("powershell.exe", []).processKind, "windows-wrapper")
|
||||
})
|
||||
|
||||
it("conservatively classifies bare commands as wrappers", () => {
|
||||
assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper")
|
||||
})
|
||||
|
||||
it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", { skip: process.platform !== "win32" }, () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-"))
|
||||
const cwd = path.join(root, "workspace")
|
||||
|
|
@ -145,11 +149,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 +246,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(
|
||||
|
|
@ -233,22 +310,23 @@ describe("buildServiceLaunchSpec", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("uses a Node trampoline for the verbatim cmd.exe batch command", () => {
|
||||
it("passes through a non-npm cmd wrapper with its required verbatim arguments", () => {
|
||||
const launch = buildServiceLaunchSpec(String.raw`C:\Program Files\OpenCode\opencode.cmd`, ["serve", "--service"], {
|
||||
platform: "win32",
|
||||
env: { ComSpec: "test-cmd.exe" },
|
||||
})
|
||||
|
||||
assert.equal(launch.command[0], process.execPath)
|
||||
assert.equal(launch.command[1], "-e")
|
||||
assert.equal(launch.command[3], "test-cmd.exe")
|
||||
assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), [
|
||||
assert.equal(launch.command[0], "test-cmd.exe")
|
||||
assert.deepEqual(launch.command.slice(1), [
|
||||
"/d", "/s", "/c", String.raw`""C:\Program Files\OpenCode\opencode.cmd" serve --service"`,
|
||||
])
|
||||
assert.equal(launch.command[5], "")
|
||||
assert.equal(launch.windowsVerbatimArguments, true)
|
||||
assert.equal(launch.nativePid, false)
|
||||
assert.equal(buildServiceLaunchSpec("custom.bat", ["serve"], { platform: "win32" }).nativePid, false)
|
||||
assert.equal(buildServiceLaunchSpec("custom.ps1", ["serve"], { platform: "win32" }).nativePid, false)
|
||||
})
|
||||
|
||||
it("records a direct service contender PID", () => {
|
||||
it("launches a direct service executable without a wrapper", () => {
|
||||
const launch = buildServiceLaunchSpec("opencode.exe", ["serve", "--service"], {
|
||||
platform: "win32",
|
||||
contenderFile: String.raw`C:\Temp\codenomad-contenders.txt`,
|
||||
|
|
@ -256,8 +334,10 @@ describe("buildServiceLaunchSpec", () => {
|
|||
|
||||
assert.equal(launch.command[0], process.execPath)
|
||||
assert.equal(launch.command[3], "opencode.exe")
|
||||
assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), ["serve", "--service"])
|
||||
assert.equal(launch.command[5], String.raw`C:\Temp\codenomad-contenders.txt`)
|
||||
assert.equal(launch.command[6], "false")
|
||||
assert.equal(launch.nativePid, true)
|
||||
assert.equal(launch.launcherRecordsPid, true)
|
||||
})
|
||||
|
||||
it("translates shared Windows state and contender files for WSL", () => {
|
||||
|
|
@ -273,9 +353,46 @@ describe("buildServiceLaunchSpec", () => {
|
|||
)
|
||||
|
||||
assert.equal(launch.command[0], "wsl.exe")
|
||||
assert.match(launch.command[6] ?? "", /wslpath -au/)
|
||||
assert.equal(launch.command[8], contenderFile)
|
||||
const wslArgs = launch.command.slice(1)
|
||||
assert.match(wslArgs[5] ?? "", /wslpath -au/)
|
||||
assert.equal(wslArgs[7], contenderFile)
|
||||
assert.equal(launch.env?.WSLENV, "XDG_STATE_HOME/p")
|
||||
assert.equal(launch.nativePid, false)
|
||||
assert.equal(launch.wslDistro, "Ubuntu")
|
||||
})
|
||||
|
||||
it("passes configured service variables only to the launched child", () => {
|
||||
const launch = buildServiceLaunchSpec("opencode", ["serve", "--service"], {
|
||||
platform: "linux",
|
||||
env: { ...process.env, XDG_STATE_HOME: "/private/state", SERVICE_ONLY: "yes" },
|
||||
propagateEnvKeys: ["XDG_STATE_HOME", "SERVICE_ONLY"],
|
||||
})
|
||||
|
||||
assert.deepEqual(launch.command, ["opencode", "serve", "--service"])
|
||||
assert.equal(launch.env?.XDG_STATE_HOME, "/private/state")
|
||||
assert.equal(launch.env?.SERVICE_ONLY, "yes")
|
||||
})
|
||||
|
||||
it("resolves the standard Windows npm opencode2 shim to its packaged executable", { skip: process.platform !== "win32" }, () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codenomad-npm-shim-"))
|
||||
const executable = path.join(root, "node_modules", "@opencode-ai", "cli", "bin", "opencode2.exe")
|
||||
mkdirSync(path.dirname(executable), { recursive: true })
|
||||
writeFileSync(executable, "")
|
||||
writeFileSync(path.join(root, "opencode2.cmd"), '@ECHO off\r\n"%~dp0\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n')
|
||||
try {
|
||||
const launch = buildServiceLaunchSpec("opencode2", ["serve", "--service"], {
|
||||
platform: "win32",
|
||||
cwd: root,
|
||||
env: { PATH: root, PATHEXT: ".CMD" },
|
||||
contenderFile: path.join(root, "contenders.txt"),
|
||||
})
|
||||
assert.equal(launch.command[0], process.execPath)
|
||||
assert.equal(launch.command[3]?.toLowerCase(), executable.toLowerCase())
|
||||
assert.equal(launch.nativePid, true)
|
||||
assert.equal(launch.launcherRecordsPid, true)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ function createManager(rootDir: string) {
|
|||
binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "Node.js", version: process.version }) },
|
||||
eventBus: new EventBus(logger),
|
||||
logger,
|
||||
getServerBaseUrl: () => "http://127.0.0.1:3000",
|
||||
sharedService,
|
||||
} as unknown as ConstructorParameters<typeof WorkspaceManager>[0])
|
||||
return manager
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ const logger = {
|
|||
warn() {},
|
||||
} as unknown as Logger
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function waitFor(check: () => boolean): Promise<void> {
|
||||
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)
|
||||
|
|
@ -26,7 +32,89 @@ function waitFor(check: () => boolean): Promise<void> {
|
|||
})
|
||||
}
|
||||
|
||||
function locationlessManager(
|
||||
events: OpenCodeEvent[],
|
||||
sessionLocations: Record<string, string | Error>,
|
||||
workspaces = [{ id: "a", path: "/repo-a" }],
|
||||
) {
|
||||
let sessionGets = 0
|
||||
const manager = {
|
||||
list: () => workspaces,
|
||||
ownsDirectory: async (workspaceId: string, directory: string) => (
|
||||
workspaces.some((workspace) => workspace.id === workspaceId && workspace.path === directory)
|
||||
),
|
||||
getSharedServiceClient: async () => ({
|
||||
session: { get: async ({ sessionID }: { sessionID: string }) => {
|
||||
sessionGets++
|
||||
const location = sessionLocations[sessionID]
|
||||
if (location instanceof Error) throw location
|
||||
if (!location) throw new Error("Session not found")
|
||||
return { id: sessionID, location: { directory: location } }
|
||||
} },
|
||||
}),
|
||||
subscribeToSharedService: async (signal?: AbortSignal) => (async function* () {
|
||||
yield* events
|
||||
await new Promise<void>((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true }))
|
||||
})(),
|
||||
} as unknown as WorkspaceManager
|
||||
return { manager, sessionGets: () => sessionGets }
|
||||
}
|
||||
|
||||
describe("InstanceEventBridge", () => {
|
||||
it("does not publish connected until the stream confirms with its first event", async () => {
|
||||
const gate = deferred<void>()
|
||||
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<void>((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<void>((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" } },
|
||||
|
|
@ -88,21 +176,201 @@ describe("InstanceEventBridge", () => {
|
|||
assert.equal(received[0].instanceId, "a")
|
||||
assert.deepEqual(received[0].event.location, { directory: "/repo-a" })
|
||||
assert.deepEqual(received[0].event.data, { id: "p1" })
|
||||
assert.deepEqual(received[0].event.properties, { id: "p1" })
|
||||
assert.equal(received[0].event.properties, undefined)
|
||||
assert.equal(received[1].event.data.sessionID, "session-1")
|
||||
assert.equal(received[1].event.properties.info.id, "session-1")
|
||||
assert.equal(received[1].event.properties, undefined)
|
||||
assert.equal(received[2].instanceId, "a")
|
||||
assert.deepEqual(received[2].event.properties, {
|
||||
assert.deepEqual(received[2].event.data, {
|
||||
sessionID: "session-2",
|
||||
assistantMessageID: "message-1",
|
||||
ordinal: 0,
|
||||
delta: "hello",
|
||||
})
|
||||
assert.equal(received[3].instanceId, "a")
|
||||
assert.equal(received[3].event.properties.delta, " again")
|
||||
assert.equal(received[3].event.data.delta, " again")
|
||||
assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2)
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it("fans an event out to every logical workspace for the same directory", async () => {
|
||||
const manager = {
|
||||
list: () => [{ id: "first", path: "/repo" }, { id: "second", path: "/repo" }],
|
||||
ownsDirectory: async (_workspaceId: string, directory: string) => directory === "/repo",
|
||||
subscribeToSharedService: async (signal?: AbortSignal) => (async function* () {
|
||||
yield { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo" }, data: { id: "p1" } } as OpenCodeEvent
|
||||
await new Promise<void>((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true }))
|
||||
})(),
|
||||
} as unknown as WorkspaceManager
|
||||
const bus = new EventBus()
|
||||
const received: string[] = []
|
||||
bus.on("instance.event", (event) => received.push(event.instanceId))
|
||||
|
||||
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, ["first", "second"])
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it("routes known locationless session events and invalidates the cache after deletion", async () => {
|
||||
const events = [
|
||||
{ type: "session.text.delta", data: { sessionID: "known", delta: "one" } },
|
||||
{ type: "session.status", data: { sessionID: "known", status: { type: "busy" } } },
|
||||
{ type: "session.deleted", data: { sessionID: "known" } },
|
||||
{ type: "session.status", data: { sessionID: "known", status: { type: "idle" } } },
|
||||
] as OpenCodeEvent[]
|
||||
const { manager, sessionGets } = locationlessManager(events, { known: "/repo-a" })
|
||||
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 === 4)
|
||||
assert.deepEqual(received.map((event) => event.instanceId), ["a", "a", "a", "a"])
|
||||
assert.equal(sessionGets(), 2)
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it("drops an unknown locationless session event", async () => {
|
||||
const events = [{ type: "session.status", data: { sessionID: "unknown", status: { type: "idle" } } }] as OpenCodeEvent[]
|
||||
const { manager, sessionGets } = locationlessManager(events, { unknown: new Error("not found") })
|
||||
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(() => sessionGets() === 1)
|
||||
assert.deepEqual(received, [])
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
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" }]
|
||||
const { manager, sessionGets } = locationlessManager(events, { deleted: new Error("not found") }, 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 === 2)
|
||||
assert.equal(sessionGets(), 1)
|
||||
assert.deepEqual(received.map((event) => event.instanceId), ["a", "b"])
|
||||
assert.deepEqual(received.map((event) => event.event.data.sessionID), ["deleted", "deleted"])
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
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 === 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.data.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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,24 @@ import type { OpenCodeEvent } from "@opencode-ai/client"
|
|||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "./manager"
|
||||
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||
import { 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
|
||||
|
|
@ -17,18 +31,20 @@ export class InstanceEventBridge {
|
|||
private readonly controller = new AbortController()
|
||||
private status: InstanceStreamStatus = "connecting"
|
||||
private task?: Promise<void>
|
||||
private readonly directoryOwners = new Map<string, { expiresAt: number; owner: Promise<string | undefined> }>()
|
||||
private readonly directoryOwners = new Map<string, { expiresAt: number; owners: Promise<string[]> }>()
|
||||
private readonly sessionDirectories = new Map<string, { expiresAt: number; directory: Promise<string | undefined> }>()
|
||||
private readonly ptyDirectories = new Map<string, string>()
|
||||
private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => {
|
||||
this.directoryOwners.clear()
|
||||
this.clearLocationCaches()
|
||||
if (!this.task) this.task = this.run()
|
||||
else this.publishStatus(event.workspace.id, this.status)
|
||||
}
|
||||
private readonly onWorkspaceStopped = (event: { workspaceId: string }) => {
|
||||
this.directoryOwners.clear()
|
||||
this.clearLocationCaches()
|
||||
this.publishStatus(event.workspaceId, "disconnected", "workspace stopped")
|
||||
}
|
||||
private readonly onWorkspaceError = (event: { workspace: { id: string } }) => {
|
||||
this.directoryOwners.clear()
|
||||
this.clearLocationCaches()
|
||||
this.publishStatus(event.workspace.id, "disconnected", "workspace error")
|
||||
}
|
||||
|
||||
|
|
@ -52,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")
|
||||
|
|
@ -71,46 +92,112 @@ 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
|
||||
if (!directory) return
|
||||
|
||||
const instanceId = await this.resolveDirectoryOwner(directory)
|
||||
if (!instanceId) return
|
||||
|
||||
// The server's auto-accept boundary still reads the legacy property name.
|
||||
const compatibleEvent: InstanceStreamEvent = {
|
||||
...event,
|
||||
properties: this.compatibilityProperties(event),
|
||||
?? 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.
|
||||
this.broadcastEvent(event)
|
||||
this.sessionDirectories.delete(sessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent })
|
||||
if (sessionId) {
|
||||
this.sessionDirectories.set(sessionId, {
|
||||
expiresAt: Date.now() + SESSION_DIRECTORY_CACHE_MS,
|
||||
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
|
||||
}
|
||||
|
||||
for (const instanceId of instanceIds) {
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId, event })
|
||||
}
|
||||
if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId)
|
||||
if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId)
|
||||
}
|
||||
|
||||
private resolveDirectoryOwner(directory: string): Promise<string | undefined> {
|
||||
const now = Date.now()
|
||||
const cached = this.directoryOwners.get(directory)
|
||||
if (cached && cached.expiresAt > now) return cached.owner
|
||||
private sessionId(event: OpenCodeEvent): string | undefined {
|
||||
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
|
||||
}
|
||||
|
||||
const workspaces = this.options.workspaceManager.list()
|
||||
const owner = Promise.all(workspaces.map((workspace) => (
|
||||
this.options.workspaceManager.ownsDirectory(workspace.id, directory)
|
||||
)))
|
||||
.then((ownership) => workspaces.find((_, index) => ownership[index])?.id)
|
||||
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 {
|
||||
for (const workspace of this.options.workspaceManager.list()) {
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event })
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSessionDirectory(sessionId: string): Promise<string | undefined> {
|
||||
const now = Date.now()
|
||||
const cached = this.sessionDirectories.get(sessionId)
|
||||
if (cached && cached.expiresAt > now) return cached.directory
|
||||
|
||||
const directory = this.options.workspaceManager.getSharedServiceClient()
|
||||
.then((client) => client.session.get({ sessionID: sessionId }))
|
||||
.then((session) => session.location.directory)
|
||||
.catch((error) => {
|
||||
this.options.logger.warn({ err: error, directory }, "Failed to resolve instance event directory owner")
|
||||
this.options.logger.warn({ err: error, sessionId }, "Failed to resolve instance event session location")
|
||||
return undefined
|
||||
})
|
||||
this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owner })
|
||||
return owner
|
||||
this.sessionDirectories.set(sessionId, { expiresAt: now + SESSION_DIRECTORY_CACHE_MS, directory })
|
||||
return directory
|
||||
}
|
||||
|
||||
private compatibilityProperties(event: OpenCodeEvent): Record<string, unknown> {
|
||||
if (event.type === "session.created") {
|
||||
return { info: { ...event.data, id: event.data.sessionID } }
|
||||
}
|
||||
if (event.type === "session.deleted") {
|
||||
return { id: event.data.sessionID }
|
||||
}
|
||||
return event.data as Record<string, unknown>
|
||||
private resolveDirectoryOwners(directory: string): Promise<string[]> {
|
||||
const now = Date.now()
|
||||
const cached = this.directoryOwners.get(directory)
|
||||
if (cached && cached.expiresAt > now) return cached.owners
|
||||
|
||||
const workspaces = this.options.workspaceManager.list()
|
||||
const owners = Promise.all(workspaces.map((workspace) => (
|
||||
this.options.workspaceManager.ownsDirectory(workspace.id, directory)
|
||||
)))
|
||||
.then((ownership) => workspaces.filter((_, index) => ownership[index]).map((workspace) => workspace.id))
|
||||
.catch((error) => {
|
||||
this.options.logger.warn({ err: error, directory }, "Failed to resolve instance event directory owner")
|
||||
return []
|
||||
})
|
||||
this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owners })
|
||||
return owners
|
||||
}
|
||||
|
||||
private clearLocationCaches(): void {
|
||||
this.directoryOwners.clear()
|
||||
this.sessionDirectories.clear()
|
||||
this.ptyDirectories.clear()
|
||||
}
|
||||
|
||||
private updateStatus(status: InstanceStreamStatus, reason?: string) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import {
|
|||
import type { OpenCodeEnsureOptions } from "./opencode-service"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
|
@ -31,8 +32,7 @@ class ControlledSharedService {
|
|||
evictions: LocationRef[] = []
|
||||
failEvictions = 0
|
||||
|
||||
async endpoint(options?: OpenCodeEnsureOptions) {
|
||||
this.assertCommand(options)
|
||||
async endpoint() {
|
||||
return { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "user", password: "pass" } }
|
||||
}
|
||||
|
||||
|
|
@ -40,13 +40,11 @@ class ControlledSharedService {
|
|||
return {} as OpenCodeClient
|
||||
}
|
||||
|
||||
async headers(options?: OpenCodeEnsureOptions) {
|
||||
this.assertCommand(options)
|
||||
async headers() {
|
||||
return { authorization: "Basic token" }
|
||||
}
|
||||
|
||||
async validateLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeEnsureOptions) {
|
||||
this.assertCommand(options)
|
||||
this.validationCalls.push({ location, options })
|
||||
this.validationStarted.resolve()
|
||||
if (this.validationGate) {
|
||||
|
|
@ -76,53 +74,67 @@ class ControlledSharedService {
|
|||
}
|
||||
|
||||
async shutdown() {}
|
||||
|
||||
private assertCommand(options?: OpenCodeEnsureOptions) {
|
||||
assert.equal(options?.file, path.join(os.tmpdir(), "codenomad-opencode-v2", "opencode", "service.json"))
|
||||
assert.equal(options?.environment?.XDG_STATE_HOME, path.join(os.tmpdir(), "codenomad-opencode-v2"))
|
||||
assert.equal(
|
||||
options?.command?.[5],
|
||||
options?.environment?.CODENOMAD_SERVICE_CONTENDERS,
|
||||
)
|
||||
assert.match(options?.environment?.CODENOMAD_SERVICE_CONTENDERS ?? "", new RegExp(`contenders-${process.pid}-.*\\.txt$`))
|
||||
assert.equal(options?.command?.[0], process.execPath)
|
||||
assert.equal(options?.command?.[1], "-e")
|
||||
assert.equal(options?.command?.[4], JSON.stringify(["serve", "--service"]))
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(service = new ControlledSharedService()) {
|
||||
function createHarness(service = new ControlledSharedService(), overrides: Record<string, unknown> = {}) {
|
||||
const eventBus = new EventBus()
|
||||
const started: string[] = []
|
||||
const stopped: string[] = []
|
||||
eventBus.on("workspace.started", (event) => started.push(event.workspace.id))
|
||||
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 }
|
||||
return { manager, service, stopped }
|
||||
}
|
||||
|
||||
describe("workspace manager shared service lifecycle", () => {
|
||||
it("creates a ready logical location without a workspace process", async () => {
|
||||
const { manager, service, started } = createHarness()
|
||||
const { workspace, created } = await manager.create(process.cwd())
|
||||
|
||||
assert.equal(created, true)
|
||||
assert.equal(workspace.status, "ready")
|
||||
assert.equal(workspace.pid, undefined)
|
||||
assert.equal(workspace.port, undefined)
|
||||
assert.equal(manager.getInstanceAuthorizationHeader(workspace.id), "Basic token")
|
||||
assert.deepEqual(service.validationCalls.map(({ location }) => location), [{ directory: process.cwd() }])
|
||||
assert.deepEqual(started, [workspace.id])
|
||||
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()
|
||||
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("shares one in-flight logical location creation", async () => {
|
||||
const harness = createHarness()
|
||||
harness.service.validationGate = deferred<void>()
|
||||
|
|
@ -151,20 +163,6 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
assert.deepEqual(harness.stopped, [forced.workspace.id, first.workspace.id])
|
||||
})
|
||||
|
||||
it("evicts a location once when duplicate owners are deleted concurrently", async () => {
|
||||
const harness = createHarness()
|
||||
const first = await harness.manager.create(process.cwd())
|
||||
const forced = await harness.manager.create(process.cwd(), undefined, { forceNew: true })
|
||||
|
||||
await Promise.all([
|
||||
harness.manager.delete(first.workspace.id),
|
||||
harness.manager.delete(forced.workspace.id),
|
||||
])
|
||||
|
||||
assert.equal(harness.service.evictions.length, 1)
|
||||
assert.deepEqual(harness.manager.list(), [])
|
||||
})
|
||||
|
||||
it("cancels validation and cleans its logical location", async () => {
|
||||
const harness = createHarness()
|
||||
harness.service.validationGate = deferred<void>()
|
||||
|
|
@ -179,22 +177,18 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }])
|
||||
})
|
||||
|
||||
it("blocks workspace creation beneath a reserved worktree deletion", async () => {
|
||||
const temp = mkdtempSync(path.join(os.tmpdir(), "codenomad-worktree-reservation-"))
|
||||
it("refuses deletion while another workspace occupies the worktree", async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-owner-"))
|
||||
const worktree = path.join(temp, "worktree")
|
||||
const nested = path.join(worktree, "apps", "web")
|
||||
mkdirSync(nested, { recursive: true })
|
||||
await mkdir(nested, { recursive: true })
|
||||
const harness = createHarness()
|
||||
|
||||
try {
|
||||
const release = await harness.manager.reserveWorktreeDeletion(worktree)
|
||||
await assert.rejects(() => harness.manager.create(nested), /being removed/)
|
||||
release()
|
||||
const created = await harness.manager.create(nested)
|
||||
assert.equal(created.workspace.path, nested)
|
||||
await harness.manager.delete(created.workspace.id)
|
||||
const { workspace } = await harness.manager.create(nested)
|
||||
await assert.rejects(() => harness.manager.reserveWorktreeDeletion(worktree), /open as another workspace/)
|
||||
await harness.manager.delete(workspace.id)
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true })
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -213,4 +207,5 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
await harness.manager.delete(workspace.id)
|
||||
assert.equal(harness.manager.get(workspace.id), undefined)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import path from "path"
|
||||
import os from "node:os"
|
||||
import { spawnSync } from "child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import type { Endpoint } from "@opencode-ai/client/service"
|
||||
import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { EventBus } from "../events/bus"
|
||||
|
|
@ -14,17 +13,29 @@ 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 { resolveWorktreeSlugForDirectory } from "./worktree-directory"
|
||||
import {
|
||||
prepareServiceState,
|
||||
SERVICE_LEASE_DIRECTORY,
|
||||
SERVICE_REGISTRATION_FILE,
|
||||
SERVICE_STATE_ROOT,
|
||||
SERVICE_STOP_LOCK,
|
||||
} from "./service-state"
|
||||
import { isPathOwnedByWorktree, resolveWorktreeSlugForDirectory } from "./worktree-directory"
|
||||
|
||||
const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000
|
||||
const OPENCODE_DATABASE = path.join(os.homedir(), ".local", "share", "opencode2", "opencode.db")
|
||||
const ORDINARY_CREATION_OWNER = ""
|
||||
const WORKSPACE_STATE = Symbol("workspaceState")
|
||||
const SERVICE_STATE_ROOT = path.join(os.tmpdir(), "codenomad-opencode-v2")
|
||||
const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json")
|
||||
const SERVICE_CONTENDER_FILE = path.join(SERVICE_STATE_ROOT, `contenders-${process.pid}-${randomUUID()}.txt`)
|
||||
type ManagerTimeout = ReturnType<typeof setTimeout>
|
||||
const SERVICE_LEASE_FILE = path.join(SERVICE_LEASE_DIRECTORY, `process-${process.pid}-${randomUUID()}.json`)
|
||||
type ManagerTimeout = number | NodeJS.Timeout
|
||||
|
||||
interface SharedService {
|
||||
endpoint: (options?: OpenCodeEnsureOptions) => Promise<Endpoint>
|
||||
|
|
@ -33,7 +44,7 @@ interface SharedService {
|
|||
validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<LocationGetOutput>
|
||||
subscribe: (requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<AsyncIterable<OpenCodeEvent>>
|
||||
evict: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<void>
|
||||
shutdown: () => Promise<void>
|
||||
shutdown: (options?: { timeoutMs?: number }) => Promise<void>
|
||||
}
|
||||
|
||||
export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean {
|
||||
|
|
@ -56,7 +67,6 @@ interface WorkspaceManagerOptions {
|
|||
binaryResolver: BinaryResolver
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
||||
nodeExtraCaCertsPath?: string
|
||||
sharedService?: SharedService
|
||||
|
|
@ -65,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
|
||||
}
|
||||
|
|
@ -129,7 +143,6 @@ export class WorkspaceManager {
|
|||
private readonly cancelledCreationRequests = new Set<string>()
|
||||
private shuttingDown = false
|
||||
private readonly sharedService: SharedService
|
||||
private serviceEndpoint?: Endpoint
|
||||
private serviceAuthorization?: string
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
|
|
@ -149,11 +162,15 @@ 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<Endpoint | undefined> {
|
||||
if (!this.workspaces.get(id)?.[WORKSPACE_STATE].published) return undefined
|
||||
try {
|
||||
const [endpoint, headers] = await Promise.all([this.sharedService.endpoint(), this.sharedService.headers()])
|
||||
this.serviceEndpoint = endpoint
|
||||
this.serviceAuthorization = headers?.authorization
|
||||
return endpoint
|
||||
} catch (error) {
|
||||
|
|
@ -179,16 +196,58 @@ export class WorkspaceManager {
|
|||
}
|
||||
|
||||
async ownsDirectory(id: string, directory: string): Promise<boolean> {
|
||||
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<string | undefined> {
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published || !await this.ownsDirectory(id, directory)) return undefined
|
||||
if (!record.wslDistro) return directory
|
||||
return this.resolveWslServiceDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS)
|
||||
?? (path.posix.isAbsolute(directory) ? directory : undefined)
|
||||
}
|
||||
|
||||
async getServicePathForPath(id: string, candidate: string): Promise<string | undefined> {
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published || !await this.ownsPath(id, candidate)) return undefined
|
||||
if (!record.wslDistro) return candidate
|
||||
return this.resolveWslServiceDirectory(candidate, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS)
|
||||
?? (path.posix.isAbsolute(candidate) ? candidate : undefined)
|
||||
}
|
||||
|
||||
private async ownsHostDirectory(record: WorkspaceRecord, directory: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return isPathOwnedByWorktree({
|
||||
workspaceId: record.id,
|
||||
workspacePath: record.path,
|
||||
candidate,
|
||||
logger: this.options.logger,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToSharedService(signal?: AbortSignal): Promise<AsyncIterable<OpenCodeEvent>> {
|
||||
return this.sharedService.subscribe({ signal })
|
||||
}
|
||||
|
|
@ -366,6 +425,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 } },
|
||||
})
|
||||
|
||||
|
|
@ -393,7 +453,10 @@ export class WorkspaceManager {
|
|||
}
|
||||
}, timeoutMs)
|
||||
try {
|
||||
return await this.createResolvedWorkspace(record)
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
state.abortController.signal.addEventListener("abort", () => reject(state.abortController.signal.reason), { once: true })
|
||||
})
|
||||
return await Promise.race([this.createResolvedWorkspace(record, timeoutMs), deadline])
|
||||
} finally {
|
||||
if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout)
|
||||
}
|
||||
|
|
@ -417,41 +480,52 @@ export class WorkspaceManager {
|
|||
}
|
||||
private async createResolvedWorkspace(
|
||||
record: WorkspaceRecord,
|
||||
timeoutMs: number,
|
||||
): Promise<WorkspaceCreateResult> {
|
||||
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
|
||||
configuredEnvironment.CODENOMAD_SERVICE_CONTENDERS = SERVICE_CONTENDER_FILE
|
||||
mkdirSync(SERVICE_STATE_ROOT, { recursive: true })
|
||||
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,
|
||||
command: launch.command,
|
||||
environment: {
|
||||
...configuredEnvironment,
|
||||
...(launch.env?.WSLENV ? { WSLENV: launch.env.WSLENV } : {}),
|
||||
},
|
||||
}
|
||||
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 }
|
||||
// ponytail: fixed V2 storage root until database selection needs to be configurable.
|
||||
serviceEnvironment.OPENCODE_DB = OPENCODE_DATABASE
|
||||
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,
|
||||
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 }
|
||||
const [endpoint, headers, location] = await Promise.all([
|
||||
this.sharedService.endpoint(ensureOptions),
|
||||
record.wslDistro = launch.wslDistro
|
||||
const serviceDirectory = launch.wslDistro
|
||||
? this.requireWslServiceDirectory(workspacePath, launch.wslDistro, timeoutMs)
|
||||
: workspacePath
|
||||
record.location = { directory: serviceDirectory }
|
||||
const [headers, location] = await Promise.all([
|
||||
this.sharedService.headers(ensureOptions),
|
||||
this.sharedService.validateLocation(
|
||||
{ directory: workspacePath },
|
||||
{ directory: serviceDirectory },
|
||||
{ signal: state.abortController.signal },
|
||||
ensureOptions,
|
||||
),
|
||||
])
|
||||
this.serviceEndpoint = endpoint
|
||||
this.serviceAuthorization = headers?.authorization
|
||||
record.location = { directory: location.directory, workspaceID: location.workspaceID }
|
||||
this.throwIfCancelled(record)
|
||||
|
|
@ -573,15 +647,23 @@ export class WorkspaceManager {
|
|||
async shutdown() {
|
||||
this.shuttingDown = true
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
const shutdownTimeoutMs = Math.max(1, this.options.shutdownTimeoutMs ?? 10000)
|
||||
const deadlineAt = Date.now() + shutdownTimeoutMs
|
||||
const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id))
|
||||
const results = stopTasks.length
|
||||
? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown")
|
||||
? await this.withTimeout(Promise.allSettled(stopTasks), shutdownTimeoutMs, "shutdown")
|
||||
: []
|
||||
const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])
|
||||
if (this.workspaces.size === 0) {
|
||||
this.pendingWorkspaceCreations.clear()
|
||||
this.cancelledCreationRequests.clear()
|
||||
await this.sharedService.shutdown().catch((error) => stopFailures.push(error))
|
||||
const remaining = deadlineAt - Date.now()
|
||||
if (remaining <= 0) stopFailures.push(new WorkspaceCleanupTimeoutError("shared service shutdown", shutdownTimeoutMs))
|
||||
else await this.withTimeout(
|
||||
this.sharedService.shutdown({ timeoutMs: remaining }),
|
||||
remaining,
|
||||
"shared service shutdown",
|
||||
).catch((error) => stopFailures.push(error))
|
||||
} else if (!stopFailures.length) stopFailures.push(
|
||||
new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`),
|
||||
)
|
||||
|
|
@ -631,6 +713,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)
|
||||
|
|
|
|||
|
|
@ -1,87 +1,41 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"
|
||||
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"
|
||||
|
||||
import { OpenCodeSharedService } from "./opencode-service"
|
||||
import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service"
|
||||
import type { ProcessIdentity, ProcessIdentityProbe, ProcessNamespace } from "./process-identity"
|
||||
|
||||
describe("OpenCodeSharedService", () => {
|
||||
it("lazily ensures one authenticated service for concurrent callers", async () => {
|
||||
let ensureCalls = 0
|
||||
let makeCalls = 0
|
||||
const client = {
|
||||
location: { get: async () => ({
|
||||
directory: "/repo",
|
||||
workspaceID: "workspace-1",
|
||||
project: { id: "project-1", directory: "/repo", canonical: "/repo" },
|
||||
}) },
|
||||
} as unknown as OpenCodeClient
|
||||
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 () => undefined,
|
||||
ensure: async () => {
|
||||
ensureCalls += 1
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
return { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "user", password: "pass" } }
|
||||
},
|
||||
headers: () => ({ authorization: "Basic token" }),
|
||||
stop: async () => undefined,
|
||||
makeClient: (options) => {
|
||||
makeCalls += 1
|
||||
assert.equal(options.baseUrl, "http://127.0.0.1:4321")
|
||||
assert.deepEqual(options.headers, { authorization: "Basic token" })
|
||||
return client
|
||||
},
|
||||
discover: async () => endpoint,
|
||||
ensure: async () => endpoint,
|
||||
headers: () => undefined,
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
|
||||
assert.equal(ensureCalls, 0)
|
||||
const [endpoint, resolvedClient, location] = await Promise.all([
|
||||
service.endpoint(),
|
||||
service.client(),
|
||||
service.validateLocation({ directory: "/repo" }),
|
||||
])
|
||||
|
||||
assert.equal(endpoint.url, "http://127.0.0.1:4321")
|
||||
assert.strictEqual(resolvedClient, client)
|
||||
assert.equal(location.workspaceID, "workspace-1")
|
||||
assert.deepEqual([ensureCalls, makeCalls], [1, 1])
|
||||
await service.endpoint({ version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/one" } })
|
||||
await assert.rejects(
|
||||
service.endpoint({ version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/two" } }),
|
||||
/launch configuration/,
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the generated location, event, and eviction APIs", async () => {
|
||||
const calls: unknown[] = []
|
||||
const signal = new AbortController().signal
|
||||
const events = { async *[Symbol.asyncIterator]() { yield { type: "server.connected" } as never } }
|
||||
const client = {
|
||||
location: {
|
||||
get: async (...args: unknown[]) => {
|
||||
calls.push(["get", ...args])
|
||||
return { directory: "/repo", project: { id: "p", directory: "/repo", canonical: "/repo" } }
|
||||
},
|
||||
},
|
||||
event: { subscribe: (...args: unknown[]) => { calls.push(["subscribe", ...args]); return events } },
|
||||
debug: { location: { evict: async (...args: unknown[]) => { calls.push(["evict", ...args]) } } },
|
||||
} as unknown as OpenCodeClient
|
||||
it("validates a caller workspace selector against the canonical location", async () => {
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "https://localhost:4321" }),
|
||||
ensure: async () => ({ url: "http://127.0.0.1:4321" }),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => client,
|
||||
makeClient: () => ({ location: { get: async () => ({
|
||||
directory: "/repo", workspaceID: "canonical", project: { id: "p", directory: "/repo", canonical: "/repo" },
|
||||
}) } }) as unknown as OpenCodeClient,
|
||||
})
|
||||
|
||||
await service.validateLocation({ directory: "/repo", workspaceID: "ws" }, { signal })
|
||||
const subscribed = await service.subscribe({ signal })
|
||||
const iterator = subscribed[Symbol.asyncIterator]()
|
||||
assert.deepEqual(await iterator.next(), { value: { type: "server.connected" }, done: false })
|
||||
await iterator.return?.()
|
||||
await service.evict({ directory: "/repo", workspaceID: "ws" }, { signal })
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["get", { location: { directory: "/repo", workspace: "ws" } }, { signal }],
|
||||
["subscribe", { signal }],
|
||||
["evict", { location: { directory: "/repo", workspace: "ws" } }, { signal }],
|
||||
])
|
||||
await assert.rejects(service.validateLocation({ directory: "/repo", workspaceID: "foreign" }), /does not match/)
|
||||
})
|
||||
|
||||
it("rejects malformed endpoints and locations", async () => {
|
||||
|
|
@ -89,210 +43,471 @@ describe("OpenCodeSharedService", () => {
|
|||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "file:///tmp/opencode" }),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => { throw new Error("client should not be created") },
|
||||
})
|
||||
await assert.rejects(invalidEndpoint.endpoint(), /Unsupported OpenCode service protocol/)
|
||||
|
||||
const remoteEndpoint = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "http://192.0.2.1:4321" }),
|
||||
headers: () => undefined,
|
||||
makeClient: () => { throw new Error("client should not be created") },
|
||||
})
|
||||
await assert.rejects(remoteEndpoint.endpoint(), /must be loopback/)
|
||||
|
||||
const invalidLocation = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "http://localhost:4321" }),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => ({ location: { get: async () => ({ directory: "/repo" }) } }) as unknown as OpenCodeClient,
|
||||
})
|
||||
await assert.rejects(invalidLocation.validateLocation({ directory: "/repo" }), /invalid location/)
|
||||
})
|
||||
|
||||
it("clears a failed ensure so the next caller can retry", async () => {
|
||||
let calls = 0
|
||||
it("persists native PID proof through transfer and shutdown reconstruction", async () => {
|
||||
const state = await serviceState("codenomad-service-peer-")
|
||||
let stops = 0
|
||||
const owner = createOwnedService(state, async () => { stops += 1; return true })
|
||||
const peer = createOwnedService(
|
||||
state,
|
||||
async () => { stops += 1; return true },
|
||||
false,
|
||||
(pid) => pid !== state.info.pid,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
try {
|
||||
await owner.endpoint(state.options("owner", true))
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, true)
|
||||
await peer.endpoint(state.options("peer", false))
|
||||
await owner.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
assert.equal(JSON.parse(await readFile(state.lease("peer"), "utf8")).service.nativePid, true)
|
||||
assert.equal((await peer.endpoint()).url, state.info.url)
|
||||
await peer.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("reclaims a bounded stale lifecycle lock after PID reuse", async () => {
|
||||
const state = await serviceState("codenomad-service-stale-lock-")
|
||||
const stalePid = 7654321
|
||||
await mkdir(state.lockDirectory)
|
||||
await writeFile(path.join(state.lockDirectory, "owner.json"), JSON.stringify({
|
||||
version: 1,
|
||||
identity: "stale-lock-owner",
|
||||
pid: stalePid,
|
||||
processIdentity: processIdentity(stalePid, "previous-process"),
|
||||
createdAt: 1,
|
||||
}))
|
||||
await Promise.all([
|
||||
utimes(path.join(state.lockDirectory, "owner.json"), 1, 1),
|
||||
utimes(state.lockDirectory, 1, 1),
|
||||
])
|
||||
const service = createOwnedService(state, async () => true, true, () => true)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), staleLockMs: 1 })
|
||||
await assert.rejects(access(state.lockDirectory))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("prunes an identity-checked lease after PID reuse", async () => {
|
||||
const state = await serviceState("codenomad-service-stale-lease-")
|
||||
const stalePid = 7654321
|
||||
const staleLease = path.join(state.leases, "stale.json")
|
||||
await writeFile(staleLease, JSON.stringify({
|
||||
version: 1,
|
||||
identity: "stale-peer",
|
||||
pid: stalePid,
|
||||
processIdentity: processIdentity(stalePid, "previous-process"),
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
state: "active",
|
||||
}))
|
||||
let stops = 0
|
||||
const service = createOwnedService(state, async () => { stops += 1; return true }, true, () => true)
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
await assert.rejects(access(staleLease))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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",
|
||||
pid: 7654321,
|
||||
processIdentity: processIdentity(7654321, "dead-codenomad"),
|
||||
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({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error("not started")
|
||||
return { url: "http://localhost:4321" }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
isProcessAlive: () => true,
|
||||
getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(
|
||||
pid,
|
||||
pid === reusedPid ? "reused-service-process" : `identity-${pid}`,
|
||||
namespace,
|
||||
),
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
|
||||
await assert.rejects(service.endpoint(), /not started/)
|
||||
assert.equal((await service.endpoint()).url, "http://localhost:4321")
|
||||
assert.equal(calls, 2)
|
||||
try {
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
it("rediscovers after transport failure", async () => {
|
||||
let ensures = 0
|
||||
let gets = 0
|
||||
const endpoint = { url: "http://localhost:4321" }
|
||||
it("prunes old invalid lease artifacts while preserving fresh writes", async () => {
|
||||
const state = await serviceState("codenomad-service-invalid-leases-")
|
||||
const oldLease = state.lease("invalid")
|
||||
const legacyLease = state.lease("legacy")
|
||||
const oldTemporary = `${state.lease("peer")}.peer-id.tmp`
|
||||
const freshLease = state.lease("fresh")
|
||||
await writeFile(oldLease, "{")
|
||||
await writeFile(legacyLease, JSON.stringify({
|
||||
version: 1,
|
||||
identity: "legacy",
|
||||
pid: 1234,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
state: "active",
|
||||
}))
|
||||
await writeFile(oldTemporary, "partial")
|
||||
await writeFile(freshLease, "{")
|
||||
await Promise.all([utimes(oldLease, 1, 1), utimes(legacyLease, 1, 1), utimes(oldTemporary, 1, 1)])
|
||||
const service = createOwnedService(state, async () => true)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), staleLockMs: 60_000 })
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 20 }), /invalid identity metadata/)
|
||||
await Promise.all([
|
||||
assert.rejects(access(oldLease)),
|
||||
assert.rejects(access(legacyLease)),
|
||||
assert.rejects(access(oldTemporary)),
|
||||
])
|
||||
await access(freshLease)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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",
|
||||
pid: deadPid,
|
||||
processIdentity: processIdentity(deadPid),
|
||||
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(options)
|
||||
await peer.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
await assert.rejects(access(state.lease("dead-owner")))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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("stops the proven endpoint without following a registration swap", async () => {
|
||||
const state = await serviceState("codenomad-service-swap-")
|
||||
const replacement = { ...state.info, id: "replacement", pid: 9999, url: "http://127.0.0.1:9999" }
|
||||
let requestedId: string | undefined
|
||||
const service = createOwnedService(state, async (info: Info, endpoint: Endpoint) => {
|
||||
await writeFile(state.file, JSON.stringify(replacement))
|
||||
requestedId = info.id
|
||||
assert.equal(endpoint.url, state.info.url)
|
||||
return true
|
||||
})
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await service.shutdown()
|
||||
assert.equal(requestedId, state.info.id)
|
||||
assert.deepEqual(JSON.parse(await readFile(state.file, "utf8")), replacement)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("never owns a symlinked registration", { skip: process.platform === "win32" }, async () => {
|
||||
const state = await serviceState("codenomad-service-link-")
|
||||
const target = path.join(state.root, "target.json")
|
||||
await writeFile(target, JSON.stringify(state.info))
|
||||
await rm(state.file)
|
||||
await symlink(target, state.file)
|
||||
let stops = 0
|
||||
const service = createOwnedService(state, async () => { stops += 1; return true })
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a wrapper launch without a proven service PID before connecting", async () => {
|
||||
const state = await serviceState("codenomad-service-wrapper-")
|
||||
let launches = 0
|
||||
let discoveries = 0
|
||||
let stops = 0
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => { discoveries += 1; return undefined },
|
||||
ensure: async () => { launches += 1; return { url: state.info.url } },
|
||||
headers: () => undefined,
|
||||
requestStop: async () => { stops += 1; return true },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
try {
|
||||
await assert.rejects(service.endpoint({
|
||||
...state.options("wrapper", true),
|
||||
contenderFile: undefined,
|
||||
nativePid: false,
|
||||
}), /cannot prove the service PID/)
|
||||
await service.shutdown()
|
||||
assert.equal(launches, 0)
|
||||
assert.equal(discoveries, 0)
|
||||
assert.equal(stops, 0)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("checks WSL stop completion in the distro despite a coincidental live Windows PID", async () => {
|
||||
const state = await serviceState("codenomad-service-wsl-stop-")
|
||||
let healthChecks = 0
|
||||
let wslPidExists = true
|
||||
const server = (await import("node:http")).createServer((_request, response) => {
|
||||
healthChecks += 1
|
||||
if (healthChecks <= 2) {
|
||||
response.destroy()
|
||||
return
|
||||
}
|
||||
response.setHeader("content-type", "application/json")
|
||||
response.end(JSON.stringify({ healthy: true, version: "test", pid: state.info.pid }))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === "object")
|
||||
state.info.url = `http://127.0.0.1:${address.port}`
|
||||
await writeFile(state.file, JSON.stringify(state.info))
|
||||
const wslNamespace = { kind: "wsl", distro: "Ubuntu" } as const
|
||||
const service = createOwnedService(
|
||||
state,
|
||||
async () => true,
|
||||
true,
|
||||
() => true,
|
||||
undefined,
|
||||
true,
|
||||
async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
|
||||
async (pid, _timeoutMs, namespace) => wslPidExists
|
||||
? { status: "found", identity: processIdentity(pid, undefined, namespace) }
|
||||
: { status: "missing" },
|
||||
)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" })
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, false)
|
||||
assert.deepEqual(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.processIdentity.namespace, wslNamespace)
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 500 }), /did not exit|completion timed out/)
|
||||
assert.ok(healthChecks > 2)
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping")
|
||||
wslPidExists = false
|
||||
await service.shutdown({ timeoutMs: 100 })
|
||||
await assert.rejects(access(state.lease("owner")))
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("reports a failed stop and keeps an active retryable lease", async () => {
|
||||
const state = await serviceState("codenomad-service-stop-failure-")
|
||||
let stops = 0
|
||||
const service = createOwnedService(state, async () => { stops += 1; return stops > 1 })
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await assert.rejects(service.shutdown(), /failed the stop request/)
|
||||
const lease = JSON.parse(await readFile(state.lease("owner"), "utf8"))
|
||||
assert.equal(lease.state, "active")
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 2)
|
||||
await assert.rejects(access(state.lease("owner")))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("bounds stalled ensure and stop operations", async () => {
|
||||
const stalledEnsure = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => {
|
||||
ensures += 1
|
||||
return endpoint
|
||||
},
|
||||
ensure: async () => new Promise<never>(() => undefined),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => ({
|
||||
location: { get: async () => {
|
||||
gets += 1
|
||||
if (gets === 1) throw new TypeError("fetch failed")
|
||||
return { directory: "/repo", project: { id: "p", directory: "/repo", canonical: "/repo" } }
|
||||
} },
|
||||
}) as unknown as OpenCodeClient,
|
||||
})
|
||||
|
||||
await assert.rejects(service.validateLocation({ directory: "/repo" }), /fetch failed/)
|
||||
await service.validateLocation({ directory: "/repo" })
|
||||
assert.equal(ensures, 2)
|
||||
})
|
||||
|
||||
it("stops only when registration proves its contender won", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-"))
|
||||
const file = path.join(root, "service.json")
|
||||
const contenders = path.join(root, "contenders.txt")
|
||||
const previous = process.env.CODENOMAD_SERVICE_TEST
|
||||
const stopFiles: Array<string | undefined> = []
|
||||
const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" }
|
||||
const createService = (contenderPid: number) => new OpenCodeSharedService({
|
||||
discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }),
|
||||
ensure: async (options) => {
|
||||
assert.equal(process.env.CODENOMAD_SERVICE_TEST, "configured")
|
||||
options?.onStart?.("missing")
|
||||
await writeFile(contenders, `${contenderPid}\n`)
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async (options) => { stopFiles.push(options?.file) },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
|
||||
try {
|
||||
const lost = createService(9999)
|
||||
await lost.endpoint({ file, environment: {
|
||||
CODENOMAD_SERVICE_TEST: "configured",
|
||||
CODENOMAD_SERVICE_CONTENDERS: contenders,
|
||||
} })
|
||||
assert.equal(process.env.CODENOMAD_SERVICE_TEST, previous)
|
||||
await lost.shutdown()
|
||||
assert.deepEqual(stopFiles, [])
|
||||
|
||||
const won = createService(info.pid)
|
||||
await won.endpoint({ file, environment: {
|
||||
CODENOMAD_SERVICE_TEST: "configured",
|
||||
CODENOMAD_SERVICE_CONTENDERS: contenders,
|
||||
} })
|
||||
await won.shutdown()
|
||||
assert.deepEqual(stopFiles, [file])
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CODENOMAD_SERVICE_TEST
|
||||
else process.env.CODENOMAD_SERVICE_TEST = previous
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("retains possible ownership after discovery failure and retries shutdown", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-retry-"))
|
||||
const file = path.join(root, "service.json")
|
||||
const contenders = path.join(root, "contenders.txt")
|
||||
const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" }
|
||||
let stops = 0
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }),
|
||||
ensure: async (options) => {
|
||||
options?.onStart?.("missing")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
await writeFile(contenders, `${info.pid}\n`)
|
||||
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async () => { stops += 1 },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
await assert.rejects(stalledEnsure.endpoint({ timeoutMs: 10 }), /timed out after 10ms/)
|
||||
|
||||
try {
|
||||
await service.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: contenders } })
|
||||
await rm(file)
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
|
||||
await writeFile(file, JSON.stringify({ ...info, id: "replacement", pid: 5678 }))
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
await Promise.all([service.shutdown(), service.shutdown()])
|
||||
assert.equal(stops, 1)
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("lets only the CodeNomad whose contender won stop a concurrent service", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-election-"))
|
||||
const file = path.join(root, "service.json")
|
||||
const firstContenders = path.join(root, "first.txt")
|
||||
const secondContenders = path.join(root, "second.txt")
|
||||
const info = { id: "winner", url: "http://localhost:4321", pid: 2222, password: "secret" }
|
||||
const starts = deferred<void>()
|
||||
let ready = false
|
||||
const state = await serviceState("codenomad-service-stop-timeout-")
|
||||
let stops = 0
|
||||
const createService = (contenderFile: string, pid: number) => new OpenCodeSharedService({
|
||||
discover: async () => ready
|
||||
? { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
: undefined,
|
||||
ensure: async (options) => {
|
||||
options?.onStart?.("missing")
|
||||
await writeFile(contenderFile, `${pid}\n`)
|
||||
await starts.promise
|
||||
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async () => { stops += 1 },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
const first = createService(firstContenders, info.pid)
|
||||
const second = createService(secondContenders, 3333)
|
||||
|
||||
const stalledStop = createOwnedService(
|
||||
state,
|
||||
async () => { stops += 1; return new Promise<boolean>(() => undefined) },
|
||||
true,
|
||||
undefined,
|
||||
async () => false,
|
||||
)
|
||||
try {
|
||||
const connections = [
|
||||
first.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: firstContenders } }),
|
||||
second.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: secondContenders } }),
|
||||
]
|
||||
await Promise.all([readWhenPresent(firstContenders), readWhenPresent(secondContenders)])
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
ready = true
|
||||
starts.resolve()
|
||||
await Promise.all(connections)
|
||||
|
||||
await Promise.all([first.shutdown(), second.shutdown()])
|
||||
await stalledStop.endpoint(state.options("owner", true))
|
||||
await assert.rejects(stalledStop.shutdown({ timeoutMs: 10 }), /stop timed out/)
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping")
|
||||
await assert.rejects(stalledStop.shutdown({ timeoutMs: 10 }), /uncertain outcome/)
|
||||
assert.equal(stops, 1)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
|
||||
return { promise, resolve }
|
||||
async function serviceState(prefix: string) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const file = path.join(root, "service.json")
|
||||
const contenders = path.join(root, "contenders.txt")
|
||||
const leases = path.join(root, "leases")
|
||||
const lockDirectory = path.join(root, "stop.lock")
|
||||
const info = { id: "instance-1", url: "http://127.0.0.1:4321", pid: 1234, password: "secret" }
|
||||
await mkdir(leases)
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
await writeFile(contenders, `${info.pid}\n`)
|
||||
return {
|
||||
root, file, contenders, leases, lockDirectory, info,
|
||||
lease(name: string) { return path.join(leases, `${name}.json`) },
|
||||
options(name: string, started: boolean): OpenCodeEnsureOptions {
|
||||
return {
|
||||
file,
|
||||
contenderFile: contenders,
|
||||
leaseFile: path.join(leases, `${name}.json`),
|
||||
lockDirectory,
|
||||
onStart: started ? () => undefined : undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readWhenPresent(file: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
await access(file)
|
||||
return
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
function createOwnedService(
|
||||
state: Awaited<ReturnType<typeof serviceState>>,
|
||||
requestStop: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise<boolean>,
|
||||
announceStart = true,
|
||||
isProcessAlive?: (pid: number) => boolean,
|
||||
waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise<boolean>,
|
||||
useDefaultWaitForStop = false,
|
||||
getProcessIdentity: (
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace?: ProcessNamespace,
|
||||
) => Promise<ProcessIdentity | undefined> = async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
|
||||
probeProcessIdentity?: (
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace?: ProcessNamespace,
|
||||
) => Promise<ProcessIdentityProbe>,
|
||||
) {
|
||||
return new OpenCodeSharedService({
|
||||
discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }),
|
||||
ensure: async (options) => {
|
||||
if (announceStart) options?.onStart?.("missing")
|
||||
return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
requestStop,
|
||||
waitForStop: useDefaultWaitForStop ? undefined : waitForStop ?? (async () => true),
|
||||
isProcessAlive,
|
||||
getProcessIdentity,
|
||||
probeProcessIdentity,
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
}
|
||||
|
||||
function processIdentity(
|
||||
pid: number,
|
||||
start = `identity-${pid}`,
|
||||
namespace: ProcessNamespace = { kind: "host" },
|
||||
): 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")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
36
packages/server/src/workspaces/process-identity.test.ts
Normal file
36
packages/server/src/workspaces/process-identity.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
|
||||
import { probeProcessStartIdentity } from "./process-identity"
|
||||
|
||||
test("probes a service PID inside its WSL distro instead of the coincidental Windows PID", async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = []
|
||||
const probe = await probeProcessStartIdentity(4242, 100, { kind: "wsl", distro: "Ubuntu" }, async (command, args) => {
|
||||
calls.push({ command, args })
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "4242 (opencode) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20\nlinux-boot-id\n",
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(calls[0]?.command, "wsl.exe")
|
||||
assert.deepEqual(calls[0]?.args.slice(0, 2), ["--distribution", "Ubuntu"])
|
||||
assert.deepEqual(probe, {
|
||||
status: "found",
|
||||
identity: { namespace: { kind: "wsl", distro: "Ubuntu" }, pid: 4242, start: "linux-boot-id:98765" },
|
||||
})
|
||||
})
|
||||
|
||||
test("distinguishes a missing WSL PID from an unverified probe", async () => {
|
||||
const missing = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({
|
||||
code: 3,
|
||||
stdout: "",
|
||||
}))
|
||||
const unknown = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({
|
||||
code: null,
|
||||
stdout: "",
|
||||
}))
|
||||
|
||||
assert.deepEqual(missing, { status: "missing" })
|
||||
assert.deepEqual(unknown, { status: "unknown" })
|
||||
})
|
||||
102
packages/server/src/workspaces/process-identity.ts
Normal file
102
packages/server/src/workspaces/process-identity.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { execFile } from "node:child_process"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
export type ProcessNamespace = { kind: "host" } | { kind: "wsl"; distro: string }
|
||||
|
||||
export interface ProcessIdentity {
|
||||
namespace: ProcessNamespace
|
||||
pid: number
|
||||
start: string
|
||||
}
|
||||
|
||||
export type ProcessIdentityProbe =
|
||||
| { status: "found"; identity: ProcessIdentity }
|
||||
| { status: "missing" }
|
||||
| { status: "unknown" }
|
||||
|
||||
export type ProcessIdentityRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
) => Promise<{ code: number | null; stdout: string }>
|
||||
|
||||
const runCommand: ProcessIdentityRunner = (command, args, timeoutMs) => new Promise((resolve) => {
|
||||
execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => {
|
||||
resolve({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout })
|
||||
})
|
||||
})
|
||||
|
||||
export async function probeProcessStartIdentity(
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace: ProcessNamespace = { kind: "host" },
|
||||
runner: ProcessIdentityRunner = runCommand,
|
||||
): Promise<ProcessIdentityProbe> {
|
||||
if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return { status: "unknown" }
|
||||
if (namespace.kind === "wsl") {
|
||||
const result = await runner("wsl.exe", [
|
||||
"--distribution", namespace.distro,
|
||||
"--exec", "sh", "-c",
|
||||
'test -r "/proc/$1/stat" || exit 3; cat "/proc/$1/stat"; cat /proc/sys/kernel/random/boot_id',
|
||||
"codenomad-process-probe", String(pid),
|
||||
], timeoutMs).catch(() => ({ code: null, stdout: "" }))
|
||||
if (result.code === 3) return { status: "missing" }
|
||||
const [stat, bootId] = result.stdout.trim().split(/\r?\n/)
|
||||
const start = result.code === 0 && stat && bootId ? linuxStart(stat, bootId) : undefined
|
||||
return start
|
||||
? { status: "found", identity: { namespace, pid, start } }
|
||||
: { status: "unknown" }
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const signal = AbortSignal.timeout(timeoutMs)
|
||||
const stat = await readFile(`/proc/${pid}/stat`, { encoding: "utf8", signal })
|
||||
const bootId = (await readFile("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim()
|
||||
const start = linuxStart(stat, bootId)
|
||||
return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" }
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return commandProbe(runner, "ps", ["-p", String(pid), "-o", "lstart="], namespace, pid, timeoutMs)
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return commandProbe(runner, "powershell.exe", [
|
||||
"-NoProfile", "-NonInteractive", "-Command",
|
||||
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop; if (!$p) { exit 3 }; $p.CreationDate.ToUniversalTime().Ticks`,
|
||||
], namespace, pid, timeoutMs)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }
|
||||
}
|
||||
return { status: "unknown" }
|
||||
}
|
||||
|
||||
export async function getProcessStartIdentity(
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace: ProcessNamespace = { kind: "host" },
|
||||
runner?: ProcessIdentityRunner,
|
||||
): Promise<ProcessIdentity | undefined> {
|
||||
const probe = await probeProcessStartIdentity(pid, timeoutMs, namespace, runner)
|
||||
return probe.status === "found" ? probe.identity : undefined
|
||||
}
|
||||
|
||||
function linuxStart(stat: string, bootId: string): string | undefined {
|
||||
const commandEnd = stat.lastIndexOf(")")
|
||||
const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19]
|
||||
return startTicks && bootId ? `${bootId}:${startTicks}` : undefined
|
||||
}
|
||||
|
||||
async function commandProbe(
|
||||
runner: ProcessIdentityRunner,
|
||||
command: string,
|
||||
args: string[],
|
||||
namespace: ProcessNamespace,
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
): Promise<ProcessIdentityProbe> {
|
||||
const result = await runner(command, args, timeoutMs).catch(() => ({ code: null, stdout: "" }))
|
||||
if (result.code === 3) return { status: "missing" }
|
||||
const start = result.code === 0 ? result.stdout.trim() : ""
|
||||
return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" }
|
||||
}
|
||||
|
|
@ -27,85 +27,73 @@ function session(id: string, parentID?: string, directory = ROOT): SessionInfo {
|
|||
function clientHarness(initial: SessionInfo[], options: {
|
||||
active?: string[]
|
||||
failMove?: (sessionId: string, call: number) => boolean
|
||||
moveGate?: (sessionId: string) => Promise<void>
|
||||
} = {}) {
|
||||
const sessions = new Map(initial.map((value) => [value.id, structuredClone(value)]))
|
||||
const moveCalls: string[] = []
|
||||
const listCalls: Array<{ cursor?: string; project?: string; order?: string }> = []
|
||||
let moveCall = 0
|
||||
const client = {
|
||||
location: {
|
||||
get: async ({ location: value }: { location?: { directory?: string } }) => ({
|
||||
directory: value?.directory ?? ROOT,
|
||||
get: async ({ location }: { location?: { directory?: string } }) => ({
|
||||
directory: location?.directory ?? ROOT,
|
||||
project: { id: "project", directory: ROOT, canonical: ROOT },
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
list: async (input: { cursor?: string; project?: string; order?: string }) => {
|
||||
listCalls.push(input)
|
||||
return { data: Array.from(sessions.values()).map((value) => structuredClone(value)), cursor: {} }
|
||||
},
|
||||
list: async () => ({ data: Array.from(sessions.values()).map((value) => structuredClone(value)), cursor: {} }),
|
||||
active: async () => Object.fromEntries((options.active ?? []).map((id) => [id, { type: "running" as const }])),
|
||||
get: async ({ sessionID }: { sessionID: string }) => structuredClone(sessions.get(sessionID)!),
|
||||
move: async ({ sessionID, directory, workspaceID }: { sessionID: string; directory: string; workspaceID?: string }) => {
|
||||
moveCall += 1
|
||||
moveCalls.push(sessionID)
|
||||
if (options.failMove?.(sessionID, moveCall)) throw new Error(`move failed: ${sessionID}`)
|
||||
await options.moveGate?.(sessionID)
|
||||
sessions.get(sessionID)!.location = { directory, workspaceID }
|
||||
},
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
return { client, sessions, moveCalls, listCalls }
|
||||
return { client, sessions, moveCalls }
|
||||
}
|
||||
|
||||
describe("project session families", () => {
|
||||
it("loads every cursor page and rejects repeated cursors", async () => {
|
||||
const first = session("root")
|
||||
const second = session("child", "root")
|
||||
let call = 0
|
||||
const paged = {
|
||||
it("loads the complete paginated project inventory", async () => {
|
||||
const calls: Array<{ project?: string; cursor?: string }> = []
|
||||
const client = {
|
||||
session: {
|
||||
list: async () => ++call === 1
|
||||
? { data: [first], cursor: { next: "next" } }
|
||||
: { data: [second], cursor: {} },
|
||||
list: async (input: { project?: string; cursor?: string }) => {
|
||||
calls.push(input)
|
||||
return input.cursor
|
||||
? { data: [session("child", "root")], cursor: {} }
|
||||
: { data: [session("root")], cursor: { next: "next" } }
|
||||
},
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
assert.deepEqual((await listCompleteProjectSessions(paged, "project")).map(({ id }) => id), ["root", "child"])
|
||||
assert.equal(call, 2)
|
||||
|
||||
const repeated = {
|
||||
session: { list: async () => ({ data: [], cursor: { next: "same" } }) },
|
||||
} as unknown as OpenCodeClient
|
||||
await assert.rejects(() => listCompleteProjectSessions(repeated, "project"), /repeated cursor/)
|
||||
assert.deepEqual((await listCompleteProjectSessions(client, "project")).map(({ id }) => id), ["root", "child"])
|
||||
assert.ok(calls.every(({ project }) => project === "project"))
|
||||
assert.equal(calls[1]?.cursor, "next")
|
||||
})
|
||||
|
||||
it("resolves complete root and descendant families and fails closed on missing parents and cycles", () => {
|
||||
it("resolves complete families and rejects incomplete ancestry", () => {
|
||||
assert.deepEqual(
|
||||
Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).entries())
|
||||
.map(([root, members]) => [root, members.map(({ id }) => id)]),
|
||||
[["root", ["root", "child"]]],
|
||||
Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).values())
|
||||
.map((family) => family.map(({ id }) => id)),
|
||||
[["root", "child"]],
|
||||
)
|
||||
assert.throws(() => resolveSessionFamilies([session("child", "missing")]), /missing parent/)
|
||||
assert.throws(() => resolveSessionFamilies([session("a", "b"), session("b", "a")]), /cycle/)
|
||||
})
|
||||
|
||||
it("moves root and descendants sequentially and verifies authoritative locations", async () => {
|
||||
const harness = clientHarness([session("child", "root"), session("root")])
|
||||
const result = await moveProjectSessionFamily({
|
||||
it("moves a complete family to the target", async () => {
|
||||
const harness = clientHarness([session("root"), session("child", "root")])
|
||||
const moved = await moveProjectSessionFamily({
|
||||
client: harness.client,
|
||||
projectDirectory: ROOT,
|
||||
sessionId: "child",
|
||||
targetDirectory: WORKTREE,
|
||||
})
|
||||
assert.deepEqual(result, { rootSessionId: "root", sessionIds: ["root", "child"] })
|
||||
assert.ok(harness.listCalls.every(({ order }) => order === "asc"))
|
||||
assert.deepEqual(harness.moveCalls, ["root", "child"])
|
||||
assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE)
|
||||
assert.equal(harness.sessions.get("child")?.location.directory, WORKTREE)
|
||||
assert.deepEqual(moved.sessionIds, ["root", "child"])
|
||||
assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === WORKTREE))
|
||||
})
|
||||
|
||||
it("refreshes and rolls back after a partial move failure", async () => {
|
||||
it("rolls back transaction-owned moves after partial failure", async () => {
|
||||
const harness = clientHarness([session("root"), session("child", "root")], {
|
||||
failMove: (id, call) => id === "child" && call === 2,
|
||||
})
|
||||
|
|
@ -117,34 +105,22 @@ describe("project session families", () => {
|
|||
}), /move failed/)
|
||||
assert.deepEqual(harness.moveCalls, ["root", "child", "root"])
|
||||
assert.equal(harness.sessions.get("root")?.location.directory, ROOT)
|
||||
assert.ok(harness.listCalls.length >= 2)
|
||||
})
|
||||
|
||||
it("serializes concurrent operations for the same project", async () => {
|
||||
let release!: () => void
|
||||
let firstMoveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { firstMoveStarted = resolve })
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let held = true
|
||||
const harness = clientHarness([session("root")], {
|
||||
moveGate: async () => {
|
||||
if (!held) return
|
||||
firstMoveStarted()
|
||||
await gate
|
||||
held = false
|
||||
},
|
||||
})
|
||||
const first = moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: WORKTREE })
|
||||
await started
|
||||
const second = moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: ROOT })
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
assert.deepEqual(harness.moveCalls, ["root"])
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
assert.deepEqual(harness.moveCalls, ["root", "root"])
|
||||
it("blocks deletion while an attached session is active", async () => {
|
||||
const harness = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] })
|
||||
await assert.rejects(() => removeProjectWorktree({
|
||||
client: harness.client,
|
||||
projectDirectory: ROOT,
|
||||
targetDirectory: WORKTREE,
|
||||
rootDirectory: ROOT,
|
||||
remove: async () => assert.fail("Git removal must not run"),
|
||||
isTargetRegistered: async () => true,
|
||||
}), (error: unknown) => error instanceof ProjectSessionError && error.statusCode === 409)
|
||||
assert.deepEqual(harness.moveCalls, [])
|
||||
})
|
||||
|
||||
it("evacuates attached families before deletion and blocks active sessions", async () => {
|
||||
it("evacuates a complete family before removing its worktree", async () => {
|
||||
const harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)])
|
||||
let removed = false
|
||||
await removeProjectWorktree({
|
||||
|
|
@ -156,31 +132,32 @@ describe("project session families", () => {
|
|||
isTargetRegistered: async () => true,
|
||||
})
|
||||
assert.equal(removed, true)
|
||||
assert.deepEqual(harness.moveCalls, ["root", "child"])
|
||||
|
||||
const active = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] })
|
||||
await assert.rejects(() => removeProjectWorktree({
|
||||
client: active.client,
|
||||
projectDirectory: ROOT,
|
||||
targetDirectory: WORKTREE,
|
||||
rootDirectory: ROOT,
|
||||
remove: async () => assert.fail("Git removal must not run"),
|
||||
isTargetRegistered: async () => true,
|
||||
}), (error: unknown) => error instanceof ProjectSessionError && error.statusCode === 409)
|
||||
assert.deepEqual(active.moveCalls, [])
|
||||
assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === ROOT))
|
||||
})
|
||||
|
||||
it("rolls sessions back when Git removal fails while the worktree remains registered", async () => {
|
||||
const harness = clientHarness([session("root", undefined, WORKTREE)])
|
||||
it("rolls back only while the original worktree identity remains", async () => {
|
||||
const original = clientHarness([session("original", undefined, WORKTREE)])
|
||||
await assert.rejects(() => removeProjectWorktree({
|
||||
client: harness.client,
|
||||
client: original.client,
|
||||
projectDirectory: ROOT,
|
||||
targetDirectory: WORKTREE,
|
||||
rootDirectory: ROOT,
|
||||
remove: async () => { throw new ProjectSessionError("dirty worktree", 409) },
|
||||
isTargetRegistered: async () => true,
|
||||
}), /dirty worktree/)
|
||||
assert.deepEqual(harness.moveCalls, ["root", "root"])
|
||||
assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE)
|
||||
assert.equal(original.sessions.get("original")?.location.directory, WORKTREE)
|
||||
|
||||
const replacement = clientHarness([session("replacement", undefined, WORKTREE)])
|
||||
let identityChecks = 0
|
||||
await assert.rejects(() => removeProjectWorktree({
|
||||
client: replacement.client,
|
||||
projectDirectory: ROOT,
|
||||
targetDirectory: WORKTREE,
|
||||
rootDirectory: ROOT,
|
||||
remove: async () => { throw new ProjectSessionError("worktree changed", 409) },
|
||||
isTargetRegistered: async () => ++identityChecks === 1,
|
||||
}), /worktree changed/)
|
||||
assert.deepEqual(replacement.moveCalls, ["replacement"])
|
||||
assert.equal(replacement.sessions.get("replacement")?.location.directory, ROOT)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,14 +27,12 @@ export async function listCompleteProjectSessions(
|
|||
projectID: string,
|
||||
): Promise<SessionInfo[]> {
|
||||
const sessions: SessionInfo[] = []
|
||||
const sessionIds = new Set<string>()
|
||||
const cursors = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
let page = 0
|
||||
|
||||
do {
|
||||
if (++page > MAX_SESSION_PAGES) throw new ProjectSessionError("Session inventory exceeded the page limit", 502)
|
||||
const response = await client.session.list({ project: projectID, limit: SESSION_PAGE_LIMIT, order: "asc", cursor })
|
||||
const response = await client.session.list({ project: projectID, limit: SESSION_PAGE_LIMIT, cursor })
|
||||
if (!response || !Array.isArray(response.data) || !response.cursor || typeof response.cursor !== "object") {
|
||||
throw new ProjectSessionError("OpenCode returned an invalid session inventory", 502)
|
||||
}
|
||||
|
|
@ -42,17 +40,11 @@ export async function listCompleteProjectSessions(
|
|||
if (!session?.id || session.projectID !== projectID || !session.location?.directory) {
|
||||
throw new ProjectSessionError("OpenCode returned a session outside the requested project", 409)
|
||||
}
|
||||
if (sessionIds.has(session.id)) {
|
||||
throw new ProjectSessionError(`Session inventory contains duplicate session: ${session.id}`, 409)
|
||||
}
|
||||
sessionIds.add(session.id)
|
||||
sessions.push(session)
|
||||
}
|
||||
|
||||
const next = response.cursor.next || undefined
|
||||
if (next && cursors.has(next)) {
|
||||
throw new ProjectSessionError(`Session inventory repeated cursor: ${next}`, 502)
|
||||
}
|
||||
if (next && cursors.has(next)) throw new ProjectSessionError(`Session inventory repeated cursor: ${next}`, 502)
|
||||
if (next) cursors.add(next)
|
||||
cursor = next
|
||||
} while (cursor)
|
||||
|
|
@ -114,6 +106,9 @@ export async function moveProjectSessionFamily(params: {
|
|||
const family = Array.from(families.entries()).find(([, members]) => members.some(({ id }) => id === params.sessionId))
|
||||
if (!family) throw new ProjectSessionError("Session not found in project", 404)
|
||||
await assertInactive(context.client, family[1])
|
||||
if (params.validateTarget && !await params.validateTarget()) {
|
||||
throw new ProjectSessionError("Worktree changed before the session move", 409)
|
||||
}
|
||||
const target = await resolveProjectLocation(context, params.targetDirectory)
|
||||
await moveWithRollback(context, family[1], target)
|
||||
return { rootSessionId: family[0], sessionIds: family[1].map(({ id }) => id) }
|
||||
|
|
@ -142,8 +137,8 @@ export async function removeProjectWorktree(params: {
|
|||
|
||||
try {
|
||||
if (families.length) {
|
||||
root = await resolveProjectLocation(context, params.rootDirectory)
|
||||
const destination = root
|
||||
const destination = await resolveProjectLocation(context, params.rootDirectory)
|
||||
root = destination
|
||||
for (const family of families) await moveMembers(context, family, destination, moved)
|
||||
await verifyInventory(context, moved, new Map(moved.map((id) => [id, destination])))
|
||||
const refreshed = await listCompleteProjectSessions(context.client, context.project.id)
|
||||
|
|
@ -151,9 +146,6 @@ export async function removeProjectWorktree(params: {
|
|||
throw new ProjectSessionError("Sessions remain attached to the worktree after evacuation", 409)
|
||||
}
|
||||
}
|
||||
if (!await params.isTargetRegistered()) {
|
||||
throw new ProjectSessionError("Worktree changed before deletion", 409)
|
||||
}
|
||||
await params.remove()
|
||||
} catch (error) {
|
||||
const changed = root ? await refreshChangedSessionIds(context, moved, root) : []
|
||||
|
|
@ -167,6 +159,7 @@ export async function removeProjectWorktree(params: {
|
|||
500,
|
||||
)
|
||||
}
|
||||
// A mismatched identity may now be a replacement checkout; never move sessions into it.
|
||||
if (registered) await rollback(context, changed, original, error)
|
||||
}
|
||||
throw asProjectError(error, "Unable to remove worktree")
|
||||
|
|
|
|||
106
packages/server/src/workspaces/service-state.ts
Normal file
106
packages/server/src/workspaces/service-state.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { constants, closeSync, chmodSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs"
|
||||
import { open } from "node:fs/promises"
|
||||
import { isIP } from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { Info } from "@opencode-ai/client/service"
|
||||
|
||||
const CODENOMAD_HOME = path.join(os.homedir(), ".codenomad")
|
||||
const CODENOMAD_STATE = path.join(CODENOMAD_HOME, "state")
|
||||
export const SERVICE_STATE_ROOT = path.join(CODENOMAD_STATE, "opencode-v2")
|
||||
export const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json")
|
||||
export const SERVICE_LEASE_DIRECTORY = path.join(SERVICE_STATE_ROOT, "leases")
|
||||
export const SERVICE_STOP_LOCK = path.join(SERVICE_STATE_ROOT, "stop.lock")
|
||||
|
||||
const preparedFiles = new Set<string>()
|
||||
|
||||
export function prepareServiceState(contenderFile: string): void {
|
||||
ensurePrivateDirectory(CODENOMAD_HOME)
|
||||
ensurePrivateDirectory(CODENOMAD_STATE)
|
||||
ensurePrivateDirectory(SERVICE_STATE_ROOT)
|
||||
ensurePrivateDirectory(path.dirname(SERVICE_REGISTRATION_FILE))
|
||||
ensurePrivateDirectory(SERVICE_LEASE_DIRECTORY)
|
||||
validateRegistrationFile(SERVICE_REGISTRATION_FILE)
|
||||
if (preparedFiles.has(contenderFile)) return
|
||||
closeSync(openSync(contenderFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600))
|
||||
preparedFiles.add(contenderFile)
|
||||
}
|
||||
|
||||
export async function readSecureServiceInfo(file: string | undefined): Promise<Info | undefined> {
|
||||
if (!file) return undefined
|
||||
let handle
|
||||
try {
|
||||
const before = lstatSync(file)
|
||||
if (!before.isFile() || before.isSymbolicLink()) return undefined
|
||||
const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW
|
||||
handle = await open(file, flags)
|
||||
const after = await handle.stat()
|
||||
if (process.platform !== "win32" && (before.dev !== after.dev || before.ino !== after.ino)) return undefined
|
||||
return parseInfo(await handle.readFile("utf8"))
|
||||
} catch {
|
||||
return undefined
|
||||
} finally {
|
||||
await handle?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLoopbackServiceUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Unsupported OpenCode service protocol: ${url.protocol}`)
|
||||
}
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
||||
const ipVersion = isIP(hostname)
|
||||
const loopback = hostname === "localhost"
|
||||
|| (ipVersion === 4 && hostname.startsWith("127."))
|
||||
|| (ipVersion === 6 && (hostname === "::1" || hostname.startsWith("::ffff:127.")))
|
||||
if (!loopback) throw new Error(`OpenCode service endpoint must be loopback: ${url.hostname}`)
|
||||
return url
|
||||
}
|
||||
|
||||
function ensurePrivateDirectory(directory: string): void {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
const stat = lstatSync(directory)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service state directory: ${directory}`)
|
||||
if (process.platform === "win32") return
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
throw new Error(`OpenCode service state directory is owned by another user: ${directory}`)
|
||||
}
|
||||
if ((stat.mode & 0o077) !== 0) chmodSync(directory, 0o700)
|
||||
}
|
||||
|
||||
function validateRegistrationFile(file: string): void {
|
||||
let stat
|
||||
try {
|
||||
stat = lstatSync(file)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
|
||||
throw error
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service registration file: ${file}`)
|
||||
const text = readFileSync(file, "utf8")
|
||||
let value: unknown
|
||||
try { value = JSON.parse(text) } catch { return }
|
||||
if (typeof value === "object" && value !== null && "url" in value && typeof value.url === "string") {
|
||||
assertLoopbackServiceUrl(value.url)
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfo(text: string): Info | undefined {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return undefined
|
||||
if (!("url" in value) || typeof value.url !== "string") return undefined
|
||||
if (!("id" in value) || typeof value.id !== "string" || !value.id) return undefined
|
||||
if (!("pid" in value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined
|
||||
try {
|
||||
assertLoopbackServiceUrl(value.url)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return value as Info
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawnSync } from "child_process"
|
||||
import { statSync } from "fs"
|
||||
import { readFileSync, statSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
|
|
@ -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([
|
||||
|
|
@ -41,6 +41,10 @@ export interface SpawnSpec {
|
|||
export interface ServiceLaunchSpec {
|
||||
command: string[]
|
||||
env?: NodeJS.ProcessEnv
|
||||
nativePid: boolean
|
||||
wslDistro?: string
|
||||
launcherRecordsPid?: boolean
|
||||
windowsVerbatimArguments?: boolean
|
||||
}
|
||||
|
||||
interface BuildSpawnSpecOptions {
|
||||
|
|
@ -93,7 +97,8 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option
|
|||
return buildWslSpawnSpec(wslPath, args, options)
|
||||
}
|
||||
|
||||
const resolvedBinaryPath = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath
|
||||
const resolvedCommand = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath
|
||||
const resolvedBinaryPath = resolveWindowsNpmExecutable(resolvedCommand) ?? resolvedCommand
|
||||
const extension = path.win32.extname(resolvedBinaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
|
|
@ -151,43 +156,81 @@ 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[],
|
||||
options: BuildSpawnSpecOptions = {},
|
||||
): ServiceLaunchSpec {
|
||||
const spec = buildSpawnSpec(binaryPath, args, options)
|
||||
if (spec.processKind === "wsl") {
|
||||
return { command: [spec.command, ...spec.args], env: spec.env }
|
||||
const direct = spec.processKind === "posix" || spec.processKind === "windows-direct"
|
||||
if (direct && options.contenderFile) {
|
||||
const launcher = [
|
||||
'const { spawn } = require("node:child_process")',
|
||||
'const { appendFileSync } = require("node:fs")',
|
||||
'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: process.argv[4] === "true" })',
|
||||
'child.once("error", (error) => { console.error(error); process.exitCode = 1 })',
|
||||
'if (child.pid) { appendFileSync(process.argv[3], `${child.pid}\\n`); child.unref() }',
|
||||
].join(";")
|
||||
return {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
launcher,
|
||||
spec.command,
|
||||
JSON.stringify(spec.args),
|
||||
options.contenderFile,
|
||||
String(Boolean(spec.options.windowsVerbatimArguments)),
|
||||
],
|
||||
env: spec.env,
|
||||
nativePid: true,
|
||||
launcherRecordsPid: true,
|
||||
}
|
||||
}
|
||||
const contenderFile = spec.processKind === "posix" || spec.processKind === "windows-direct"
|
||||
? options.contenderFile
|
||||
: undefined
|
||||
if (!spec.options.windowsVerbatimArguments && !contenderFile) {
|
||||
return { command: [spec.command, ...spec.args], env: spec.env }
|
||||
}
|
||||
|
||||
// Service.ensure cannot pass spawn options or expose contender PIDs. A Node
|
||||
// trampoline supplies both for commands whose child PID is the service PID.
|
||||
const launcher = [
|
||||
'const { spawn } = require("node:child_process")',
|
||||
'const { appendFileSync } = require("node:fs")',
|
||||
'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { stdio: "inherit", windowsVerbatimArguments: process.argv[4] === "true" })',
|
||||
'if (process.argv[3]) appendFileSync(process.argv[3], `${child.pid}\\n`)',
|
||||
'child.once("error", (error) => { console.error(error); process.exit(1) })',
|
||||
'child.once("exit", (code) => process.exit(code ?? 1))',
|
||||
].join(";")
|
||||
return {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
launcher,
|
||||
spec.command,
|
||||
JSON.stringify(spec.args),
|
||||
contenderFile ?? "",
|
||||
String(Boolean(spec.options.windowsVerbatimArguments)),
|
||||
],
|
||||
command: [spec.command, ...spec.args],
|
||||
env: spec.env,
|
||||
nativePid: direct,
|
||||
wslDistro: spec.wsl?.distro,
|
||||
launcherRecordsPid: spec.processKind === "wsl" && Boolean(options.contenderFile),
|
||||
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +417,20 @@ function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined,
|
|||
return steps.join(" && ")
|
||||
}
|
||||
|
||||
function resolveWindowsNpmExecutable(command: string): string | null {
|
||||
if (!WINDOWS_CMD_EXTENSIONS.has(path.win32.extname(command).toLowerCase())) return null
|
||||
try {
|
||||
const script = readFileSync(command, "utf8")
|
||||
if (script.length > 64 * 1024) return null
|
||||
const match = script.match(/["'](?:%~dp0|%dp0%)[\\/]([^"'\r\n]+\.exe)["']\s+%\*/i)
|
||||
if (!match?.[1]) return null
|
||||
const executable = path.win32.resolve(path.win32.dirname(command), match[1])
|
||||
return statSync(executable).isFile() ? executable : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(input: string): string | null {
|
||||
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||
if (!normalized) {
|
||||
|
|
@ -405,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { realpath } from "fs/promises"
|
||||
import path from "node:path"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
|
||||
|
||||
|
|
@ -101,3 +102,22 @@ export async function resolveWorktreeSlugForDirectory(params: {
|
|||
})
|
||||
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
|
||||
}
|
||||
|
||||
export async function isPathOwnedByWorktree(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
candidate: string
|
||||
logger?: LogLike
|
||||
}): Promise<boolean> {
|
||||
let target: string
|
||||
try {
|
||||
target = await realpath(params.candidate)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const cached = await getCachedWorktrees(params)
|
||||
return cached.worktrees.some((worktree) => {
|
||||
const relative = path.relative(worktree.normalizedDirectory, target)
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
51
packages/tauri-app/Cargo.lock
generated
51
packages/tauri-app/Cargo.lock
generated
|
|
@ -497,7 +497,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -1014,15 +1014,6 @@ version = "1.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
|
|
@ -1649,25 +1640,6 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
|
|
@ -1793,7 +1765,6 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
|
|
@ -3403,11 +3374,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
|
@ -3416,7 +3385,6 @@ dependencies = [
|
|||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
@ -3428,14 +3396,12 @@ dependencies = [
|
|||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
|
@ -3470,7 +3436,7 @@ dependencies = [
|
|||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
|
@ -5231,19 +5197,6 @@ dependencies = [
|
|||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ serde_json = "1"
|
|||
serde_yaml = "0.9"
|
||||
base64 = "0.22"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "http2", "charset", "json", "stream", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
regex = "1"
|
||||
parking_lot = "0.12"
|
||||
anyhow = "1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||
"identifier": "remote-window-notifications",
|
||||
"description": "Grant remote CodeNomad windows access only to native OS notifications.",
|
||||
"local": false,
|
||||
"remote": {
|
||||
"urls": ["http://*:*", "https://*:*"]
|
||||
},
|
||||
"windows": ["remote-*"],
|
||||
"permissions": [
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]},"remote-window-notifications":{"identifier":"remote-window-notifications","description":"Grant remote CodeNomad windows access only to native OS notifications.","remote":{"urls":["http://*:*","https://*:*"]},"local":false,"windows":["remote-*"],"permissions":["notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]}}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
use crate::desktop_event_transport::DesktopEventStreamConfig;
|
||||
use crate::managed_node::resolve_bundled_node_binary;
|
||||
use dirs::home_dir;
|
||||
use parking_lot::Mutex;
|
||||
|
|
@ -480,6 +479,21 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn is_loopback_http_url(base_url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(base_url) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != "http" || !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return false;
|
||||
}
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
||||
Some(url::Host::Ipv4(host)) => host.is_loopback(),
|
||||
Some(url::Host::Ipv6(host)) => host.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
|
|
@ -565,15 +579,6 @@ fn generate_auth_cookie_name() -> String {
|
|||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
fn generate_transport_connection_id() -> String {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let tid = std::thread::current().id();
|
||||
format!("tauri-{}-{:?}", ts, tid)
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -712,6 +717,13 @@ pub struct CliStatus {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LocalCliAccess {
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) cookie_name: String,
|
||||
pub(crate) session_cookie: String,
|
||||
}
|
||||
|
||||
impl Default for CliStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -731,8 +743,7 @@ pub struct CliProcessManager {
|
|||
#[cfg(windows)]
|
||||
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
session_cookie: Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: Arc<Mutex<Option<String>>>,
|
||||
local_access: Arc<Mutex<Option<LocalCliAccess>>>,
|
||||
lifecycle: Arc<Mutex<()>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
|
@ -745,8 +756,7 @@ impl CliProcessManager {
|
|||
#[cfg(windows)]
|
||||
job: Arc::new(Mutex::new(None)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
session_cookie: Arc::new(Mutex::new(None)),
|
||||
auth_cookie_name: Arc::new(Mutex::new(None)),
|
||||
local_access: Arc::new(Mutex::new(None)),
|
||||
lifecycle: Arc::new(Mutex::new(())),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
|
|
@ -755,11 +765,10 @@ impl CliProcessManager {
|
|||
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
||||
let _lifecycle = self.lifecycle.lock();
|
||||
let generation = self.advance_generation();
|
||||
*self.bootstrap_token.lock() = None;
|
||||
*self.local_access.lock() = None;
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop_tracked_child()?;
|
||||
*self.bootstrap_token.lock() = None;
|
||||
*self.session_cookie.lock() = None;
|
||||
*self.auth_cookie_name.lock() = None;
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
|
|
@ -784,6 +793,8 @@ impl CliProcessManager {
|
|||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let _lifecycle = self.lifecycle.lock();
|
||||
self.advance_generation();
|
||||
*self.bootstrap_token.lock() = None;
|
||||
*self.local_access.lock() = None;
|
||||
self.stop_tracked_child()?;
|
||||
self.reset_stopped_status();
|
||||
Ok(())
|
||||
|
|
@ -855,11 +866,12 @@ impl CliProcessManager {
|
|||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
*self.session_cookie.lock() = None;
|
||||
*self.local_access.lock() = None;
|
||||
}
|
||||
|
||||
fn publish_error(&self, app: &AppHandle, generation: u64, message: String) {
|
||||
self.with_current_generation(generation, || {
|
||||
*self.local_access.lock() = None;
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Error;
|
||||
status.error = Some(message.clone());
|
||||
|
|
@ -874,24 +886,12 @@ impl CliProcessManager {
|
|||
self.status.lock().clone()
|
||||
}
|
||||
|
||||
pub fn desktop_event_stream_config(&self) -> Option<DesktopEventStreamConfig> {
|
||||
let base_url = self.status.lock().url.clone()?;
|
||||
let events_url = format!("{}/api/events", base_url.trim_end_matches('/'));
|
||||
let client_id = format!("tauri-{}", std::process::id());
|
||||
let cookie_name = self
|
||||
.auth_cookie_name
|
||||
.lock()
|
||||
.clone()
|
||||
.unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string());
|
||||
|
||||
Some(DesktopEventStreamConfig {
|
||||
base_url,
|
||||
events_url,
|
||||
client_id,
|
||||
connection_id: generate_transport_connection_id(),
|
||||
cookie_name,
|
||||
session_cookie: self.session_cookie.lock().clone(),
|
||||
})
|
||||
pub(crate) fn local_cli_access(&self) -> Option<LocalCliAccess> {
|
||||
let _lifecycle = self.lifecycle.lock();
|
||||
if self.status.lock().state != CliState::Ready {
|
||||
return None;
|
||||
}
|
||||
self.local_access.lock().clone()
|
||||
}
|
||||
|
||||
fn spawn_cli(
|
||||
|
|
@ -1037,7 +1037,6 @@ impl CliProcessManager {
|
|||
let stdout = child.stdout.take().map(BufReader::new);
|
||||
let stderr = child.stderr.take().map(BufReader::new);
|
||||
debug_assert!(manager.child.lock().is_none());
|
||||
*manager.auth_cookie_name.lock() = Some(auth_cookie_name.as_str().to_string());
|
||||
manager.status.lock().pid = Some(pid);
|
||||
*manager.child.lock() = Some(child);
|
||||
#[cfg(windows)]
|
||||
|
|
@ -1167,6 +1166,7 @@ impl CliProcessManager {
|
|||
}
|
||||
Poll::Exited(code) => {
|
||||
manager.with_current_generation(generation, || {
|
||||
*manager.local_access.lock() = None;
|
||||
let mut status = manager.status.lock();
|
||||
if status.state != CliState::Ready {
|
||||
status.state = CliState::Error;
|
||||
|
|
@ -1299,10 +1299,8 @@ impl CliProcessManager {
|
|||
log_line(&format!("cli ready on {base_url}"));
|
||||
|
||||
if let Some(token) = token {
|
||||
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
||||
// skip the exchange and let the user authenticate normally.
|
||||
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
||||
if scheme.as_deref() != Some("http") {
|
||||
// Native credentials are only established against the managed loopback listener.
|
||||
if !is_loopback_http_url(&base_url) {
|
||||
navigate_main(manager, generation, app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
|
|
@ -1318,7 +1316,11 @@ impl CliProcessManager {
|
|||
navigate_main(manager, generation, app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
manager.with_current_generation(generation, || {
|
||||
*manager.session_cookie.lock() = Some(session_id.clone());
|
||||
*manager.local_access.lock() = Some(LocalCliAccess {
|
||||
base_url: base_url.clone(),
|
||||
cookie_name: auth_cookie_name.to_string(),
|
||||
session_cookie: session_id,
|
||||
});
|
||||
});
|
||||
navigate_main(manager, generation, app, &base_url);
|
||||
}
|
||||
|
|
@ -1762,6 +1764,34 @@ mod tests {
|
|||
assert_eq!(manager.status().state, CliState::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_cli_access_requires_readiness_and_clears_on_stop() {
|
||||
let manager = CliProcessManager::new();
|
||||
let access = LocalCliAccess {
|
||||
base_url: "http://127.0.0.1:3000".into(),
|
||||
cookie_name: "codenomad_session_test".into(),
|
||||
session_cookie: "secret".into(),
|
||||
};
|
||||
*manager.local_access.lock() = Some(access.clone());
|
||||
|
||||
assert_eq!(manager.local_cli_access(), None);
|
||||
manager.status.lock().state = CliState::Ready;
|
||||
assert_eq!(manager.local_cli_access(), Some(access));
|
||||
|
||||
manager.stop().unwrap();
|
||||
assert_eq!(manager.local_cli_access(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_auth_is_limited_to_loopback_http() {
|
||||
assert!(is_loopback_http_url("http://127.0.0.1:3000"));
|
||||
assert!(is_loopback_http_url("http://[::1]:3000"));
|
||||
assert!(is_loopback_http_url("http://localhost:3000"));
|
||||
assert!(!is_loopback_http_url("https://localhost:3000"));
|
||||
assert!(!is_loopback_http_url("http://remote.example:3000"));
|
||||
assert!(!is_loopback_http_url("http://user@localhost:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_waits_for_an_authorized_spawn_section() {
|
||||
let manager = CliProcessManager::new();
|
||||
|
|
|
|||
|
|
@ -106,29 +106,25 @@ type StateWriter =
|
|||
std::sync::Arc<dyn Fn(&Path, &[u8], &dyn Fn() -> bool) -> Result<(), String> + Send + Sync>;
|
||||
|
||||
impl ClientState {
|
||||
pub(crate) fn validate_renderer_access(
|
||||
&self,
|
||||
access_token: &str,
|
||||
renderer_url: &Url,
|
||||
) -> Result<u64, String> {
|
||||
self.renderer_access
|
||||
.validate_stable(access_token, renderer_url)
|
||||
}
|
||||
|
||||
pub fn initialize(app: &AppHandle) -> Self {
|
||||
match app.path().app_data_dir() {
|
||||
Ok(app_data_dir) => {
|
||||
match (cross_host::election_directory(), cross_host::state_path()) {
|
||||
(Ok(election_dir), Ok(state_path)) => {
|
||||
match (
|
||||
cross_host::election_directory(),
|
||||
cross_host::state_path(),
|
||||
cross_host::legacy_state_path(),
|
||||
) {
|
||||
(Ok(election_dir), Ok(state_path), Ok(legacy_state_path)) => {
|
||||
let legacy_electron = cross_host::legacy_electron_data_directory();
|
||||
Self::initialize_managed_at_with_election(
|
||||
&app_data_dir,
|
||||
&election_dir,
|
||||
&state_path,
|
||||
Some(&legacy_state_path),
|
||||
legacy_electron.as_deref(),
|
||||
)
|
||||
}
|
||||
(Err(err), _) | (_, Err(err)) => {
|
||||
(Err(err), _, _) | (_, Err(err), _) | (_, _, Err(err)) => {
|
||||
eprintln!("[client-state] initialization failed; restore disabled: {err}");
|
||||
Self::disabled(app_data_dir.join(CLIENT_STATE_FILENAME))
|
||||
}
|
||||
|
|
@ -153,12 +149,14 @@ impl ClientState {
|
|||
app_data_dir: &Path,
|
||||
election_dir: &Path,
|
||||
state_path: &Path,
|
||||
legacy_shared_state_path: Option<&Path>,
|
||||
legacy_electron_data_dir: Option<&Path>,
|
||||
) -> Self {
|
||||
Self::initialize_at_with_writer_and_election(
|
||||
app_data_dir,
|
||||
election_dir,
|
||||
state_path,
|
||||
legacy_shared_state_path,
|
||||
legacy_electron_data_dir,
|
||||
std::sync::Arc::new(write_atomically),
|
||||
)
|
||||
|
|
@ -221,6 +219,7 @@ impl ClientState {
|
|||
&app_data_dir.join(".cross-host-election"),
|
||||
&app_data_dir.join(CLIENT_STATE_FILENAME),
|
||||
None,
|
||||
None,
|
||||
write_state,
|
||||
)
|
||||
}
|
||||
|
|
@ -229,6 +228,7 @@ impl ClientState {
|
|||
app_data_dir: &Path,
|
||||
election_dir: &Path,
|
||||
state_path: &Path,
|
||||
legacy_shared_state_path: Option<&Path>,
|
||||
legacy_electron_data_dir: Option<&Path>,
|
||||
write_state: StateWriter,
|
||||
) -> Result<Self, String> {
|
||||
|
|
@ -244,6 +244,11 @@ impl ClientState {
|
|||
election_dir,
|
||||
legacy_electron_data_dir,
|
||||
)?;
|
||||
if registration.is_primary() && !state_path.exists() {
|
||||
if let Some(legacy_path) = legacy_shared_state_path {
|
||||
copy_legacy_shared_state(legacy_path, state_path, &|| registration.is_primary())?;
|
||||
}
|
||||
}
|
||||
let future_legacy =
|
||||
!state_path.exists() && has_future_legacy_state(app_data_dir, legacy_electron_data_dir);
|
||||
if registration.is_primary() && !state_path.exists() && !future_legacy {
|
||||
|
|
@ -624,26 +629,46 @@ fn migrate_legacy_state(
|
|||
.map_err(|err| format!("failed to create shared client-state directory: {err}"))?;
|
||||
}
|
||||
write_atomically(state_path, &bytes, ownership_valid)?;
|
||||
for path in [
|
||||
electron_data_dir.map(|path| path.join(CLIENT_STATE_FILENAME)),
|
||||
Some(tauri_data_dir.join(CLIENT_STATE_FILENAME)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
return Err(format!(
|
||||
"failed to remove migrated legacy client state: {err}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_legacy_shared_state(
|
||||
legacy_path: &Path,
|
||||
state_path: &Path,
|
||||
ownership_valid: &dyn Fn() -> bool,
|
||||
) -> Result<(), String> {
|
||||
if state_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = match fs::read(legacy_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(format!("failed to read legacy shared client state: {err}")),
|
||||
};
|
||||
let parent = state_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("state path has no parent: {}", state_path.display()))?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("failed to create shared client-state directory: {err}"))?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent)
|
||||
.map_err(|err| format!("failed to create temporary state file: {err}"))?;
|
||||
temporary
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| temporary.as_file().sync_all())
|
||||
.map_err(|err| format!("failed to copy legacy shared client state: {err}"))?;
|
||||
if !ownership_valid() {
|
||||
return Err("Client state ownership changed before atomic replacement".to_string());
|
||||
}
|
||||
match temporary.persist_noclobber(state_path) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(err) => Err(format!(
|
||||
"failed to publish copied legacy shared client state: {}",
|
||||
err.error
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialized_value_size(value: &Value) -> Result<usize, String> {
|
||||
serde_json::to_vec(value)
|
||||
.map(|bytes| bytes.len())
|
||||
|
|
|
|||
|
|
@ -75,27 +75,6 @@ impl RendererAccess {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_stable(
|
||||
&self,
|
||||
access_token: &str,
|
||||
renderer_url: &Url,
|
||||
) -> Result<u64, String> {
|
||||
if access_token.is_empty() {
|
||||
return Err("Client state access token must not be empty".to_string());
|
||||
}
|
||||
let renderer_origin = origin_key(renderer_url)?;
|
||||
let state = self.state.lock().map_err(|err| err.to_string())?;
|
||||
if state.token.as_deref() != Some(access_token)
|
||||
|| state.committed_origin.as_deref() != Some(renderer_origin.as_str())
|
||||
{
|
||||
return Err("Client state renderer access is no longer current".to_string());
|
||||
}
|
||||
if state.pending_origin.is_some() {
|
||||
return Err("Renderer navigation is in progress".to_string());
|
||||
}
|
||||
Ok(state.generation)
|
||||
}
|
||||
|
||||
pub(super) fn is_generation_current(&self, generation: u64) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ use std::io::Write;
|
|||
use std::path::{Path, PathBuf};
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
const OWNER_DIRECTORY: &str = "primary.owner.json";
|
||||
const OWNER_FILENAME: &str = "owner.json";
|
||||
|
|
@ -16,6 +17,7 @@ const RECOVERY_PREFIX: &str = "recovery.";
|
|||
const RECOVERY_SUFFIX: &str = ".claim";
|
||||
const RETIRED_PREFIX: &str = "retired.";
|
||||
const ACQUIRE_ATTEMPTS: usize = 10;
|
||||
const CROSS_HOST_PARTICIPANT_GRACE: Duration = Duration::from_millis(50);
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -110,12 +112,12 @@ fn resolve_election_directory_for(
|
|||
let home = configured_home(platform, &environment, fallback_home)?;
|
||||
Some(if platform == "windows" {
|
||||
format!(
|
||||
"{}\\.codenomad\\client-state\\election",
|
||||
"{}\\.codenomad\\client-state\\v2\\election",
|
||||
home.trim_end_matches(['\\', '/'])
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/.codenomad/client-state/election",
|
||||
"{}/.codenomad/client-state/v2/election",
|
||||
home.trim_end_matches('/')
|
||||
)
|
||||
})
|
||||
|
|
@ -125,6 +127,35 @@ fn resolve_state_path_for(
|
|||
platform: &str,
|
||||
environment: impl Fn(&str) -> Option<OsString>,
|
||||
fallback_home: Option<&Path>,
|
||||
) -> Option<String> {
|
||||
let home = configured_home(platform, &environment, fallback_home)?;
|
||||
Some(if platform == "windows" {
|
||||
format!(
|
||||
"{}\\.codenomad\\client-state\\v2\\client-state.json",
|
||||
home.trim_end_matches(['\\', '/'])
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/.codenomad/client-state/v2/client-state.json",
|
||||
home.trim_end_matches('/')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn legacy_state_path() -> Result<PathBuf, String> {
|
||||
resolve_legacy_state_path_for(
|
||||
std::env::consts::OS,
|
||||
|name| std::env::var_os(name),
|
||||
dirs::home_dir().as_deref(),
|
||||
)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "user home directory is unavailable".to_string())
|
||||
}
|
||||
|
||||
fn resolve_legacy_state_path_for(
|
||||
platform: &str,
|
||||
environment: impl Fn(&str) -> Option<OsString>,
|
||||
fallback_home: Option<&Path>,
|
||||
) -> Option<String> {
|
||||
let home = configured_home(platform, &environment, fallback_home)?;
|
||||
Some(if platform == "windows" {
|
||||
|
|
@ -226,7 +257,7 @@ impl Registration {
|
|||
let mut recovery_claim = None;
|
||||
|
||||
let result = (|| {
|
||||
let legacy_blocked = legacy_electron_data
|
||||
let mut legacy_blocked = legacy_electron_data
|
||||
.filter(|_| primary_candidate)
|
||||
.map(|path| {
|
||||
has_live_legacy_electron_with(
|
||||
|
|
@ -239,6 +270,23 @@ impl Registration {
|
|||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
if legacy_blocked {
|
||||
// A peer may have published its legacy marker just before its
|
||||
// cross-host participant. Reconcile once before yielding ownership.
|
||||
std::thread::sleep(CROSS_HOST_PARTICIPANT_GRACE);
|
||||
legacy_blocked = legacy_electron_data
|
||||
.map(|path| {
|
||||
has_live_legacy_electron_with(
|
||||
path,
|
||||
election_directory,
|
||||
pid_alive,
|
||||
identity,
|
||||
expected_electron,
|
||||
)
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
}
|
||||
let mut primary = false;
|
||||
if primary_candidate && !legacy_blocked {
|
||||
for _ in 0..ACQUIRE_ATTEMPTS {
|
||||
|
|
@ -1425,7 +1473,7 @@ mod tests {
|
|||
};
|
||||
assert_eq!(
|
||||
resolve("linux", HashMap::from([("HOME", "/home/dev")]), "/fallback"),
|
||||
"/home/dev/.codenomad/client-state/election"
|
||||
"/home/dev/.codenomad/client-state/v2/election"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(
|
||||
|
|
@ -1433,7 +1481,7 @@ mod tests {
|
|||
HashMap::from([("USERPROFILE", ""), ("HOME", "D:\\Home")]),
|
||||
"C:\\Fallback"
|
||||
),
|
||||
"D:\\Home\\.codenomad\\client-state\\election"
|
||||
"D:\\Home\\.codenomad\\client-state\\v2\\election"
|
||||
);
|
||||
let resolve_state = |platform: &str, values: HashMap<&str, &str>, fallback: &str| {
|
||||
resolve_state_path_for(
|
||||
|
|
@ -1449,11 +1497,11 @@ mod tests {
|
|||
HashMap::from([("HOME", "/Users/dev")]),
|
||||
"/fallback"
|
||||
),
|
||||
"/Users/dev/.codenomad/client-state/client-state.json"
|
||||
"/Users/dev/.codenomad/client-state/v2/client-state.json"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_state("linux", HashMap::from([("HOME", "/home/dev")]), "/fallback"),
|
||||
"/home/dev/.codenomad/client-state/client-state.json"
|
||||
"/home/dev/.codenomad/client-state/v2/client-state.json"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_state(
|
||||
|
|
@ -1461,7 +1509,37 @@ mod tests {
|
|||
HashMap::from([("USERPROFILE", ""), ("HOME", "D:\\Home")]),
|
||||
"C:\\Fallback"
|
||||
),
|
||||
"D:\\Home\\.codenomad\\client-state\\client-state.json"
|
||||
"D:\\Home\\.codenomad\\client-state\\v2\\client-state.json"
|
||||
);
|
||||
for (platform, home, fallback, expected) in [
|
||||
(
|
||||
"macos",
|
||||
"/Users/dev",
|
||||
"/fallback",
|
||||
"/Users/dev/.codenomad/client-state/client-state.json",
|
||||
),
|
||||
(
|
||||
"linux",
|
||||
"/home/dev",
|
||||
"/fallback",
|
||||
"/home/dev/.codenomad/client-state/client-state.json",
|
||||
),
|
||||
(
|
||||
"windows",
|
||||
"D:\\Home",
|
||||
"C:\\Fallback",
|
||||
"D:\\Home\\.codenomad\\client-state\\client-state.json",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_legacy_state_path_for(
|
||||
platform,
|
||||
|name| (name == "HOME").then(|| OsString::from(home)),
|
||||
Some(Path::new(fallback)),
|
||||
)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,14 +250,15 @@ fn migrates_dual_legacy_files_with_disabled_dominance_and_malformed_fallback() {
|
|||
&tauri,
|
||||
&election,
|
||||
&shared,
|
||||
None,
|
||||
Some(&electron),
|
||||
Arc::new(super::write_atomically),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.load().unwrap(), load(true, false, Value::Null));
|
||||
assert!(!parse_client_state(&fs::read(&shared).unwrap()).restore_enabled);
|
||||
assert!(!electron.join(CLIENT_STATE_FILENAME).exists());
|
||||
assert!(!tauri.join(CLIENT_STATE_FILENAME).exists());
|
||||
assert!(electron.join(CLIENT_STATE_FILENAME).exists());
|
||||
assert!(tauri.join(CLIENT_STATE_FILENAME).exists());
|
||||
}
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
|
@ -286,6 +287,7 @@ fn migrates_dual_legacy_files_with_disabled_dominance_and_malformed_fallback() {
|
|||
&tauri,
|
||||
&election,
|
||||
&shared,
|
||||
None,
|
||||
Some(&electron),
|
||||
Arc::new(super::write_atomically),
|
||||
)
|
||||
|
|
@ -293,6 +295,61 @@ fn migrates_dual_legacy_files_with_disabled_dominance_and_malformed_fallback() {
|
|||
assert_eq!(state.load().unwrap().snapshot, Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_shared_state_is_copied_once_and_v2_mutations_remain_isolated() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let tauri = root.path().join("tauri");
|
||||
let shared = root.path().join("shared");
|
||||
let legacy_shared = shared.join(CLIENT_STATE_FILENAME);
|
||||
let v2 = shared.join("v2");
|
||||
let election = v2.join("election");
|
||||
let v2_state = v2.join(CLIENT_STATE_FILENAME);
|
||||
fs::create_dir_all(&shared).unwrap();
|
||||
fs::create_dir_all(&tauri).unwrap();
|
||||
let legacy_bytes = br#"{
|
||||
"version": 1, "restoreEnabled": true, "snapshot": { "source": "v1" }, "v1Only": true
|
||||
}"#;
|
||||
fs::write(&legacy_shared, legacy_bytes).unwrap();
|
||||
let host_local = br#"{"version":1,"restoreEnabled":true,"snapshot":{"source":"host-local"}}"#;
|
||||
fs::write(tauri.join(CLIENT_STATE_FILENAME), host_local).unwrap();
|
||||
|
||||
let state = ClientState::initialize_at_with_writer_and_election(
|
||||
&tauri,
|
||||
&election,
|
||||
&v2_state,
|
||||
Some(&legacy_shared),
|
||||
None,
|
||||
Arc::new(super::write_atomically),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&v2_state).unwrap(), legacy_bytes);
|
||||
assert_eq!(state.load().unwrap().snapshot, json!({ "source": "v1" }));
|
||||
assert!(state.save_snapshot(json!({ "source": "v2-save" })).unwrap());
|
||||
assert_eq!(fs::read(&legacy_shared).unwrap(), legacy_bytes);
|
||||
assert!(state.set_restore_enabled(false).unwrap());
|
||||
assert_eq!(fs::read(&legacy_shared).unwrap(), legacy_bytes);
|
||||
assert!(state.clear().unwrap());
|
||||
assert_eq!(fs::read(&legacy_shared).unwrap(), legacy_bytes);
|
||||
assert_eq!(
|
||||
fs::read(tauri.join(CLIENT_STATE_FILENAME)).unwrap(),
|
||||
host_local
|
||||
);
|
||||
state.release_locks();
|
||||
|
||||
let restarted = ClientState::initialize_at_with_writer_and_election(
|
||||
&tauri,
|
||||
&election,
|
||||
&v2_state,
|
||||
Some(&legacy_shared),
|
||||
None,
|
||||
Arc::new(super::write_atomically),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!restarted.load().unwrap().restore_enabled);
|
||||
assert_ne!(fs::read(&v2_state).unwrap(), legacy_bytes);
|
||||
assert_eq!(fs::read(&legacy_shared).unwrap(), legacy_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn electron_and_tauri_share_the_complete_envelope_across_handoffs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
|
@ -316,6 +373,7 @@ fn electron_and_tauri_share_the_complete_envelope_across_handoffs() {
|
|||
&tauri,
|
||||
&election,
|
||||
&shared,
|
||||
None,
|
||||
Some(&electron),
|
||||
Arc::new(super::write_atomically),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,477 +0,0 @@
|
|||
use parking_lot::Mutex;
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
|
||||
mod assembler;
|
||||
mod stream;
|
||||
mod transport;
|
||||
|
||||
use stream::*;
|
||||
use transport::*;
|
||||
|
||||
const EVENT_BATCH_NAME: &str = "desktop:event-batch";
|
||||
const EVENT_STATUS_NAME: &str = "desktop:event-stream-status";
|
||||
const FLUSH_INTERVAL_MS: u64 = 16;
|
||||
const DELTA_STREAM_WINDOW_MS: u64 = 48;
|
||||
const MAX_BATCH_EVENTS: usize = 256;
|
||||
const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000;
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000;
|
||||
const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0;
|
||||
const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000;
|
||||
const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000;
|
||||
const STREAM_STALL_TIMEOUT_MS: u64 = 30_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DesktopEventStreamConfig {
|
||||
pub base_url: String,
|
||||
pub events_url: String,
|
||||
pub client_id: String,
|
||||
pub connection_id: String,
|
||||
pub cookie_name: String,
|
||||
pub session_cookie: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DesktopEventsStartRequest {
|
||||
pub reconnect: Option<DesktopEventReconnectPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DesktopEventReconnectPolicy {
|
||||
pub initial_delay_ms: Option<u64>,
|
||||
pub max_delay_ms: Option<u64>,
|
||||
pub multiplier: Option<f64>,
|
||||
pub max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopEventsStartResult {
|
||||
pub started: bool,
|
||||
pub generation: Option<u64>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct ResolvedDesktopEventReconnectPolicy {
|
||||
initial_delay_ms: u64,
|
||||
max_delay_ms: u64,
|
||||
multiplier: f64,
|
||||
max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
impl ResolvedDesktopEventReconnectPolicy {
|
||||
fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self {
|
||||
let initial_delay_ms = policy
|
||||
.and_then(|value| value.initial_delay_ms)
|
||||
.unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS)
|
||||
.max(1);
|
||||
let max_delay_ms = policy
|
||||
.and_then(|value| value.max_delay_ms)
|
||||
.unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS)
|
||||
.max(initial_delay_ms);
|
||||
let multiplier = policy
|
||||
.and_then(|value| value.multiplier)
|
||||
.filter(|value| value.is_finite() && *value >= 1.0)
|
||||
.unwrap_or(DEFAULT_RECONNECT_MULTIPLIER);
|
||||
let max_attempts = policy
|
||||
.and_then(|value| value.max_attempts)
|
||||
.filter(|value| *value > 0);
|
||||
|
||||
Self {
|
||||
initial_delay_ms,
|
||||
max_delay_ms,
|
||||
multiplier,
|
||||
max_attempts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct DesktopEventTransportConfig {
|
||||
stream: DesktopEventStreamConfig,
|
||||
reconnect: ResolvedDesktopEventReconnectPolicy,
|
||||
}
|
||||
|
||||
impl DesktopEventTransportConfig {
|
||||
fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_equivalent_start(&self, other: &Self) -> bool {
|
||||
self.reconnect == other.reconnect
|
||||
&& self.stream.base_url == other.stream.base_url
|
||||
&& self.stream.events_url == other.stream.events_url
|
||||
&& self.stream.client_id == other.stream.client_id
|
||||
&& self.stream.cookie_name == other.stream.cookie_name
|
||||
&& self.stream.session_cookie == other.stream.session_cookie
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceEventBatchPayload {
|
||||
generation: u64,
|
||||
sequence: u64,
|
||||
emitted_at: u128,
|
||||
events: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopEventStreamStatusPayload {
|
||||
generation: u64,
|
||||
state: &'static str,
|
||||
reconnect_attempt: u32,
|
||||
terminal: bool,
|
||||
reason: Option<String>,
|
||||
next_delay_ms: Option<u64>,
|
||||
status_code: Option<u16>,
|
||||
stats: DesktopEventTransportStats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopEventTransportStats {
|
||||
raw_events: u64,
|
||||
emitted_events: u64,
|
||||
emitted_batches: u64,
|
||||
delta_coalesces: u64,
|
||||
snapshot_coalesces: u64,
|
||||
status_coalesces: u64,
|
||||
superseded_deltas_dropped: u64,
|
||||
}
|
||||
|
||||
struct DesktopEventTransportState {
|
||||
stop: Option<Arc<AtomicBool>>,
|
||||
config: Option<DesktopEventTransportConfig>,
|
||||
}
|
||||
|
||||
pub struct DesktopEventTransportManager {
|
||||
state: Arc<Mutex<DesktopEventTransportState>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
enum ReaderMessage {
|
||||
Activity,
|
||||
Event(Value),
|
||||
Ping(Value),
|
||||
End(Option<String>),
|
||||
}
|
||||
|
||||
enum PendingEntry {
|
||||
Delta {
|
||||
key: String,
|
||||
scope: String,
|
||||
event: Value,
|
||||
started_at: Instant,
|
||||
},
|
||||
Status {
|
||||
key: String,
|
||||
event: Value,
|
||||
},
|
||||
Snapshot {
|
||||
key: String,
|
||||
event: Value,
|
||||
},
|
||||
Event(Value),
|
||||
}
|
||||
|
||||
enum EventDeliveryPolicy {
|
||||
CoalesceDelta(String),
|
||||
CoalesceStatus(String),
|
||||
CoalesceSnapshot(String),
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
enum OpenStreamErrorKind {
|
||||
Unauthorized,
|
||||
Http,
|
||||
Transport,
|
||||
}
|
||||
|
||||
struct OpenStreamError {
|
||||
kind: OpenStreamErrorKind,
|
||||
message: String,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingBatch {
|
||||
events: Vec<PendingEntry>,
|
||||
}
|
||||
|
||||
impl DesktopEventTransportManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(DesktopEventTransportState {
|
||||
stop: None,
|
||||
config: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
stream_config: Option<DesktopEventStreamConfig>,
|
||||
request: Option<DesktopEventsStartRequest>,
|
||||
) -> DesktopEventsStartResult {
|
||||
let Some(stream_config) = stream_config else {
|
||||
return DesktopEventsStartResult {
|
||||
started: false,
|
||||
generation: None,
|
||||
reason: Some("desktop event stream unavailable".to_string()),
|
||||
};
|
||||
};
|
||||
|
||||
let request = request.unwrap_or_default();
|
||||
let transport_config = DesktopEventTransportConfig::new(stream_config, &request);
|
||||
|
||||
let mut state = self.state.lock();
|
||||
if state
|
||||
.config
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.is_equivalent_start(&transport_config))
|
||||
{
|
||||
if let Some(stop) = &state.stop {
|
||||
if !stop.load(Ordering::SeqCst) {
|
||||
return DesktopEventsStartResult {
|
||||
started: true,
|
||||
generation: Some(self.generation.load(Ordering::SeqCst)),
|
||||
reason: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(stop) = state.stop.take() {
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
state.stop = Some(stop.clone());
|
||||
state.config = Some(transport_config.clone());
|
||||
let shared_generation = self.generation.clone();
|
||||
drop(state);
|
||||
|
||||
thread::spawn(move || {
|
||||
run_transport_loop(app, shared_generation, generation, stop, transport_config)
|
||||
});
|
||||
|
||||
DesktopEventsStartResult {
|
||||
started: true,
|
||||
generation: Some(generation),
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
let mut state = self.state.lock();
|
||||
if let Some(stop) = state.stop.take() {
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
state.config = None;
|
||||
self.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_event(event: &Value) -> EventDeliveryPolicy {
|
||||
if let Some(key) = delta_key(event) {
|
||||
return EventDeliveryPolicy::CoalesceDelta(key);
|
||||
}
|
||||
|
||||
if let Some(key) = status_key(event) {
|
||||
return EventDeliveryPolicy::CoalesceStatus(key);
|
||||
}
|
||||
|
||||
if let Some(key) = snapshot_key(event) {
|
||||
return EventDeliveryPolicy::CoalesceSnapshot(key);
|
||||
}
|
||||
|
||||
EventDeliveryPolicy::Passthrough
|
||||
}
|
||||
|
||||
fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value {
|
||||
if event.get("type").and_then(Value::as_str) == Some("instance.event") {
|
||||
event.get("event").unwrap_or(event)
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
fn coalesced_instance_id(event: &Value) -> &str {
|
||||
event
|
||||
.get("instanceId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn snapshot_key(event: &Value) -> Option<String> {
|
||||
let instance_id = coalesced_instance_id(event);
|
||||
let inner = coalesced_payload_event(event);
|
||||
let inner_type = inner.get("type")?.as_str()?;
|
||||
let props = inner.get("properties")?;
|
||||
|
||||
match inner_type {
|
||||
"message.part.updated" => {
|
||||
let session_id = props
|
||||
.get("part")
|
||||
.and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId")))
|
||||
.and_then(Value::as_str)?;
|
||||
let message_id = props
|
||||
.get("part")
|
||||
.and_then(|part| part.get("messageID").or_else(|| part.get("messageId")))
|
||||
.and_then(Value::as_str)?;
|
||||
let part_id = props
|
||||
.get("part")
|
||||
.and_then(|part| part.get("id"))
|
||||
.and_then(Value::as_str)?;
|
||||
|
||||
Some(format!(
|
||||
"message.part.updated:{}:{}:{}:{}",
|
||||
instance_id, session_id, message_id, part_id
|
||||
))
|
||||
}
|
||||
"message.updated" => {
|
||||
let info = props.get("info")?;
|
||||
let session_id = info
|
||||
.get("sessionID")
|
||||
.or_else(|| info.get("sessionId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let message_id = info.get("id").and_then(Value::as_str)?;
|
||||
|
||||
Some(format!(
|
||||
"message.updated:{}:{}:{}",
|
||||
instance_id, session_id, message_id
|
||||
))
|
||||
}
|
||||
"session.updated" | "session.status" => {
|
||||
let session_id = props
|
||||
.get("info")
|
||||
.and_then(|info| info.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
props
|
||||
.get("sessionID")
|
||||
.or_else(|| props.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
})?;
|
||||
|
||||
Some(format!("{}:{}:{}", inner_type, instance_id, session_id))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_scope(event: &Value) -> Option<String> {
|
||||
let instance_id = coalesced_instance_id(event);
|
||||
let inner = coalesced_payload_event(event);
|
||||
if inner.get("type")?.as_str()? != "message.part.delta" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let props = inner.get("properties")?;
|
||||
let session_id = props
|
||||
.get("sessionID")
|
||||
.or_else(|| props.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let message_id = props
|
||||
.get("messageID")
|
||||
.or_else(|| props.get("messageId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let part_id = props
|
||||
.get("partID")
|
||||
.or_else(|| props.get("partId"))
|
||||
.and_then(Value::as_str)?;
|
||||
|
||||
Some(format!(
|
||||
"message.part:{}:{}:{}:{}",
|
||||
instance_id, session_id, message_id, part_id
|
||||
))
|
||||
}
|
||||
|
||||
fn delta_key(event: &Value) -> Option<String> {
|
||||
let scope = delta_scope(event)?;
|
||||
let props = coalesced_payload_event(event).get("properties")?;
|
||||
let field = props.get("field")?.as_str()?;
|
||||
|
||||
Some(format!("{}:{}", scope, field))
|
||||
}
|
||||
|
||||
fn snapshot_superseded_delta_scope(event: &Value) -> Option<String> {
|
||||
let instance_id = coalesced_instance_id(event);
|
||||
let inner = coalesced_payload_event(event);
|
||||
if inner.get("type")?.as_str()? != "message.part.updated" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let part = inner.get("properties")?.get("part")?;
|
||||
let session_id = part
|
||||
.get("sessionID")
|
||||
.or_else(|| part.get("sessionId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let message_id = part
|
||||
.get("messageID")
|
||||
.or_else(|| part.get("messageId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let part_id = part.get("id")?.as_str()?;
|
||||
|
||||
Some(format!(
|
||||
"message.part:{}:{}:{}:{}",
|
||||
instance_id, session_id, message_id, part_id
|
||||
))
|
||||
}
|
||||
|
||||
fn append_delta(target: &mut Value, event: &Value) {
|
||||
let next_delta = coalesced_payload_event(event)
|
||||
.get("properties")
|
||||
.and_then(|value| value.get("delta"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(existing_delta) = coalesced_payload_event_mut(target)
|
||||
.and_then(|event| event.get_mut("properties"))
|
||||
.and_then(Value::as_object_mut)
|
||||
.and_then(|props| props.get_mut("delta"))
|
||||
{
|
||||
let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta;
|
||||
*existing_delta = Value::String(combined);
|
||||
}
|
||||
}
|
||||
|
||||
fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
|
||||
if event.get("type").and_then(Value::as_str) == Some("instance.event") {
|
||||
event.get_mut("event").and_then(Value::as_object_mut)
|
||||
} else {
|
||||
event.as_object_mut()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_key(event: &Value) -> Option<String> {
|
||||
match event.get("type")?.as_str()? {
|
||||
"instance.eventStatus" => Some(coalesced_instance_id(event).to_string()),
|
||||
"session.status" => snapshot_key(event),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
impl PendingBatch {
|
||||
pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) {
|
||||
match classify_event(&event) {
|
||||
EventDeliveryPolicy::CoalesceDelta(key) => {
|
||||
let Some(scope) = delta_scope(&event) else {
|
||||
self.events.push(PendingEntry::Event(event));
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(PendingEntry::Delta {
|
||||
key: existing_key,
|
||||
event: existing_event,
|
||||
..
|
||||
}) = self.events.last_mut()
|
||||
{
|
||||
if existing_key == &key {
|
||||
append_delta(existing_event, &event);
|
||||
stats.delta_coalesces = stats.delta_coalesces.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.events.push(PendingEntry::Delta {
|
||||
key,
|
||||
scope,
|
||||
event,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
EventDeliveryPolicy::CoalesceStatus(key) => {
|
||||
if let Some(PendingEntry::Status {
|
||||
key: existing_key,
|
||||
event: existing_event,
|
||||
}) = self.events.last_mut()
|
||||
{
|
||||
if existing_key == &key {
|
||||
*existing_event = event;
|
||||
stats.status_coalesces = stats.status_coalesces.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.events.push(PendingEntry::Status { key, event });
|
||||
}
|
||||
EventDeliveryPolicy::CoalesceSnapshot(key) => {
|
||||
if let Some(part_scope) = snapshot_superseded_delta_scope(&event) {
|
||||
let mut dropped = 0_u64;
|
||||
while matches!(
|
||||
self.events.last(),
|
||||
Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope
|
||||
) {
|
||||
self.events.pop();
|
||||
dropped = dropped.saturating_add(1);
|
||||
}
|
||||
if dropped > 0 {
|
||||
stats.superseded_deltas_dropped =
|
||||
stats.superseded_deltas_dropped.saturating_add(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(PendingEntry::Snapshot {
|
||||
key: existing_key,
|
||||
event: existing_event,
|
||||
}) = self.events.last_mut()
|
||||
{
|
||||
if existing_key == &key {
|
||||
*existing_event = event;
|
||||
stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.events.push(PendingEntry::Snapshot { key, event });
|
||||
}
|
||||
EventDeliveryPolicy::Passthrough => {
|
||||
self.events.push(PendingEntry::Event(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take_events(&mut self) -> Vec<Value> {
|
||||
let pending = std::mem::take(&mut self.events);
|
||||
pending
|
||||
.into_iter()
|
||||
.map(|entry| match entry {
|
||||
PendingEntry::Delta { event, .. } => event,
|
||||
PendingEntry::Status { event, .. } => event,
|
||||
PendingEntry::Snapshot { event, .. } => event,
|
||||
PendingEntry::Event(event) => event,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn pending_len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool {
|
||||
matches!(
|
||||
self.events.as_slice(),
|
||||
[PendingEntry::Delta { started_at, .. }]
|
||||
if now.duration_since(*started_at)
|
||||
< Duration::from_millis(DELTA_STREAM_WINDOW_MS)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
use super::*;
|
||||
use reqwest::blocking::RequestBuilder;
|
||||
|
||||
pub(super) fn build_stream_client() -> Result<Client, OpenStreamError> {
|
||||
Client::builder()
|
||||
.connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS))
|
||||
.tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS))
|
||||
// Note: reqwest's blocking client doesn't expose a per-read timeout.
|
||||
// The global `.timeout()` would kill the entire SSE stream, so we
|
||||
// rely on:
|
||||
// 1. tcp_keepalive to detect dead connections (OS will RST after
|
||||
// several unacked probes, typically ~2 min).
|
||||
// 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS).
|
||||
// 3. Reader thread breaking on channel send error (consumer dropped).
|
||||
.build()
|
||||
.map_err(|error: reqwest::Error| OpenStreamError {
|
||||
kind: OpenStreamErrorKind::Transport,
|
||||
message: error.to_string(),
|
||||
status_code: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn open_stream(
|
||||
app: &AppHandle,
|
||||
client: &Client,
|
||||
config: &DesktopEventStreamConfig,
|
||||
) -> Result<Response, OpenStreamError> {
|
||||
let url = format!(
|
||||
"{}?clientId={}&connectionId={}",
|
||||
config.events_url, config.client_id, config.connection_id
|
||||
);
|
||||
|
||||
let request = attach_session_cookie(
|
||||
client.get(&url).header("Accept", "text/event-stream"),
|
||||
app,
|
||||
config,
|
||||
);
|
||||
|
||||
let response = request.send().map_err(|error| OpenStreamError {
|
||||
kind: OpenStreamErrorKind::Transport,
|
||||
message: error.to_string(),
|
||||
status_code: None,
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
OpenStreamErrorKind::Unauthorized
|
||||
} else {
|
||||
OpenStreamErrorKind::Http
|
||||
};
|
||||
|
||||
Err(OpenStreamError {
|
||||
kind,
|
||||
message: format!("desktop event stream unavailable ({status})"),
|
||||
status_code: Some(status.as_u16()),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option<String> {
|
||||
read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name)
|
||||
.or_else(|| config.session_cookie.clone())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn attach_session_cookie(
|
||||
request: RequestBuilder,
|
||||
app: &AppHandle,
|
||||
config: &DesktopEventStreamConfig,
|
||||
) -> RequestBuilder {
|
||||
attach_session_cookie_value(
|
||||
request,
|
||||
&config.cookie_name,
|
||||
resolve_session_cookie(app, config).as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn attach_session_cookie_value(
|
||||
request: RequestBuilder,
|
||||
cookie_name: &str,
|
||||
session_cookie: Option<&str>,
|
||||
) -> RequestBuilder {
|
||||
let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else {
|
||||
return request;
|
||||
};
|
||||
|
||||
request.header(
|
||||
"Cookie",
|
||||
format!(
|
||||
"{}={}",
|
||||
cookie_name,
|
||||
encode_cookie_header_value(session_cookie)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_cookie_header_value(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
|
||||
for byte in value.bytes() {
|
||||
if is_cookie_header_value_byte(byte) {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
|
||||
encoded
|
||||
}
|
||||
|
||||
fn is_cookie_header_value_byte(byte: u8) -> bool {
|
||||
matches!(
|
||||
byte,
|
||||
b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~'
|
||||
)
|
||||
}
|
||||
|
||||
fn read_session_cookie_from_webview(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
) -> Option<String> {
|
||||
let url = Url::parse(base_url).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let path = url.path();
|
||||
let windows = app.webview_windows();
|
||||
let window = windows.get("main")?;
|
||||
let cookies = window.cookies().ok()?;
|
||||
cookies
|
||||
.into_iter()
|
||||
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name)
|
||||
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| {
|
||||
let Some(domain) = cookie.domain() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase();
|
||||
host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain))
|
||||
})
|
||||
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| {
|
||||
let Some(cookie_path) = cookie.path() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
path.starts_with(cookie_path)
|
||||
})
|
||||
.map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string())
|
||||
.next()
|
||||
}
|
||||
|
||||
pub(super) fn read_sse(
|
||||
response: Response,
|
||||
tx: SyncSender<ReaderMessage>,
|
||||
stop: Arc<AtomicBool>,
|
||||
generation_atomic: Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
) {
|
||||
let mut reader = BufReader::new(response);
|
||||
let mut line = String::new();
|
||||
let mut event_name: Option<String> = None;
|
||||
let mut data_lines: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) {
|
||||
let _ = tx.send(ReaderMessage::End(Some("stopped".to_string())));
|
||||
return;
|
||||
}
|
||||
|
||||
line.clear();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => {
|
||||
let _ = flush_sse_frame(&tx, &event_name, &data_lines);
|
||||
let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string())));
|
||||
return;
|
||||
}
|
||||
Ok(_) => {
|
||||
if tx.send(ReaderMessage::Activity).is_err() {
|
||||
return; // consumer dropped — stop reading
|
||||
}
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||
if handle_sse_line(trimmed, &mut event_name, &mut data_lines) {
|
||||
if flush_sse_frame(&tx, &event_name, &data_lines).is_err() {
|
||||
return;
|
||||
}
|
||||
event_name = None;
|
||||
data_lines.clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = flush_sse_frame(&tx, &event_name, &data_lines);
|
||||
let _ = tx.send(ReaderMessage::End(Some(error.to_string())));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sse_line(
|
||||
trimmed: &str,
|
||||
event_name: &mut Option<String>,
|
||||
data_lines: &mut Vec<String>,
|
||||
) -> bool {
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.starts_with(':') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(name) = trimmed.strip_prefix("event:") {
|
||||
*event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string());
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(data) = trimmed.strip_prefix("data:") {
|
||||
data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string());
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn flush_sse_frame(
|
||||
tx: &SyncSender<ReaderMessage>,
|
||||
event_name: &Option<String>,
|
||||
lines: &[String],
|
||||
) -> Result<(), ()> {
|
||||
let Some(payload) = parse_sse_payload(lines) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if event_name.as_deref() == Some("codenomad.client.ping") {
|
||||
tx.send(ReaderMessage::Ping(payload)).map_err(|_| ())
|
||||
} else {
|
||||
tx.send(ReaderMessage::Event(payload)).map_err(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sse_payload(lines: &[String]) -> Option<Value> {
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = lines.join("\n").trim().to_string();
|
||||
if payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<Value>(&payload).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_ping_event_is_routed_to_ping_channel() {
|
||||
let (tx, rx) = mpsc::sync_channel(1);
|
||||
let mut event_name = None;
|
||||
let mut data_lines = Vec::new();
|
||||
|
||||
assert!(!handle_sse_line(
|
||||
"event: codenomad.client.ping",
|
||||
&mut event_name,
|
||||
&mut data_lines
|
||||
));
|
||||
assert!(!handle_sse_line(
|
||||
r#"data: {"ts":123}"#,
|
||||
&mut event_name,
|
||||
&mut data_lines
|
||||
));
|
||||
assert!(handle_sse_line("", &mut event_name, &mut data_lines));
|
||||
|
||||
flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush");
|
||||
|
||||
match rx.recv().expect("ping frame should be emitted") {
|
||||
ReaderMessage::Ping(payload) => {
|
||||
assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123));
|
||||
}
|
||||
_ => panic!("expected ping frame"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_is_attached_to_requests() {
|
||||
let request = attach_session_cookie_value(
|
||||
Client::new().post("http://localhost/api/client-connections/pong"),
|
||||
"codenomad_session",
|
||||
Some("cookie-value"),
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("codenomad_session=cookie-value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_value_is_encoded_before_header_attachment() {
|
||||
let request = attach_session_cookie_value(
|
||||
Client::new().post("http://localhost/api/client-connections/pong"),
|
||||
"codenomad_session",
|
||||
Some("safe;\r\nInjected=bad value"),
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("codenomad_session=safe%3B%0D%0AInjected=bad%20value")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fresh_stats() -> DesktopEventTransportStats {
|
||||
DesktopEventTransportStats::default()
|
||||
}
|
||||
|
||||
fn stream_config(connection_id: &str) -> DesktopEventStreamConfig {
|
||||
DesktopEventStreamConfig {
|
||||
base_url: "http://127.0.0.1:4096".to_string(),
|
||||
events_url: "http://127.0.0.1:4096/api/events".to_string(),
|
||||
client_id: "tauri-test".to_string(),
|
||||
connection_id: connection_id.to_string(),
|
||||
cookie_name: "codenomad_session".to_string(),
|
||||
session_cookie: Some("cookie-value".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_event(delta: &str) -> Value {
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1",
|
||||
"partID": "part-1",
|
||||
"field": "text",
|
||||
"delta": delta,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delta_event_for(part_id: &str, delta: &str) -> Value {
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1",
|
||||
"partID": part_id,
|
||||
"field": "text",
|
||||
"delta": delta,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_delta_event(delta: &str) -> Value {
|
||||
json!({
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1",
|
||||
"partID": "part-1",
|
||||
"field": "text",
|
||||
"delta": delta,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_message_part_updated_event(text: &str) -> Value {
|
||||
json!({
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"id": "part-1",
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn message_part_updated_event(text: &str) -> Value {
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"id": "part-1",
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_message_part_delta_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event("Hello"), &mut stats);
|
||||
pending.push(delta_event(" world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["delta"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_write_wins_for_status_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.eventStatus",
|
||||
"instanceId": "inst-1",
|
||||
"status": "connecting"
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.eventStatus",
|
||||
"instanceId": "inst-1",
|
||||
"status": "connected"
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["status"].as_str(), Some("connected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_write_wins_for_consecutive_snapshot_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(message_part_updated_event("Hello"), &mut stats);
|
||||
pending.push(message_part_updated_event("Hello world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["part"]["text"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_snapshot_keys_keep_order() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(message_part_updated_event("A1"), &mut stats);
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"id": "part-2",
|
||||
"type": "text",
|
||||
"text": "B1",
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
pending.push(message_part_updated_event("A2"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["part"]["id"].as_str(),
|
||||
Some("part-1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1]["event"]["properties"]["part"]["id"].as_str(),
|
||||
Some("part-2")
|
||||
);
|
||||
assert_eq!(
|
||||
events[2]["event"]["properties"]["part"]["text"].as_str(),
|
||||
Some("A2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_replaces_trailing_deltas_for_same_part() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event("Hello"), &mut stats);
|
||||
pending.push(message_part_updated_event("Hello world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["event"]["type"].as_str(),
|
||||
Some("message.part.updated")
|
||||
);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["part"]["text"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_events_force_coalesced_flush_before_append() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event("Hello"), &mut stats);
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.updated",
|
||||
"properties": {
|
||||
"id": "msg-1"
|
||||
}
|
||||
}
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(
|
||||
events[0]["event"]["type"].as_str(),
|
||||
Some("message.part.delta")
|
||||
);
|
||||
assert_eq!(events[1]["event"]["type"].as_str(), Some("message.updated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_delta_keys_keep_order() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event_for("part-1", "A1"), &mut stats);
|
||||
pending.push(delta_event_for("part-2", "B1"), &mut stats);
|
||||
pending.push(delta_event_for("part-1", "A2"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["partID"].as_str(),
|
||||
Some("part-1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["delta"].as_str(),
|
||||
Some("A1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1]["event"]["properties"]["partID"].as_str(),
|
||||
Some("part-2")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1]["event"]["properties"]["delta"].as_str(),
|
||||
Some("B1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[2]["event"]["properties"]["partID"].as_str(),
|
||||
Some("part-1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[2]["event"]["properties"]["delta"].as_str(),
|
||||
Some("A2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_delay_grows_and_caps() {
|
||||
let policy = ResolvedDesktopEventReconnectPolicy {
|
||||
initial_delay_ms: 100,
|
||||
max_delay_ms: 500,
|
||||
multiplier: 2.0,
|
||||
max_attempts: None,
|
||||
};
|
||||
|
||||
assert_eq!(compute_reconnect_delay_ms(1, &policy), 100);
|
||||
assert_eq!(compute_reconnect_delay_ms(2, &policy), 200);
|
||||
assert_eq!(compute_reconnect_delay_ms(3, &policy), 400);
|
||||
assert_eq!(compute_reconnect_delay_ms(4, &policy), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_single_delta_within_stream_window() {
|
||||
let pending = PendingBatch {
|
||||
events: vec![PendingEntry::Delta {
|
||||
key: "delta-key".to_string(),
|
||||
scope: "delta-scope".to_string(),
|
||||
event: delta_event("Hello"),
|
||||
started_at: Instant::now(),
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(pending.should_hold_single_delta(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flushes_single_delta_after_stream_window() {
|
||||
let started_at = Instant::now() - Duration::from_millis(DELTA_STREAM_WINDOW_MS + 1);
|
||||
let pending = PendingBatch {
|
||||
events: vec![PendingEntry::Delta {
|
||||
key: "delta-key".to_string(),
|
||||
scope: "delta-scope".to_string(),
|
||||
event: delta_event("Hello"),
|
||||
started_at,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(!pending.should_hold_single_delta(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_direct_message_part_delta_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(direct_delta_event("Hello"), &mut stats);
|
||||
pending.push(direct_delta_event(" world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["properties"]["delta"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(direct_delta_event("Hello"), &mut stats);
|
||||
pending.push(direct_message_part_updated_event("Hello world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["type"].as_str(), Some("message.part.updated"));
|
||||
assert_eq!(
|
||||
events[0]["properties"]["part"]["text"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_transport_start_ignores_fresh_connection_id() {
|
||||
let request = DesktopEventsStartRequest::default();
|
||||
let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request);
|
||||
let second = DesktopEventTransportConfig::new(stream_config("conn-2"), &request);
|
||||
|
||||
assert!(first.is_equivalent_start(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_transport_start_detects_material_stream_changes() {
|
||||
let request = DesktopEventsStartRequest::default();
|
||||
let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request);
|
||||
let mut changed_stream = stream_config("conn-2");
|
||||
changed_stream.session_cookie = Some("other-cookie".to_string());
|
||||
let second = DesktopEventTransportConfig::new(changed_stream, &request);
|
||||
|
||||
assert!(!first.is_equivalent_start(&second));
|
||||
}
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
fn send_connection_pong(
|
||||
app: &AppHandle,
|
||||
client: &Client,
|
||||
config: &DesktopEventStreamConfig,
|
||||
payload: &Value,
|
||||
) {
|
||||
let body = serde_json::json!({
|
||||
"clientId": config.client_id,
|
||||
"connectionId": config.connection_id,
|
||||
"pingTs": payload.get("ts").and_then(Value::as_u64),
|
||||
});
|
||||
|
||||
let request = client
|
||||
.post(format!(
|
||||
"{}/api/client-connections/pong",
|
||||
config.base_url.trim_end_matches('/')
|
||||
))
|
||||
.json(&body);
|
||||
|
||||
let _ = attach_session_cookie(request, app, config).send();
|
||||
}
|
||||
|
||||
pub(super) fn run_transport_loop(
|
||||
app: AppHandle,
|
||||
generation_atomic: Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
config: DesktopEventTransportConfig,
|
||||
) {
|
||||
let mut reconnect_attempt = 0_u32;
|
||||
let mut stats = DesktopEventTransportStats::default();
|
||||
|
||||
let client = match build_stream_client() {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"error",
|
||||
0,
|
||||
true,
|
||||
Some(error.message),
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) {
|
||||
break;
|
||||
}
|
||||
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"connecting",
|
||||
reconnect_attempt,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
|
||||
match open_stream(&app, &client, &config.stream) {
|
||||
Ok(response) => {
|
||||
reconnect_attempt = 0;
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"connected",
|
||||
reconnect_attempt,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
|
||||
let disconnect_reason = consume_stream(
|
||||
&app,
|
||||
&client,
|
||||
&config.stream,
|
||||
response,
|
||||
&generation_atomic,
|
||||
generation,
|
||||
stop.clone(),
|
||||
&mut stats,
|
||||
);
|
||||
if stop.load(Ordering::SeqCst)
|
||||
|| !generation_matches(&generation_atomic, generation)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if !schedule_retry(
|
||||
&app,
|
||||
&generation_atomic,
|
||||
generation,
|
||||
stop.clone(),
|
||||
&config.reconnect,
|
||||
&mut reconnect_attempt,
|
||||
"disconnected",
|
||||
disconnect_reason,
|
||||
None,
|
||||
&stats,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let state_name = match error.kind {
|
||||
OpenStreamErrorKind::Unauthorized => "unauthorized",
|
||||
OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error",
|
||||
};
|
||||
|
||||
if !schedule_retry(
|
||||
&app,
|
||||
&generation_atomic,
|
||||
generation,
|
||||
stop.clone(),
|
||||
&config.reconnect,
|
||||
&mut reconnect_attempt,
|
||||
state_name,
|
||||
Some(error.message),
|
||||
error.status_code,
|
||||
&stats,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"stopped",
|
||||
reconnect_attempt,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
}
|
||||
|
||||
fn schedule_retry(
|
||||
app: &AppHandle,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
policy: &ResolvedDesktopEventReconnectPolicy,
|
||||
reconnect_attempt: &mut u32,
|
||||
state_name: &'static str,
|
||||
reason: Option<String>,
|
||||
status_code: Option<u16>,
|
||||
stats: &DesktopEventTransportStats,
|
||||
) -> bool {
|
||||
*reconnect_attempt = reconnect_attempt.saturating_add(1);
|
||||
let terminal = policy
|
||||
.max_attempts
|
||||
.map(|max_attempts| *reconnect_attempt >= max_attempts)
|
||||
.unwrap_or(false);
|
||||
let next_delay_ms = if terminal {
|
||||
None
|
||||
} else {
|
||||
Some(compute_reconnect_delay_ms(*reconnect_attempt, policy))
|
||||
};
|
||||
|
||||
emit_status(
|
||||
app,
|
||||
generation,
|
||||
state_name,
|
||||
*reconnect_attempt,
|
||||
terminal,
|
||||
reason,
|
||||
next_delay_ms,
|
||||
status_code,
|
||||
stats,
|
||||
);
|
||||
|
||||
if terminal {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(delay_ms) = next_delay_ms {
|
||||
wait_with_cancellation(generation_atomic, generation, stop, delay_ms);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn wait_with_cancellation(
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
delay_ms: u64,
|
||||
) {
|
||||
let mut remaining_ms = delay_ms;
|
||||
while remaining_ms > 0 {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk_ms = remaining_ms.min(100);
|
||||
thread::sleep(Duration::from_millis(chunk_ms));
|
||||
remaining_ms -= chunk_ms;
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_stream(
|
||||
app: &AppHandle,
|
||||
client: &Client,
|
||||
stream_config: &DesktopEventStreamConfig,
|
||||
response: Response,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
stats: &mut DesktopEventTransportStats,
|
||||
) -> Option<String> {
|
||||
let (tx, rx) = mpsc::sync_channel::<ReaderMessage>(4096);
|
||||
let reader_stop = stop.clone();
|
||||
let reader_generation_atomic = generation_atomic.clone();
|
||||
thread::spawn(move || {
|
||||
read_sse(
|
||||
response,
|
||||
tx,
|
||||
reader_stop,
|
||||
reader_generation_atomic,
|
||||
generation,
|
||||
)
|
||||
});
|
||||
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut sequence = 0_u64;
|
||||
let mut last_reader_activity = Instant::now();
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) {
|
||||
return Some("stopped".to_string());
|
||||
}
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) {
|
||||
Ok(ReaderMessage::Activity) => {
|
||||
last_reader_activity = Instant::now();
|
||||
}
|
||||
Ok(ReaderMessage::Ping(payload)) => {
|
||||
last_reader_activity = Instant::now();
|
||||
send_connection_pong(app, client, stream_config, &payload);
|
||||
}
|
||||
Ok(ReaderMessage::Event(event)) => {
|
||||
last_reader_activity = Instant::now();
|
||||
stats.raw_events = stats.raw_events.saturating_add(1);
|
||||
|
||||
pending.push(event, stats);
|
||||
if pending.pending_len() >= MAX_BATCH_EVENTS {
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(ReaderMessage::End(reason)) => {
|
||||
if !pending.is_empty() {
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS)
|
||||
{
|
||||
if !pending.is_empty() {
|
||||
sequence += 1;
|
||||
emit_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
return Some("stream stalled".to_string());
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
if pending.should_hold_single_delta(Instant::now()) {
|
||||
continue;
|
||||
}
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
if !pending.is_empty() {
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
return Some("reader disconnected".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_pending_batch(
|
||||
app: &AppHandle,
|
||||
generation: u64,
|
||||
pending: &mut PendingBatch,
|
||||
sequence: &mut u64,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
stats: &mut DesktopEventTransportStats,
|
||||
) {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
*sequence += 1;
|
||||
emit_batch(
|
||||
app,
|
||||
generation,
|
||||
pending,
|
||||
*sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_batch(
|
||||
app: &AppHandle,
|
||||
generation: u64,
|
||||
pending: &mut PendingBatch,
|
||||
sequence: u64,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
stats: &mut DesktopEventTransportStats,
|
||||
) {
|
||||
if !generation_matches(generation_atomic, generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let events = pending.take_events();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
stats.emitted_batches = stats.emitted_batches.saturating_add(1);
|
||||
stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64);
|
||||
|
||||
let _ = app.emit(
|
||||
EVENT_BATCH_NAME,
|
||||
WorkspaceEventBatchPayload {
|
||||
generation,
|
||||
sequence,
|
||||
emitted_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
events,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_status(
|
||||
app: &AppHandle,
|
||||
generation: u64,
|
||||
state_name: &'static str,
|
||||
reconnect_attempt: u32,
|
||||
terminal: bool,
|
||||
reason: Option<String>,
|
||||
next_delay_ms: Option<u64>,
|
||||
status_code: Option<u16>,
|
||||
stats: &DesktopEventTransportStats,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
EVENT_STATUS_NAME,
|
||||
DesktopEventStreamStatusPayload {
|
||||
generation,
|
||||
state: state_name,
|
||||
reconnect_attempt,
|
||||
terminal,
|
||||
reason,
|
||||
next_delay_ms,
|
||||
status_code,
|
||||
stats: stats.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn generation_matches(generation_atomic: &Arc<AtomicU64>, generation: u64) -> bool {
|
||||
generation_atomic.load(Ordering::SeqCst) == generation
|
||||
}
|
||||
|
||||
pub(super) fn compute_reconnect_delay_ms(
|
||||
attempt: u32,
|
||||
policy: &ResolvedDesktopEventReconnectPolicy,
|
||||
) -> u64 {
|
||||
let exponent = attempt.saturating_sub(1) as i32;
|
||||
let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent);
|
||||
(scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms)
|
||||
}
|
||||
|
|
@ -4,18 +4,14 @@
|
|||
mod cert_manager;
|
||||
mod cli_manager;
|
||||
mod client_state;
|
||||
mod desktop_event_transport;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_tls;
|
||||
mod managed_node;
|
||||
mod shutdown;
|
||||
mod windows_update;
|
||||
mod worktree_file_manager;
|
||||
mod workspace_open;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use desktop_event_transport::{
|
||||
DesktopEventTransportManager, DesktopEventsStartRequest, DesktopEventsStartResult,
|
||||
};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
|
@ -24,7 +20,9 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::future::Future;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, SubmenuBuilder};
|
||||
use tauri::menu::{
|
||||
AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder,
|
||||
};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::{PageLoadEvent, Webview};
|
||||
use tauri::{
|
||||
|
|
@ -48,20 +46,84 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|||
const ZOOM_STEP: f64 = 0.1;
|
||||
const RELEASES_URL: &str = "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest";
|
||||
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
|
||||
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str =
|
||||
"window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub desktop_events: DesktopEventTransportManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
|
||||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||
pub remote_titles: Mutex<HashMap<String, String>>,
|
||||
pub workspace_menu_items: Mutex<Option<WorkspaceMenuItems>>,
|
||||
pub workspace_menu_requested_enabled: Mutex<bool>,
|
||||
}
|
||||
|
||||
pub struct WorkspaceMenuItems {
|
||||
folder: MenuItem<Wry>,
|
||||
terminal: MenuItem<Wry>,
|
||||
editor: Submenu<Wry>,
|
||||
}
|
||||
|
||||
fn update_workspace_menu_state(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let requested = state
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
.map(|value| *value)
|
||||
.unwrap_or(false);
|
||||
let focused = app
|
||||
.get_webview_window("main")
|
||||
.and_then(|window| window.is_focused().ok())
|
||||
.unwrap_or(false);
|
||||
if let Ok(items) = state.workspace_menu_items.lock() {
|
||||
if let Some(items) = items.as_ref() {
|
||||
let enabled = requested && focused;
|
||||
let _ = items.folder.set_enabled(enabled);
|
||||
let _ = items.terminal.set_enabled(enabled);
|
||||
let _ = items.editor.set_enabled(enabled);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_workspace_menu_enabled(
|
||||
window: tauri::WebviewWindow,
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
if window.label() != "main" {
|
||||
return Err("Workspace menu updates are limited to the local main window".into());
|
||||
}
|
||||
if !enabled {
|
||||
*state
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())? = false;
|
||||
update_workspace_menu_state(&app);
|
||||
return Ok(());
|
||||
}
|
||||
let config = state
|
||||
.manager
|
||||
.local_cli_access()
|
||||
.ok_or("Local CodeNomad server is unavailable")?;
|
||||
let expected = Url::parse(&config.base_url).map_err(|error| error.to_string())?;
|
||||
let current = window.url().map_err(|error| error.to_string())?;
|
||||
if current.origin() != expected.origin() {
|
||||
return Err("Workspace menu updates require the local CodeNomad origin".into());
|
||||
}
|
||||
*state
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())? = enabled;
|
||||
update_workspace_menu_state(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -139,7 +201,6 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
|||
#[tauri::command]
|
||||
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
|
||||
let dev_mode = is_dev_mode();
|
||||
state.desktop_events.stop();
|
||||
state.manager.stop().map_err(|e| e.to_string())?;
|
||||
state
|
||||
.manager
|
||||
|
|
@ -148,21 +209,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_events_start(
|
||||
app: AppHandle,
|
||||
state: tauri::State<AppState>,
|
||||
request: Option<DesktopEventsStartRequest>,
|
||||
) -> DesktopEventsStartResult {
|
||||
let config = state.manager.desktop_event_stream_config();
|
||||
state.desktop_events.start(app, config, request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_events_stop(state: tauri::State<AppState>) {
|
||||
state.desktop_events.stop();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_start(
|
||||
state: tauri::State<AppState>,
|
||||
|
|
@ -219,19 +265,20 @@ fn should_allow_window_origin<R: Runtime>(
|
|||
window_label: &str,
|
||||
url: &Url,
|
||||
) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let Ok(allowed) = state.remote_origins.lock() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(origin) = allowed.get(window_label) {
|
||||
return origin == &url.origin().ascii_serialization();
|
||||
}
|
||||
should_allow_registered_origin(allowed.get(window_label).map(String::as_str), url)
|
||||
}
|
||||
|
||||
false
|
||||
fn should_allow_registered_origin(registered_origin: Option<&str>, url: &Url) -> bool {
|
||||
if let Some(origin) = registered_origin {
|
||||
if matches!(url.scheme(), "http" | "https") {
|
||||
return origin == url.origin().ascii_serialization();
|
||||
}
|
||||
}
|
||||
should_allow_internal(url)
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
|
|
@ -367,6 +414,9 @@ async fn open_remote_window_impl(
|
|||
let app_handle = app.clone();
|
||||
let label_for_cleanup = label.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Focused(_)) {
|
||||
update_workspace_menu_state(&app_handle);
|
||||
}
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label_for_cleanup);
|
||||
|
|
@ -567,6 +617,19 @@ fn set_windows_app_user_model_id() {
|
|||
#[cfg(not(windows))]
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn isolate_windows_webview_profile() {
|
||||
if std::env::var_os("WEBVIEW2_USER_DATA_FOLDER").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(root) = dirs::data_local_dir() {
|
||||
std::env::set_var(
|
||||
"WEBVIEW2_USER_DATA_FOLDER",
|
||||
root.join("ai.neuralnomads.codenomad.client-v2"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
if let Some(code) = cli_manager::run_windows_cli_launcher_if_requested() {
|
||||
|
|
@ -574,6 +637,8 @@ fn main() {
|
|||
}
|
||||
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
#[cfg(windows)]
|
||||
isolate_windows_webview_profile();
|
||||
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
|
|
@ -599,15 +664,27 @@ fn main() {
|
|||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
desktop_events: DesktopEventTransportManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
remote_proxy_sessions: Mutex::new(HashMap::new()),
|
||||
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||
remote_titles: Mutex::new(HashMap::new()),
|
||||
workspace_menu_items: Mutex::new(None),
|
||||
workspace_menu_requested_enabled: Mutex::new(false),
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if webview.label() == "main" && payload.event() == PageLoadEvent::Started {
|
||||
if let Ok(mut enabled) = webview
|
||||
.app_handle()
|
||||
.state::<AppState>()
|
||||
.workspace_menu_requested_enabled
|
||||
.lock()
|
||||
{
|
||||
*enabled = false;
|
||||
}
|
||||
update_workspace_menu_state(&webview.app_handle());
|
||||
}
|
||||
if matches!(
|
||||
payload.event(),
|
||||
PageLoadEvent::Started | PageLoadEvent::Finished
|
||||
|
|
@ -625,9 +702,15 @@ fn main() {
|
|||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
#[cfg(windows)]
|
||||
shutdown::install_windows_session_end_handler(&window)
|
||||
shutdown::schedule_windows_session_end_handler(&window)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
|
||||
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Focused(_)) {
|
||||
update_workspace_menu_state(&app_handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
|
|
@ -661,8 +744,6 @@ fn main() {
|
|||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
desktop_events_start,
|
||||
desktop_events_stop,
|
||||
wake_lock_start,
|
||||
wake_lock_stop,
|
||||
needs_local_certificate_install,
|
||||
|
|
@ -674,15 +755,27 @@ fn main() {
|
|||
client_state::client_state_clear,
|
||||
client_state::client_state_renderer_flushed,
|
||||
client_state::client_state_navigation_flushed,
|
||||
worktree_file_manager::open_worktree_in_file_manager,
|
||||
windows_update::install_stable_update
|
||||
windows_update::install_stable_update,
|
||||
workspace_open::open_workspace_target,
|
||||
set_workspace_menu_enabled
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
"new_instance" => {
|
||||
action @ ("new-instance"
|
||||
| "open-workspace-folder"
|
||||
| "open-workspace-terminal"
|
||||
| "open-workspace-editor-vscode"
|
||||
| "open-workspace-editor-cursor"
|
||||
| "open-workspace-editor-zed"
|
||||
| "open-workspace-editor-vscodium") => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("menu:newInstance", ());
|
||||
if action.starts_with("open-workspace-")
|
||||
&& !window.is_focused().unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = window.emit("menu:action", action);
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
|
|
@ -885,22 +978,60 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||
// File menu - create New Instance with accelerator
|
||||
let new_instance_item = MenuItem::with_id(
|
||||
app,
|
||||
"new_instance",
|
||||
"new-instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
let open_folder_item = MenuItem::with_id(
|
||||
app,
|
||||
"open-workspace-folder",
|
||||
"Open Project Folder",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_terminal_item = MenuItem::with_id(
|
||||
app,
|
||||
"open-workspace-terminal",
|
||||
"Open Terminal Here",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_editor_menu = SubmenuBuilder::new(app, "Open Project In")
|
||||
.text("open-workspace-editor-vscode", "VS Code")
|
||||
.text("open-workspace-editor-cursor", "Cursor")
|
||||
.text("open-workspace-editor-zed", "Zed")
|
||||
.text("open-workspace-editor-vscodium", "VSCodium")
|
||||
.build()?;
|
||||
open_folder_item.set_enabled(false)?;
|
||||
open_terminal_item.set_enabled(false)?;
|
||||
open_editor_menu.set_enabled(false)?;
|
||||
if let Ok(mut items) = app.state::<AppState>().workspace_menu_items.lock() {
|
||||
*items = Some(WorkspaceMenuItems {
|
||||
folder: open_folder_item.clone(),
|
||||
terminal: open_terminal_item.clone(),
|
||||
editor: open_editor_menu.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let file_menu = if is_mac {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.item(&open_folder_item)
|
||||
.item(&open_terminal_item)
|
||||
.item(&open_editor_menu)
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.item(&open_folder_item)
|
||||
.item(&open_terminal_item)
|
||||
.item(&open_editor_menu)
|
||||
.separator()
|
||||
.text("quit", "Quit")
|
||||
.build()?
|
||||
};
|
||||
|
|
@ -1061,8 +1192,13 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada
|
|||
|
||||
#[cfg(test)]
|
||||
mod menu_tests {
|
||||
use super::{build_about_metadata, run_update_with_fallback, RELEASES_URL};
|
||||
use super::{
|
||||
build_about_metadata, run_update_with_fallback, should_allow_registered_origin,
|
||||
RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn failed_update_uses_release_fallback() {
|
||||
|
|
@ -1093,4 +1229,53 @@ mod menu_tests {
|
|||
assert_eq!(metadata.website, None);
|
||||
assert_eq!(metadata.website_label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_windows_identify_as_remote_tauri_windows() {
|
||||
assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_RUNTIME_HOST__ = 'tauri'"));
|
||||
assert!(REMOTE_WINDOW_CONTEXT_SCRIPT.contains("__CODENOMAD_WINDOW_CONTEXT__ = 'remote'"));
|
||||
|
||||
let capability: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../capabilities/remote-window-notifications.json"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(capability["local"], false);
|
||||
assert_eq!(
|
||||
capability["remote"]["urls"],
|
||||
json!(["http://*:*", "https://*:*"])
|
||||
);
|
||||
assert_eq!(capability["windows"], json!(["remote-*"]));
|
||||
assert_eq!(
|
||||
capability["permissions"],
|
||||
json!([
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
])
|
||||
);
|
||||
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
assert!(config["app"]["security"]["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("remote-window-notifications")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_windows_stay_on_their_registered_http_origin() {
|
||||
let origin = "https://remote.example:9898";
|
||||
assert!(should_allow_registered_origin(
|
||||
Some(origin),
|
||||
&Url::parse("https://remote.example:9898/settings").unwrap()
|
||||
));
|
||||
assert!(!should_allow_registered_origin(
|
||||
Some(origin),
|
||||
&Url::parse("http://localhost:9898/").unwrap()
|
||||
));
|
||||
assert!(should_allow_registered_origin(
|
||||
Some(origin),
|
||||
&Url::parse("about:blank").unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue