diff --git a/.github/workflows/comment-pr-artifacts.yml b/.github/workflows/comment-pr-artifacts.yml index e6383bcf6..e296828ba 100644 --- a/.github/workflows/comment-pr-artifacts.yml +++ b/.github/workflows/comment-pr-artifacts.yml @@ -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 diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index c73a8f1a5..6ea701dd7 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -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,10 +104,20 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/browser-frame-security.test.ts + 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/session-idle-attention.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/client-identity.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/hooks/use-electron-folder-launch.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 @@ -115,30 +125,43 @@ jobs: 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-partitions.test.ts packages/ui/src/stores/client-state.test.ts - packages/ui/src/stores/instances-restore-cancellation.test.ts + packages/ui/src/stores/message-prompt-display.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/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts - packages/ui/src/stores/session-metadata.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-pending-state.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/app-tabs.test.ts + packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts + packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/permission-lifecycle.test.ts + packages/ui/src/stores/shell-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 - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" @@ -184,4 +207,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 diff --git a/.github/workflows/restrict-non-dev-prs.yml b/.github/workflows/restrict-non-dev-prs.yml index ab27f943a..d8367109a 100644 --- a/.github/workflows/restrict-non-dev-prs.yml +++ b/.github/workflows/restrict-non-dev-prs.yml @@ -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' }} diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 0066a61fa..650d67908 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -1,153 +1,81 @@ --- name: codenomad-architecture-guide description: | - Comprehensive architecture and SDK navigation guide for the CodeNomad codebase. - - **When to use:** Load this skill when you need to navigate the CodeNomad monorepo, understand cross-package dependencies, work with the OpenCode SDK V2, or ensure you don't miss related code when implementing features or fixing bugs. This skill covers the 6 functional areas (ServerBackend, UserInterface, DesktopClient, SpeechAndAudio, BuildAndPackaging, CloudflareDeployment), OpenCode SDK V2 integration patterns, critical schema behaviors, and feature traces with decision branches. - - **Trigger contexts:** Working on CodeNomad features, debugging cross-area issues, integrating OpenCode SDK APIs, adding UI components, implementing server routes, or navigating the monorepo structure. - - **Permission required:** Agent must explicitly request or be granted permission to load this skill. + Architecture and native OpenCode V2 navigation guide for CodeNomad. Use for cross-package changes, OpenCode client calls, server routes, events, workspaces, Git, Yolo, UI, or desktop integration. Permission is required before loading. --- -# CodeNomad Architecture & SDK Navigation Skill - -## Quick Start (by contribution frequency) - -- **UI component/feature (60%)** → Read `references/ui-conventions.md` → Check i18n -- **Server route/feature (25%)** → Read `references/server-conventions.md` → Check `references/feature-traces.md` -- **Bug fix (10%)** → Use Navigation Guide below → Check `references/feature-traces.md` -- **Desktop/Plugin (5%)** → Read `references/desktop-conventions.md` -- **Not covered?** → See "Escape Hatch" at bottom - -## 1. Architecture Overview - -CodeNomad is a multi-platform desktop application with a Fastify backend and SolidJS frontend. - -### 6 Functional Areas (from RPG analysis) - -| Area | Entities | Key Responsibility | -|------|----------|-------------------| -| **UserInterface** | 613 | SolidJS components, stores, hooks, i18n, API client | -| **ServerBackend** | 418 | Fastify routes, auth, workspaces, filesystem, speech | -| **SpeechAndAudio** | 74 | Speech synthesis, voice mode, conversation mode | -| **DesktopClient** | 59 | Electron main, Tauri Rust, preload, IPC | -| **BuildAndPackaging** | 28 | Build scripts, packaging, resource bundling | -| **CloudflareDeployment** | 3 | Edge deployment, asset serving | - -### Package Map - -- `packages/server/` — Fastify backend, workspaces, auth, speech, sidecars -- `packages/ui/` — SolidJS frontend, stores, components, i18n -- `packages/electron-app/` — Electron desktop wrapper -- `packages/tauri-app/` — Tauri desktop wrapper (Rust + webview) -- `packages/opencode-plugin/` — OpenCode plugin integration - -### Key Entry Points - -- **Server:** `packages/server/src/index.ts` (CLI entry) -- **UI:** `packages/ui/src/main.tsx` (app bootstrap) -- **Electron:** `packages/electron-app/electron/main/main.ts` -- **Tauri:** `packages/tauri-app/src-tauri/src/main.rs` - -## 2. Navigation Guide - -### Finding Code in the Codebase - -Use grep and file search tools to navigate: - -**Search by intent:** -- `grep "permission approval" packages/ui/src/components/` -- `grep "session list" packages/ui/src/stores/` -- `grep "workspace create" packages/server/src/server/routes/` - -**Search by imports:** -- Find what uses a module: `grep "import.*from.*module-path" packages/` -- Find exports: `grep "^export" packages/server/src/api-types.ts` - -**Cross-reference by feature:** -- Server API types: `packages/server/src/api-types.ts` -- UI type mirrors: `packages/ui/src/types/` -- SDK wrappers: `packages/ui/src/lib/sdk-manager.ts` - -## 3. SDK Schema Verification (Mandatory) - -**SDK Note:** The OpenCode SDK is an external package (`@opencode-ai/sdk/v2/client`). Its implementation lives outside this repository. - -- After `npm install`, you can inspect types in `node_modules/@opencode-ai/sdk/v2/client.d.ts` -- **Fallback:** Read the actual usage patterns in CodeNomad code (see `references/sdk-api-reference.md` for file locations) -- When in doubt, check how the SDK is imported and used in existing CodeNomad files - -This skill provides navigation and patterns, not definitive schemas. - -## 4. Anti-Patterns - -### Common Mistakes - -| Mistake | Correct Approach | Reference | -|---------|-----------------|-----------| -| Import `enMessages` directly | Use `t()` or `tGlobal()` | `packages/ui/src/lib/i18n/index.tsx` | -| Set `metadata: { flag: true }` on assistant parts | Use client-side registry | `packages/ui/src/stores/session-compaction.ts` | -| Call `client.session.*` directly without worktree routing | Use `getOrCreateWorktreeClient()` | `packages/ui/src/stores/worktrees.ts` | -| Forget SSE disconnection handling | Add handlers | `packages/ui/src/lib/event-source-handlers.ts` | -| Add hardcoded strings without i18n | Add to English + all 7 locales | `packages/ui/src/lib/i18n/messages/` | -| Modify server route without checking UI API client | Trace full feature flow | `references/feature-traces.md` | -| Change API type without checking UI type matches | Check UI types mirror server types | `packages/ui/src/types/` vs `packages/server/src/api-types.ts` | - -## 5. Platform Integration Checklist - -### Desktop Platform Rules - -- **Existing IPC/handlers (pre-Tauri):** MUST implement in both Electron + Tauri -- **New features:** Implement in Electron first, Tauri if time permits -- **Native APIs (dialogs, notifications):** Use `packages/ui/src/lib/native/` abstraction - -### Checklist - -- [ ] Electron main-process changes? (`packages/electron-app/electron/main/`) -- [ ] Tauri Rust changes? (`packages/tauri-app/src-tauri/src/`) -- [ ] Preload API exposure? (`packages/electron-app/electron/preload/`) -- [ ] Native abstraction? (`packages/ui/src/lib/native/`) - -## 6. Implementation Checklist - -Before submitting changes: - -- [ ] Run impact analysis: `grep "YOUR_EXPORT_NAME" packages/` to find all usages -- [ ] Check i18n: Search for hardcoded strings in modified files -- [ ] Verify file length: Check line count (warn >500, reject >800 source; >1000 tests) -- [ ] Check DesktopClient: Does this need IPC/main-process changes? -- [ ] Verify SDK compatibility: Check types in `node_modules/@opencode-ai/sdk/v2/client.d.ts` -- [ ] Cross-area check: If modifying server routes, check UI stores and API clients -- [ ] Check anti-patterns: Review "Common Mistakes" section above -- [ ] API compatibility: If changing `api-types.ts`, check UI type matches - -## 7. Escape Hatch + Update Criteria - -### Not Covered? - -If your change involves areas not documented here: - -1. Read package entry points and scan directory structure -2. Ask the user before proceeding with unfamiliar code - -### Update This Skill If - -- You discover a new SDK gotcha not documented in `references/sdk-critical-behaviors.md` -- You add a new cross-area feature flow (add to `references/feature-traces.md`) -- File paths or conventions change significantly -- You find an anti-pattern occurring repeatedly -- SDK schemas change and examples become outdated - -## Reference Files - -| File | Purpose | -|------|---------| -| `references/architecture-overview.md` | Package structure, functional areas, entry points | -| `references/ui-conventions.md` | SolidJS, i18n, stores, components, testing | -| `references/server-conventions.md` | Fastify, API types, config, testing | -| `references/desktop-conventions.md` | Electron + Tauri parity, native abstractions | -| `references/sdk-api-reference.md` | OpenCode SDK V2 categories and signatures | -| `references/sdk-critical-behaviors.md` | Schema gotchas, limitations, decision matrix | -| `references/sdk-integration-patterns.md` | Client lifecycle, error handling, optimistic updates | -| `references/feature-traces.md` | End-to-end flows with decision branches | +# CodeNomad Architecture Guide + +## Start Here + +- UI: read `references/ui-conventions.md`; use i18n for visible text. +- Server: read `references/server-conventions.md` and `references/feature-traces.md`. +- OpenCode: read the three `sdk-*.md` references before changing client calls or service lifecycle. +- Desktop: read `references/desktop-conventions.md`. + +## Native OpenCode V2 Baseline + +- The only OpenCode client dependency is the experimental `@opencode-ai/client` protocol. Server and UI stay on the same reviewed release; the selected runtime CLI is independently updated and validated through service/API compatibility rather than an exact version gate. 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 uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global OpenCode daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and uses no cross-namespace PID operations. +- The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. +- OpenCode owns session APIs, native Forms, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. Question request/reply/reject routes are compatibility-only; new interruption flows use `client.form.*`. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. +- CodeNomad owns explicit Stop Workspace eviction, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. Tab/window close only detaches local UI and never evicts. +- OpenCode owns the global daemon's standard state and database. Allowed configured environment variables apply only to `service start` for a missing daemon; an existing daemon is unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. +- Native desktop identity is channel plus config profile: one singleton process/backend per profile, multiple UUID windows, second-launch focus by default, and `--new-window` for another window. Stable/dev/non-default profiles isolate native state; OpenCode sessions/messages are shared while tabs/drafts/views are per-window. +- Client-state V3 is a per-window envelope over the V2 content-addressed partition graph with atomic publication/migration, ownership-fenced writes, and conservative post-commit GC. Native SideCar/browser previews are sandboxed without same-origin access; DOM comment inspection is web-only. + +## Package Map + +- `packages/server/`: Fastify control API, shared OpenCode service, locations, auth, filesystem, Git, Yolo, speech. +- `packages/ui/`: SolidJS application, generated client adapters, stores, components, i18n. +- `packages/electron-app/`: Electron host. +- `packages/tauri-app/`: Tauri host. +- `packages/cloudflare/`: edge deployment. + +## Integration Paths + +- Shared service: `packages/server/src/workspaces/opencode-service.ts` +- Location ownership: `packages/server/src/workspaces/manager.ts` +- OpenCode proxy: `packages/server/src/server/http-server.ts` +- CodeNomad API client/events: `packages/ui/src/lib/api-client.ts` +- OpenCode client cache: `packages/ui/src/lib/sdk-manager.ts` +- Root client authority: `packages/ui/src/stores/opencode-client.ts` +- Native session calls: `packages/ui/src/stores/session-api.ts`, `session-actions.ts` +- Git mutations: `packages/server/src/workspaces/git-mutations.ts` +- Yolo: `packages/server/src/permissions/`, `packages/server/src/server/routes/yolo.ts` +- Desktop hosts: `packages/electron-app/electron/main/`, `packages/electron-app/electron/preload/index.cjs`, `packages/tauri-app/src-tauri/src/` + +## Rules + +- 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. 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. +- Desktop behavior must remain at strict Electron/Tauri parity in the same change; use the shared native abstraction and test both hosts. + +## Anti-Patterns + +| Avoid | Use | +|---|---| +| Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` declarations | +| One `opencode serve` per workspace | One externally owned global daemon through the official CLI lifecycle | +| Per-worktree clients/processes | Root proxy client plus native location/directory inputs | +| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Native session Shell/instructions, background `shell.*`, and separate interactive `pty.*` management | +| OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | +| Hardcoded UI strings | `t()` / `tGlobal()` and every locale | + +## References + +- `references/architecture-overview.md` +- `references/server-conventions.md` +- `references/sdk-api-reference.md` +- `references/sdk-integration-patterns.md` +- `references/sdk-critical-behaviors.md` +- `references/feature-traces.md` +- `references/ui-conventions.md` +- `references/desktop-conventions.md` diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index 1b4acf785..9c921285c 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -1,76 +1,55 @@ # Architecture Overview -## Package Structure - -| Package | Purpose | Key Subdirectories | -|---------|---------|-------------------| -| `packages/server/` | Fastify backend | `src/server/routes/`, `src/workspaces/`, `src/auth/`, `src/speech/` | -| `packages/ui/` | SolidJS frontend | `src/components/`, `src/stores/`, `src/lib/`, `src/types/` | -| `packages/electron-app/` | Electron desktop wrapper | `electron/main/`, `electron/preload/`, `electron/resources/` | -| `packages/tauri-app/` | Tauri desktop wrapper | `src-tauri/src/`, `src-tauri/capabilities/` | -| `packages/opencode-plugin/` | OpenCode plugin integration | `plugin/lib/`, `plugin/codenomad.ts` | -| `packages/cloudflare/` | Edge deployment | `src/`, `scripts/` | - -## Functional Areas (from RPG) - -### UserInterface (613 entities) -- **Components:** JSX components in `packages/ui/src/components/` -- **Stores:** Signal-based state in `packages/ui/src/stores/` -- **Hooks:** Reusable logic in `packages/ui/src/lib/hooks/` -- **i18n:** 7-locale translation system in `packages/ui/src/lib/i18n/` -- **API Client:** SDK wrapper in `packages/ui/src/lib/sdk-manager.ts` - -### ServerBackend (418 entities) -- **API Routes:** Fastify route handlers in `packages/server/src/server/routes/` -- **Authentication:** Auth manager, session manager, token manager in `packages/server/src/auth/` -- **Background Processes:** Process spawn and management in `packages/server/src/background-processes/` -- **Configuration:** YAML-based settings in `packages/server/src/settings/` -- **Filesystem:** Restricted file browser in `packages/server/src/filesystem/` -- **Workspaces:** Git worktrees, runtime management in `packages/server/src/workspaces/` - -### SpeechAndAudio (74 entities) -- **Speech Synthesis:** OpenAI-compatible provider in `packages/server/src/speech/` -- **Voice Mode:** Real-time voice state management in `packages/server/src/plugins/voice-mode.ts` -- **Conversation Mode:** Client-side speech queue in `packages/ui/src/stores/conversation-speech.ts` - -### DesktopClient (59 entities) -- **Electron Main:** Process manager, menu, IPC in `packages/electron-app/electron/main/` -- **Tauri Rust:** CLI manager, certificate management in `packages/tauri-app/src-tauri/src/` -- **Preload:** API exposure layer in `packages/electron-app/electron/preload/` - -### BuildAndPackaging (28 entities) -- **Build Scripts:** Version sync, icon generation, resource copying -- **Packaging:** Server resource bundling, node runtime preparation - -### CloudflareDeployment (3 entities) -- **Edge Functions:** Asset serving with cache headers in `packages/cloudflare/src/index.ts` - -## Key Entry Points - -| Entry Point | File | Purpose | -|-------------|------|---------| -| Server CLI | `packages/server/src/index.ts` | Parses CLI options, starts HTTP server | -| UI Bootstrap | `packages/ui/src/main.tsx` | Initializes SolidJS app, mounts to DOM | -| Electron Main | `packages/electron-app/electron/main/main.ts` | Creates window, starts CLI process | -| Tauri Main | `packages/tauri-app/src-tauri/src/main.rs` | Rust entry, sets up window and CLI | -| Plugin Entry | `packages/opencode-plugin/plugin/codenomad.ts` | Initializes CodeNomad plugin tools | - -## Inter-Area Dependencies - -``` -UserInterface → ServerBackend (via SDK HTTP calls) -UserInterface → SpeechAndAudio (via conversation-speech store) -DesktopClient → UserInterface (hosts the UI in a native window) -DesktopClient → ServerBackend (spawns and manages server process) -ServerBackend → SpeechAndAudio (delegates to speech providers) -ServerBackend → CloudflareDeployment (fetches remote assets) +## Runtime Shape + +```text +Electron/Tauri -> CodeNomad Fastify server -> one shared OpenCode service + | | + | /api/* | Location-scoped /api/* + v v + SolidJS UI <- /api/events <- event bridge ``` -## Finding Code by Area +The server uses `packages/server/src/workspaces/opencode-service.ts` and the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and performs no cross-namespace PID operations. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; explicit Stop Workspace evicts that location, while tab/window close only detaches local UI. + +Native desktop identity is channel plus config profile: one singleton process/backend per profile, multiple UUID windows, MRU focus on second launch, and `--new-window` for another window. Stable/dev/non-default profiles isolate native/browser/client state. OpenCode sessions/messages remain shared; tabs, drafts, views, and restore membership are per-window. + +Client-state V3 is a per-window envelope over the V2 content-addressed partition graph. Electron and Tauri prepare immutable partitions before atomically publishing the root, fence migration and writes on current ownership, and collect only unreferenced partitions after publication. Native SideCar/browser previews omit same-origin sandbox permission, making DOM comment inspection web-only. + +## Boundaries + +| Owner | Responsibilities | Main paths | +|---|---|---| +| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive 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/` | + +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped `shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Output preserves native cursor pagination; interactive `pty.*` terminals remain separate. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. + +Native Forms are the interruption API. Allowlisted Question request/reply/reject routes are compatibility-only; do not build new Question queue architecture. + +## 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 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 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 + +`packages/server/src/config/location.ts` resolves CodeNomad data under `~/.config/codenomad/`: canonical `config.yaml`, `state.yaml`, and `instances/`, with `config.json` retained only as migration input. + +OpenCode location/workspace identity is upstream state. CodeNomad persists only its own preferences and policy metadata, including Yolo state. + +OpenCode owns the global daemon's standard state and database. Allowed configured environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership variables are ignored. + +## Entry Points -| Area | Directory Patterns | Search Command | -|------|------------------|----------------| -| UserInterface | `packages/ui/src/components/`, `packages/ui/src/stores/` | `grep "query" packages/ui/src/` | -| ServerBackend | `packages/server/src/server/routes/`, `packages/server/src/workspaces/` | `grep "query" packages/server/src/` | -| DesktopClient | `packages/electron-app/electron/main/`, `packages/tauri-app/src-tauri/src/` | `grep "query" packages/*-app/` | -| SpeechAndAudio | `packages/server/src/speech/`, `packages/ui/src/stores/conversation-speech.ts` | `grep "query" packages/**/speech*` | +- Server: `packages/server/src/index.ts` +- HTTP/proxy: `packages/server/src/server/http-server.ts` +- Workspace/location manager: `packages/server/src/workspaces/manager.ts` +- UI: `packages/ui/src/main.tsx` +- OpenCode UI client: `packages/ui/src/lib/sdk-manager.ts` +- Electron: `packages/electron-app/electron/main/main.ts` +- Tauri: `packages/tauri-app/src-tauri/src/main.rs` diff --git a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md index 4ba27754b..016d66d9d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/desktop-conventions.md @@ -1,136 +1,51 @@ # Desktop Conventions -## Dual Platform: Electron + Tauri +## Shared Model -CodeNomad supports two desktop platforms: -- **Electron** (primary, mature) -- **Tauri** (emerging, Rust-based) +CodeNomad supports Electron and Tauri as equal desktop hosts. Identity is update channel plus config profile: each scope has one native singleton process and one CodeNomad backend, with multiple UUID-backed windows. A second launch focuses the MRU window unless `--new-window` requests another window. -## Electron +OpenCode sessions and messages stay in the shared global daemon. Tabs, drafts, views, restore membership, and native bounds are per-window. Client-state V3 is a per-window envelope over the V2 SHA-256 content-addressed partition graph: prepare immutable partitions, fence migration and writes on current ownership and renderer authority, atomically publish the root, then remove only partitions unreferenced by every window. -### Directory Structure +Native SideCar/browser previews are sandboxed without `allow-same-origin`; DOM comment inspection is web-only. -``` -packages/electron-app/electron/ -├── main/ # Main process code -│ ├── main.ts # Entry point, window management -│ ├── menu.ts # Application menu -│ ├── ipc.ts # IPC handlers -│ ├── storage.ts # File system storage -│ ├── permissions.ts # Media permissions -│ ├── user-shell.ts # Shell command execution -│ └── process-manager.ts # CLI process management -├── preload/ # Preload scripts -│ └── index.cjs # API exposure to renderer -└── resources/ # Bundled resources - └── cli-supervisor.cjs # Process supervisor -``` +## Current Host Paths -### Main Process Responsibilities +| Concern | Electron | Tauri | +|---|---|---| +| Entry and host wiring | `packages/electron-app/electron/main/main.ts` | `packages/tauri-app/src-tauri/src/main.rs` | +| Backend process | `packages/electron-app/electron/main/process-manager.ts` | `packages/tauri-app/src-tauri/src/cli_manager.rs` | +| Launch and singleton behavior | `packages/electron-app/electron/main/startup.ts` | `packages/tauri-app/src-tauri/src/launch.rs`, `identity.rs`, `local_windows.rs` | +| Native commands | `packages/electron-app/electron/main/ipc.ts` | command handlers registered in `packages/tauri-app/src-tauri/src/main.rs` | +| Renderer bridge | `packages/electron-app/electron/preload/index.cjs` | Tauri invoke/plugins through `packages/ui/src/lib/native/tauri/functions.ts` | +| Client state | `packages/electron-app/electron/main/client-state.ts` and `client-state-*.ts` | `packages/tauri-app/src-tauri/src/client_state.rs` and `client_state/` | +| Shutdown | `packages/electron-app/electron/main/multiwindow-lifecycle.ts` | `packages/tauri-app/src-tauri/src/shutdown.rs` | +| Workspace open | `packages/electron-app/electron/main/workspace-open.ts` | `packages/tauri-app/src-tauri/src/workspace_open.rs` | -- Create and manage browser windows -- Spawn and monitor CLI server process -- Handle native APIs (file dialogs, notifications) -- Manage application lifecycle - -### IPC Pattern - -```typescript -// Main process handler -// packages/electron-app/electron/main/ipc.ts -function setupCliIPC() { - ipcMain.handle("dialog:open", async (_, options) => { - return dialog.showOpenDialog(options) - }) -} - -// Preload exposure -// packages/electron-app/electron/preload/index.cjs -contextBridge.exposeInMainWorld("electronAPI", { - openDialog: (options) => ipcRenderer.invoke("dialog:open", options) -}) -``` - -## Tauri - -### Directory Structure - -``` -packages/tauri-app/ -├── src-tauri/ -│ ├── src/ # Rust backend code -│ │ ├── main.rs # Entry point -│ │ ├── cli_manager.rs # CLI process management -│ │ ├── cert_manager.rs # TLS certificate management -│ │ └── linux_tls.rs # Linux TLS handling -│ └── capabilities/ # Permission capabilities -└── src/ # Frontend code (same as UI) -``` - -### Rust Backend - -- Commands exposed to frontend via `#[tauri::command]` -- Process management similar to Electron's process-manager.ts -- Certificate management for HTTPS - -### Command Pattern - -```rust -// packages/tauri-app/src-tauri/src/main.rs -#[tauri::command] -fn open_dialog(options: DialogOptions) -> Result { - // Implementation -} -``` - -## Parity Rules - -| Scenario | Rule | -|----------|------| -| Existing IPC/handlers (pre-Tauri) | MUST implement in both Electron + Tauri | -| New features | Implement in Electron first, Tauri if time permits | -| Native APIs | Use `packages/ui/src/lib/native/` abstraction layer | +The desktop process managers start and supervise the CodeNomad backend. They do not own or stop the shared OpenCode daemon. ## Native Abstractions -CodeNomad abstracts native APIs to work across Electron, Tauri, and Web: - -| Feature | Abstraction File | -|---------|-----------------| -| File dialogs | `packages/ui/src/lib/native/native-functions.ts` | -| Desktop file drop | `packages/ui/src/lib/native/desktop-file-drop.ts` | -| Electron-specific | `packages/ui/src/lib/native/electron/functions.ts` | -| Wake lock | `packages/ui/src/lib/native/wake-lock.ts` | -| Remote windows | `packages/ui/src/lib/native/remote-window.ts` | -| CLI restart | `packages/ui/src/lib/native/cli.ts` | - -### Abstraction Pattern - -```typescript -// packages/ui/src/lib/native/native-functions.ts -export type NativeDialogResult = string | string[] | null +- Shared dispatch and dialogs: `packages/ui/src/lib/native/native-functions.ts` +- Shared types: `packages/ui/src/lib/native/types.ts` +- Electron adapter: `packages/ui/src/lib/native/electron/functions.ts` +- Tauri adapter: `packages/ui/src/lib/native/tauri/functions.ts` +- Desktop file drop: `packages/ui/src/lib/native/desktop-file-drop.ts` +- Client state: `packages/ui/src/lib/native/client-state.ts` +- Remote windows: `packages/ui/src/lib/native/remote-window.ts` +- Runtime detection: `packages/ui/src/lib/runtime-env.ts` -export async function openNativeFileDialogs( - options?: Omit -): Promise { - const result = await openNativeDialog({ mode: "file", multiple: true, ...options }) - // Platform-specific implementation -} -``` +Use these abstractions instead of importing host APIs into feature components. -## Platform Detection +## Strict Parity -```typescript -// packages/ui/src/lib/runtime-env.ts -export function isElectronHost(): boolean { /* ... */ } -export function isTauriHost(): boolean { /* ... */ } -export function isWebHost(): boolean { /* ... */ } -``` +- Every desktop behavior change must ship for Electron and Tauri in the same change. There is no Electron-first or follow-up parity exception. +- Keep lifecycle, singleton identity, window restore, client-state safety, navigation security, native commands, and shutdown semantics equivalent. +- Add or update tests for both hosts. Include Web behavior when the shared abstraction has a browser fallback. +- Platform-specific implementation details may differ, but user-visible behavior and security boundaries must not. -## Checklist for Desktop Features +## Checklist -- [ ] Electron main-process changes? (`packages/electron-app/electron/main/`) -- [ ] Tauri Rust changes? (`packages/tauri-app/src-tauri/src/`) -- [ ] Preload API exposure? (`packages/electron-app/electron/preload/`) -- [ ] Native abstraction? (`packages/ui/src/lib/native/`) -- [ ] Cross-platform test (Electron + Tauri + Web) +- [ ] Electron main/preload behavior updated and tested +- [ ] Tauri Rust/plugin behavior updated and tested +- [ ] Shared UI native abstraction remains host-agnostic +- [ ] Multi-window, restore, navigation, and shutdown invariants preserved diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 2aa5a42dc..cb006242d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -1,182 +1,48 @@ # Feature Traces -End-to-end feature flows with decision branches and mechanism references. +## Workspace And Location -## Permission Flow (with branches) +1. UI posts a folder to `/api/workspaces`. +2. `WorkspaceManager` resolves the binary launch spec and calls the single `OpenCodeSharedService`. +3. The adapter runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle and validates the authenticated loopback endpoint. WSL requires Windows localhost forwarding and stays inside the Linux namespace. +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. Explicit Stop Workspace evicts the location/resources and removes the logical workspace. Ordinary tab/window close only detaches local UI; backend shutdown clears connection state. Neither close nor shutdown stops the daemon. -1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` - - Events are pushed through the instance event stream +## Prompt, Shell, Instructions, And PTYs -2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled) - - **File:** `packages/server/src/permissions/auto-accept-manager.ts` - - **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup - - **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes -3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` - - **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately - - **Branch:** ELSE (normal flow) - - **Mechanism:** Permission queued in `permissionQueues` signal - - **Action:** Display approval modal - - **File:** `packages/ui/src/components/permission-approval-modal.tsx` +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`. Native Shell remains separate from PTY management. +4. The Status panel lists native background Shells for the active location, displays their native metadata, and refreshes on Shell events and reconnect. +5. Removal uses native Shell APIs; the proxy verifies native `cwd` ownership before every ID-scoped operation and preserves output cursors. +6. Interactive PTYs use separate `pty.*` APIs and are not background-process records. +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. -3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` - - Adds permission to message store for display in chat +No CodeNomad OpenCode plugin participates in this flow. `packages/opencode-plugin` and server plugin/background-process paths remain deleted and must not be restored. -4. **UI Component:** Modal displays (if not auto-accepted) - - Shows permission details and allow/deny/once buttons +## Permission And Yolo -5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` - - Validates permission still pending - - Prepares reply payload +1. OpenCode emits a location-scoped permission event. +2. `InstanceEventBridge` publishes it as `instance.event`. +3. `AutoAcceptManager` checks CodeNomad-owned Yolo state. +4. If enabled, `createOpencodePermissionReplier` calls native `client.permission.reply` and emits `yolo.autoAccepted`. +5. Otherwise the UI queues the permission and replies with the native client. +6. Yolo toggle/persistence remains in CodeNomad; `/api/events` distributes `yolo.stateChanged`. -6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` - - Wrapped with `requestData()` for error handling +## Git Changes -7. **Optimistic Update:** `removePermissionV2()` in bridge - - Immediately removes from local store - - UI updates without waiting for server +1. UI reads Git status/diff from `/api/workspaces/:id/worktrees/:slug/git-status|git-diff`. +2. Stage, unstage and commit post to corresponding `git-stage`, `git-unstage` and `git-commit` routes. +3. The server resolves the owned worktree directory, validates relative paths/messages, and runs Git in `packages/server/src/workspaces/git-mutations.ts`. -8. **SSE Confirmation:** Server emits `permission.replied` event - - **Branch:** IF SSE is connected - - Bridge reconciles (no-op if already removed optimistically) - - **Branch:** IF SSE is disconnected during reply - - **Mechanism:** `serverEvents` reconnection triggers `syncPendingPermissions()` in `packages/ui/src/stores/instances.ts` - - **Action:** Re-fetches pending permissions, reconciles state - - If permission was already replied, it disappears from queue +Do not replace mutation routes with OpenCode file/status calls; CodeNomad owns this write boundary. ---- +## Events -## Session Lifecycle (with branches) - -1. **UI:** `packages/ui/src/stores/session-api.ts:fetchSessions()` calls `client.session.list()` - - Uses root worktree client (no worktree slug needed for listing) - -2. **Server:** Backend returns session array via API response - - Includes status, title, parentID, version - -3. **UI:** Normalizes with `toClientSession()` → stores in `session-state.ts` - - Maps SDK types to UI types - - Preserves existing local state (title, model, status) - - **Branch:** IF session has `parentID` set - - **Mechanism:** Child session, no additional fetch - - **Branch:** IF session has no `parentID` and is expanded - - **Mechanism:** `fetchSessionChildren()` called recursively - - **File:** `packages/ui/src/stores/session-api.ts` - -4. **SSE:** Server pushes updates via instance event stream - - **Branch:** IF `message.part.delta` event - - **Mechanism:** Incremental text update streamed to UI - - **File:** `packages/ui/src/stores/message-v2/bridge.ts:updateMessagePartDelta()` - - **Branch:** IF `session.status` changed - - **Mechanism:** Update session indicator, idle timers, status badges - - **File:** `packages/ui/src/stores/session-status.ts` - - **Branch:** IF `message.part.updated` (completed) - - **Mechanism:** Finalize part content, update tool call state - -5. **UI:** Bridge reconciles SSE events with local state - - Handles optimistic update conflicts - - Merges server truth with local pending operations - ---- - -## Speech Flow (with branches) - -1. **UI:** User enables conversation mode - - **File:** `packages/ui/src/stores/conversation-speech.ts:setConversationModeEnabled()` - - **Branch:** IF `isConversationModeAvailable()` returns false - - **Mechanism:** Show error toast - - **File:** `packages/ui/src/lib/notifications.tsx:showToastNotification()` - - **Action:** Abort speech setup, keep existing state - - **Branch:** IF available - - **Mechanism:** Sync setting to server, initialize speech queue - -2. **Server:** `packages/server/src/server/routes/speech.ts` exposes capabilities - - Returns available TTS/STT providers and models - - **File:** `packages/server/src/speech/service.ts:getSpeechCapabilities()` - -3. **Provider:** `packages/server/src/speech/providers/openai-compatible.ts` synthesizes audio - - Converts text to audio bytes - - **Branch:** IF provider returns error - - **Mechanism:** Return error status to UI - - **File:** `packages/ui/src/components/speech-action-button.tsx` - - **Action:** Display error state, allow retry - - **Branch:** IF successful - - **Mechanism:** Stream audio data to client - -4. **UI:** `packages/ui/src/lib/hooks/use-speech.ts` streams audio playback - - Creates MediaSource for streaming playback - - Appends audio chunks to source buffer - - **Branch:** IF user interrupts (clicks stop or sends new message) - - **Mechanism:** Stop playback, clear queue - - **File:** `packages/ui/src/stores/conversation-speech.ts` - - **Action:** Abort current playback, discard pending chunks - - **Branch:** IF audio completes naturally - - **Mechanism:** Mark playback complete, process next queue item - ---- - -## Background Process Flow (with branches) - -1. **Plugin:** `packages/opencode-plugin/plugin/lib/background-process.ts` creates agent tools - - Defines `run_background_process`, `list_background_processes`, `stop_background_process` - - Validates commands stay within workspace base directory - -2. **Server:** `packages/server/src/background-processes/manager.ts` spawns process - - Uses `spawn` with shell command - - Captures stdout/stderr to log files - - **Branch:** IF spawn fails (command not found, permission denied) - - **Mechanism:** Emit error event, update process status to "error" - - **File:** `packages/server/src/background-processes/manager.ts` - - **Action:** Notify client of failure, keep process record with error state - - **Branch:** IF spawn succeeds - - **Mechanism:** Track PID, stream output, update index - -3. **UI:** `packages/ui/src/stores/background-processes.ts` polls/listens - - Fetches process list periodically - - Subscribes to SSE events for process updates - - **Branch:** IF process completes AND `notify=true` was set - - **Mechanism:** Show completion notification - - **File:** `packages/ui/src/lib/notifications.tsx` - - **Action:** Toast notification with process title and exit code - - **Branch:** IF process errors - - **Mechanism:** Update UI with error status, allow viewing logs - -4. **UI:** `packages/ui/src/components/background-process-output-dialog.tsx` displays stream - - Opens dialog showing real-time output - - Uses ANSI renderer for colored terminal output - - **Branch:** IF user clicks "Stop" - - **Mechanism:** Call `stop_background_process` tool - - **Action:** Send SIGTERM, then SIGKILL if needed - ---- - -## Git Clone Flow (with branches) - -1. **UI:** User initiates clone from UI or command - - **File:** `packages/ui/src/components/folder-selection-view.tsx` or command palette - -2. **Server:** `packages/server/src/server/routes/workspaces.ts` receives request - - Validates `repositoryUrl` and `destinationPath` - - **File:** `packages/server/src/workspaces/git-clone.ts:cloneGitRepository()` - -3. **Validation:** `packages/server/src/workspaces/git-clone.ts` - - **Branch:** IF destination is filesystem root or home folder - - **Mechanism:** Throw `GitCloneError` with 400 status - - **Action:** Return error to client - - **Branch:** IF destination exists and not empty (and cleanup=false) - - **Mechanism:** Throw `GitCloneError` with 409 status - - **Action:** Return error, suggest cleanup or different path - - **Branch:** IF validation passes - - **Mechanism:** Proceed to clone - -4. **Clone Execution:** - - **Branch:** IF destination exists and cleanup=true - - **Mechanism:** `replaceDestinationAfterSuccessfulClone()` - - **Action:** Clone to temp path, swap directories, delete old - - **File:** `packages/server/src/workspaces/git-clone.ts` - - **Branch:** IF destination doesn't exist or is empty - - **Mechanism:** `runGitClone()` direct to destination - - **Action:** Standard `git clone` execution - -5. **Result:** Return `{ path: destinationPath }` on success - - Workspace manager picks up new folder - - UI navigates to new workspace +- OpenCode events: shared `client.event.subscribe()` -> `InstanceEventBridge` -> `EventBus`. +- CodeNomad events: workspace/Git-adjacent policy/Yolo producers -> `EventBus`. +- Browser transport: `GET /api/events` with heartbeat/pong via `/api/client-connections/pong`. +- The native stream is volatile. On reconnect, refetch authoritative session/pending-request/file/config state rather than expecting replay. +- Current invalidations use `filesystem.changed` and `config.updated`; session lifecycle/output uses `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index 636a07446..c467cbabb 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -1,109 +1,48 @@ -# SDK API Reference +# Native OpenCode V2 Client Reference -## Overview +## Package -CodeNomad uses the OpenCode SDK V2 (`@opencode-ai/sdk/v2/client`) via `createOpencodeClient()`. +CodeNomad keeps the experimental `@opencode-ai/client` protocol aligned in `packages/server/package.json` and `packages/ui/package.json`. The runtime CLI is independently updated and startup validates service health and API compatibility without requiring that exact dependency version. This is distinct from the current public `@opencode-ai/sdk` documentation. -**Note:** The SDK implementation lives outside this repository. +- Promise client: `import { OpenCode } from "@opencode-ai/client"` +- Service authentication headers: `import { Service } from "@opencode-ai/client/service"` +- Client construction: `OpenCode.make({ baseUrl, headers?, fetch? })` +- Declarations: `node_modules/@opencode-ai/client/dist/promise/` -- After `npm install`, inspect types in `node_modules/@opencode-ai/sdk/v2/client.d.ts` -- **Fallback:** Use the CodeNomad wrapper locations documented below as the source of truth -- When node_modules is unavailable, read how the SDK is imported in existing files +Do not import `@opencode-ai/sdk`; its wrapper shapes, `{ data, error }` conventions, and `createOpencodeClient()` do not apply to this pinned experimental protocol build. -## SDK Methods Used by CodeNomad +## Used Native APIs -### Session +| Area | Calls | CodeNomad caller | +|---|---|---| +| Service | CLI `service status/start/get password`; `Service.headers` for authenticated health/API calls | `packages/server/src/workspaces/opencode-service.ts`, `packages/server/src/workspaces/opencode-cli-service.ts`, `packages/server/src/workspaces/host-opencode-service.ts`, `packages/server/src/workspaces/wsl-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 | +| Instructions | `client.session.instructions.entry.put/remove` | conversation-mode prompt setup | +| Permissions | `permission.request.list`, `permission.reply` | UI and server Yolo replier | +| Forms | `client.form.request.list`, `client.form.reply`, `client.form.cancel` | `packages/ui/src/stores/instances.ts`, `forms.ts` | -**SDK:** `client.session.promptAsync({ sessionID, content, command?, agent? })` -**Wrapper:** `packages/ui/src/stores/session-actions.ts` -```typescript -const response = await requestData( - client.session.promptAsync({ sessionID, content }), - "session.promptAsync" -) -``` +Native methods return decoded Promise values. Follow the installed declarations and existing callers; do not wrap calls in stale SDK response-unwrapping helpers. -**Other Session Methods Used:** -- `client.session.list()` — List all sessions -- `client.session.create({ parentID? })` — Create new session -- `client.session.get({ sessionID })` — Get session info -- `client.session.delete({ sessionID })` — Delete session -- `client.session.children({ sessionID })` — Get child sessions -- `client.session.diff({ sessionID })` — Get file changes -- `client.session.revert({ sessionID, messageID? })` — Revert code -- `client.session.summarize({ sessionID })` — Generate summary -- `client.session.messages({ sessionID })` — List messages -- `client.session.update({ sessionID, ... })` — Update session properties -- `client.session.command({ sessionID, command })` — Send command -- `client.session.shell({ sessionID, command })` — Execute shell command -- `client.session.abort({ sessionID })` — Abort active session +Native Forms own pending interruption state. The allowlisted Question request/reply/reject routes are compatibility-only. The Question tool renderer may display compatible output, but no Question queue/state architecture should return. -**Note on Message Deletion:** The SDK does not expose a typed method for message deletion. CodeNomad uses a raw client call: -```typescript -// packages/ui/src/stores/session-actions.ts:451-457 -await requestData( - (client as any).client.delete({ - url: `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`, - }), - "session.message.delete", -) -``` +## Routing -### Part +The UI client base is `/workspaces/:id/instance/`. Generated methods append native `/api/*` endpoints. `packages/ui/src/lib/sdk-manager.ts` caches clients by instance/proxy path and supplies a fetch adapter with cookies. -**SDK:** `client.part.delete({ sessionID, messageID, partID })` -**Wrapper:** `packages/ui/src/stores/session-actions.ts:deleteMessagePart()` -```typescript -await requestData( - client.part.delete({ sessionID: sessionId, messageID: messageId, partID: partId }), - "part.delete", -) -``` +Location-sensitive list/create calls include `directory` or `location`. Session-specific calls rely on the session's native location, while the CodeNomad proxy verifies that location belongs to the selected workspace. -**⚠️ Constraint:** Message must retain ≥1 part. Delete entire message if removing last part. +## CodeNomad-Owned APIs -**Note on Part Updates:** CodeNomad does not currently use `client.part.update()`. Part modifications are handled through other mechanisms. +Do not look for these in the OpenCode client: -### Permission +- Workspace create/delete and worktree management +- Git status/diff/stage/unstage/commit +- Yolo toggle, persistence and auto-accept policy +- Authentication, storage, speech, sidecars and previews +- Multiplexed browser SSE at `/api/events` -**SDK:** `client.permission.reply({ requestID, reply: "allow" | "deny" | "once" })` -**Wrapper:** `packages/ui/src/stores/instances.ts:sendPermissionResponse()` +These use `packages/ui/src/lib/api-client.ts` and server routes. -**Other Permission Methods:** -- `client.permission.list()` — Get pending permissions - -### Question - -**SDK:** `client.question.reply({ requestID, answers: string[][] })` -**Wrapper:** `packages/ui/src/stores/instances.ts:sendQuestionReply()` - -**Other Question Methods:** -- `client.question.list()` — Get pending questions -- `client.question.reject({ requestID })` — Reject question - -### File - -**SDK:** `client.file.list({ path })` — List directory contents -**Wrapper:** `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx` - -**SDK:** `client.file.read({ path })` — Read file content -**Wrapper:** `packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx` - -**SDK:** `client.file.status()` — Get Git status of files -**Wrapper:** `packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts` - -### Config - -**SDK:** `client.config.get()` — Get current configuration -**Wrapper:** `packages/ui/src/lib/hooks/use-instance-metadata.ts` - -**Note:** `client.config.update()` and `client.config.providers()` are available but configuration updates flow through server routes instead. - -## SDK Categories Not Currently Used - -The following SDK categories are available but not actively used by CodeNomad: - -- `client.find.*` — File/symbol search (CodeNomad uses server routes) -- `client.global.*` — Global config/health (CodeNomad uses server meta endpoint) -- `client.app.*` — App logging/agents -- `client.worktree.*` — Git worktree management (CodeNomad uses server routes) +The instance proxy is method/path allowlisted. Adding an upstream client method does not make its route available through CodeNomad. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 67607a4ca..73be6a142 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -1,109 +1,38 @@ -# SDK Critical Behaviors +# Native OpenCode V2 Critical Behaviors -## Upstream OpenCode Behaviors +## Contract -The following behaviors are implemented in the upstream OpenCode SDK/server, not in the CodeNomad repository. They affect how CodeNomad must interact with the SDK. +- Keep server and UI on the same reviewed experimental `@opencode-ai/client` release. Update the runtime CLI independently and validate service health and API compatibility instead of enforcing the client's exact version string. Review OpenCode release notes, current documentation, installed declarations, and proxy/API parity on every upgrade. +- 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. -## Critical Behaviors Table +## Location Is Authority -| Behavior | Detail | Impact | Verification | -|----------|--------|--------|--------------| -| `ignored: true` on assistant parts | Backend only checks for user parts | Assistant parts still sent to AI model | Observe via SSE behavior; not verifiable locally | -| Part delete | Message must retain ≥1 part | Delete entire message if last part | `packages/ui/src/stores/session-actions.ts` | -| Metadata on assistant parts | Passed as `providerMetadata` to ai SDK | Flat objects cause fatal schema violations | Avoid setting metadata on assistant parts | -| Session revert | Only restores files to Git snapshot | Not an undo mechanism for messages | Test via `client.session.revert()` | -| Empty messages | Backend rejects `parts: []` | Check part count before delete | `packages/ui/src/stores/session-actions.ts` | +- 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`. +- Explicit Stop Workspace evicts the native location/resources and removes CodeNomad's logical workspace. Ordinary tab/window close only detaches local UI and never evicts. -## Schema Violation Details +## Shared Lifecycle -### Assistant Part Metadata (Fatal) +- There is one externally owned global service, one server client and one upstream event subscription. CodeNomad uses official host/WSL CLI status/start/password commands, owns no private service state or PID, and never stops the daemon on backend shutdown. +- WSL requires Windows localhost forwarding, executes lifecycle commands inside Linux, and never uses cross-namespace PID operations. +- A workspace stop evicts its location; it does not stop a dedicated process or the global daemon. +- OpenCode owns standard state/database. Allowed configured environment variables apply only when starting a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` are ignored. +- 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. -**Behavior:** Assistant text part `metadata` is passed as `providerMetadata` to the underlying AI SDK. +## Ownership Matrix -**Expected format:** -```typescript -providerMetadata?: Record> -``` +| Concern | Owner | +|---|---| +| Session/message/Shell/instructions | OpenCode native API; session Shell remains separate from background Shell and PTY management | +| Background Shell list/metadata/output/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on Shell events/reconnect | +| Interactive PTYs | Separate native `pty.*` API | +| Service status/start/password | CodeNomad adapter using the selected host or WSL CLI; daemon stop remains external | +| Workspace and directory authorization | CodeNomad | +| Git status/diff and mutations | CodeNomad | +| Yolo policy/persistence/auto-reply | CodeNomad | +| Browser event multiplexing | CodeNomad `/api/events` | -**Violation examples:** -```typescript -// ❌ WRONG: Flat object -metadata: { compacted: true } - -// ❌ WRONG: Missing provider name wrapper -metadata: { key: "value" } - -// ✅ CORRECT: Nested by provider -metadata: { openai: { key: "value" } } -``` - -**Fix:** Do not store metadata on assistant text parts. Use client-side registry instead: -```typescript -// ✅ Use client-side registry -// packages/ui/src/stores/session-compaction.ts -const compactedParts = new Set() // part IDs -``` - -### Empty Messages After Part Deletion - -**Root Cause:** Backend validates messages have ≥1 part - -**Fix:** Check remaining part count before deleting last part -```typescript -// packages/ui/src/stores/session-actions.ts -if (record.partIds.length <= 1) { - // Delete entire message instead - await deleteMessage(sessionID, messageID) -} else { - await deleteMessagePart(sessionID, messageID, partID) -} -``` - -## `ignored` Flag Asymmetry - -| Part Type | `ignored: true` Effect | Notes | -|-----------|------------------------|-------| -| User text | ✅ Excluded from AI model context | Safe to use | -| Assistant text | ❌ No effect — still sent to model | Do not rely on this | -| Tool | ❌ No `ignored` field exists | N/A | -| Reasoning | ❌ No `ignored` field exists | N/A | - -**Implication:** Cannot "soft delete" assistant parts. Must delete or use client-side registry. - -## Decision Matrix: Context Modification - -| Goal | Strategy | SDK Support | Safe? | -|------|----------|-------------|-------| -| Update assistant text | `Part.update()` (if available) | ✅ | ✅ Yes (no metadata) | -| Update user text | `Part.update()` (if available) | ✅ | ✅ Yes | -| Hide user part from AI | `ignored: true` | ✅ | ✅ Yes | -| Hide assistant part from AI | `ignored: true` | ⚠️ No effect | ❌ No effect | -| Delete part | `client.part.delete()` | ✅ | ✅ Yes (check message parts) | -| Delete message | Raw DELETE via client | ✅ | ✅ Yes (irreversible) | -| Undo message deletion | Client-side restore | ⚠️ Manual | ⚠️ Must recreate | -| Revert code changes | `client.session.revert()` | ✅ | ✅ Only affects files | -| Store UI state | Client-side registry | N/A | ✅ localStorage/Set | - -## Race Conditions - -### Optimistic Updates - -**Symptom:** UI state desync after rapid operations - -**Cause:** `removeMessagePartV2()` and `removeMessageV2()` called optimistically before server confirmation - -**Mitigation:** SSE events eventually converge state. Do not rely on optimistic state for subsequent operations. - -### SSE Disconnection - -**Symptom:** Missed events during reconnection - -**Mitigation:** `serverEvents` reconnection triggers sync handlers (e.g., `syncPendingPermissions()`) to reconcile state. - -## Recommendations - -1. **Never store flat metadata on assistant text parts.** Always use client-side registries for UI state. -2. **Prefer user messages for metadata-heavy operations.** User text parts don't pass metadata to ai SDK. -3. **Implement client-side undo for destructive operations.** The SDK has no native message-level undo. -4. **Validate part payloads before sending.** Always spread existing part and override only specific fields. -5. **Handle `ignored` carefully.** It only works for user text parts. Don't rely on it for assistant parts. +Background Shell output uses native cursor pagination; interactive PTYs remain separate. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index 3f5d43212..ee7103985 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -1,209 +1,46 @@ -# SDK Integration Patterns +# Native OpenCode V2 Integration Patterns -## Client Lifecycle +## Shared Service -### SDK Manager +`WorkspaceManager` owns one `OpenCodeSharedService`. Production runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle, validates the authenticated loopback endpoint, creates one Promise client, and invalidates failed connections. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and performs no cross-namespace PID operations. -CodeNomad creates and manages `OpencodeClient` instances through `SDKManager`: +OpenCode owns standard state/database. Allowed configured environment variables apply only to `service start` for a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership variables are ignored. -```typescript -// packages/ui/src/lib/sdk-manager.ts -class SDKManager { - private clients = new Map() - - createClient(instanceId: string, proxyPath: string): OpencodeClient { - const baseUrl = buildInstanceBaseUrl(proxyPath) - return createOpencodeClient({ baseUrl }) - } -} -``` - -### Worktree-Based Routing +## Locations And Directories -SDK clients are routed per worktree, not just per instance: - -```typescript -// packages/ui/src/stores/worktrees.ts -export function getOrCreateWorktreeClient( - instanceId: string, - worktreeSlug: string -): OpencodeClient { - const proxyPath = `/worktrees/${worktreeSlug}` - return sdkManager.createClient(instanceId, proxyPath) -} -``` +Workspace creation calls `client.location.get({ location: { directory } })` and records the returned directory/workspace ID. Explicit Stop Workspace calls `client.debug.location.evict` before removing the logical workspace. Ordinary tab/window close only detaches local UI and never evicts. -**Rule:** Always use `getOrCreateWorktreeClient()` rather than creating clients directly. This ensures: -- Correct base URL with worktree proxy path -- Client caching and reuse -- Proper cleanup on instance disposal +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. -### Base URL Construction +## UI Client -```typescript -// packages/ui/src/lib/sdk-manager.ts -export function buildInstanceBaseUrl(proxyPath: string): string { - const normalized = normalizeProxyPath(proxyPath) - const base = stripTrailingSlashes(CODENOMAD_API_BASE) - return `${base}${normalized}/` -} +```ts +const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) }) ``` -## Error Handling - -### RequestData Wrapper - -Most SDK calls that return `{ data, error }` go through `requestData()` for consistent error handling: - -```typescript -// packages/ui/src/lib/opencode-api.ts -export async function requestData( - promise: Promise<{ data?: T; error? }>, - operation: string -): Promise { - const response = await promise - if (response.error) { - log.error(`API error in ${operation}`, response.error) - throw response.error - } - if (response.data === undefined) { - throw new Error(`No data returned from ${operation}`) - } - return response.data -} -``` +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. -### Pattern +## Session Shell, Background Shells, And PTYs -```typescript -// Always wrap SDK calls -const sessions = await requestData( - client.session.list(), - "session.list" -) +- Shell mode calls `client.session.shell({ sessionID, command })`. +- Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`. +- Session Shell remains separate from background Shell and native PTY management. +- Background Shells are location-scoped and listed with `client.shell.list`; the Status panel refreshes on Shell lifecycle events and reconnect and displays native metadata. +- Shell ID operations are ownership-checked against the native `cwd`; output preserves the native cursor and removal uses `client.shell.remove`. +- Interactive terminals use separate `client.pty.*` APIs. +- Keep `packages/opencode-plugin` and server plugin/background-process paths deleted. -// Direct SDK calls are also used when the method doesn't return { data, error } -// Example: const response = await rootClient.session.list() -``` +## Event Flow -## Optimistic Updates - -### Pattern - -1. Update local state immediately -2. Make API call -3. Handle success/error -4. SSE events eventually confirm/converge - -```typescript -// packages/ui/src/stores/message-v2/bridge.ts -export function removePermissionV2(instanceId: string, requestId: string) { - // 1. Optimistic: Remove from local store - updateMessageStore(instanceId, (store) => { - store.permissions.delete(requestId) - }) - - // 2. API call (may fail) - // 3. SSE event eventually confirms -} -``` +1. The server subscribes once with `client.event.subscribe()`. +2. `InstanceEventBridge` maps location-scoped OpenCode events to CodeNomad `instance.event` records. +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. -### Reconciliation - -SSE events from the server eventually reconcile optimistic state: - -| Event | Handler | File | -|-------|---------|------| -| `message.part.updated` | `updateMessagePartV2()` | `bridge.ts` | -| `message.part.removed` | `removeMessagePartV2()` | `bridge.ts` | -| `permission.replied` | `removePermissionV2()` | `bridge.ts` | -| `question.replied` | `removeQuestionV2()` | `bridge.ts` | - -### Race Condition Warning - -Rapid successive operations can cause temporary desync: -- Delete part → quickly delete message → may error if part delete in flight -- Always check current state before optimistic updates - -## Permission Flow - -1. **Server emits** `permission.asked` or `permission.updated` SSE event - - Pushed through instance event stream -2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled) - - File: `packages/server/src/permissions/auto-accept-manager.ts` - - Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change - - Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI -3. **UI Store receives** via `serverEvents` - - File: `packages/ui/src/stores/instances.ts` - - **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue - - **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal -4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` -5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays -6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` -7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` -8. **Optimistic Update:** `removePermissionV2()` in bridge -9. **SSE Confirmation:** `permission.replied` event - - **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect - -## Session Event Handling - -### SSE Event Types - -| Event | Direction | Description | -|-------|-----------|-------------| -| `message.part.delta` | Server → UI | Streaming text update | -| `message.part.updated` | Server → UI | Part content changed | -| `message.part.removed` | Server → UI | Part deleted | -| `session.status` | Server → UI | Session status changed | -| `permission.asked` | Server → UI | New permission request | -| `permission.updated` | Server → UI | Permission updated | -| `permission.replied` | Server → UI | Permission resolved | -| `question.asked` | Server → UI | New question | -| `question.replied` | Server → UI | Question answered | -| `question.rejected` | Server → UI | Question rejected | - -### Event Source Setup - -```typescript -// packages/ui/src/lib/event-source-handlers.ts -export function attachEventSourceHandlers( - source: EventSource, - options: EventSourceHandlerOptions -) { - source.onmessage = (event) => { - const payload = JSON.parse(event.data) - options.onEvent(payload) - } - - source.onerror = () => { - options.onError?.() - } - - ;(source as EventSourceWithClose).onclose = () => { - options.onError?.() - } -} -``` - -## Worktree Client Pattern - -```typescript -// Always route through worktree -const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) -const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) +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. -// Then use client normally -const diff = await requestData( - client.session.diff({ sessionID: sessionId }), - "session.diff" -) -``` - -## Cleanup Pattern +## CodeNomad Policy Boundaries -```typescript -// On instance disposal -sdkManager.destroyClientsForInstance(instanceId) -messageStoreBus.unregister(instanceId) -clearCacheForInstance(instanceId) -``` +- Git mutations run validated `git` commands in `packages/server/src/workspaces/git-mutations.ts` through `/api/workspaces/:id/worktrees/:slug/git-*`. +- Yolo is server-owned. `AutoAcceptManager` persists CodeNomad metadata and replies through the shared native client, then emits `yolo.stateChanged`/`yolo.autoAccepted`. +- Never move these operations into a browser-only client or an OpenCode plugin. diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index 65b783d83..41b63b708 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -1,114 +1,49 @@ # Server Conventions -## Framework: Fastify +## Fastify API -- Routes registered in `packages/server/src/server/routes/` -- Route handlers typed with Fastify generics -- Dependencies injected via `RouteDeps` interfaces +- Register CodeNomad control routes in `packages/server/src/server/routes/` under `/api/*`. +- Keep route dependencies explicit through `RouteDeps`. +- Define shared response/event types in `packages/server/src/api-types.ts` and check UI consumers. +- `/workspaces/:id/instance/*` is a guarded OpenCode proxy, not a CodeNomad control route. -### Route Registration Pattern +## OpenCode Service -```typescript -// packages/server/src/server/routes/example.ts -interface RouteDeps { - exampleManager: ExampleManager -} +- Use `OpenCodeSharedService` in `packages/server/src/workspaces/opencode-service.ts`. +- Keep one shared-service adapter and one event subscription for all workspaces. Use the selected host or WSL CLI's official status/start/password lifecycle, own no private service state/PID, and never stop the externally owned global daemon on backend shutdown. +- 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. +- Explicit Stop Workspace evicts the location; ordinary UI close never calls workspace deletion. WSL requires localhost forwarding and no cross-namespace PID operations. +- Leave global service state/database ownership to OpenCode. Pass allowed environment only when starting a missing daemon; leave an existing daemon unchanged and ignore `OPENCODE_DB`/`XDG_STATE_HOME`. -function registerExampleRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/api/examples", async () => { - return deps.exampleManager.list() - }) -} -``` +## Trust Boundaries -## API Types - -- **Shared types:** `packages/server/src/api-types.ts` -- **Consumed by UI:** `packages/ui/src/types/` -- **Breaking change rule:** Changing a type requires checking UI for matching interfaces -- **Preferred approach:** Additive changes (new optional fields) over breaking changes - -### Type Sharing Pattern - -```typescript -// Server defines in api-types.ts -export interface ExampleResponse { - id: string - name: string -} - -// UI may extend or mirror in packages/ui/src/types/ -export type { ExampleResponse } from "../../../server/src/api-types" -``` +- 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. ## Configuration -- **Settings service:** `packages/server/src/settings/service.ts` -- **YAML document store:** `packages/server/src/settings/yaml-doc-store.ts` -- **Public config sanitization:** `packages/server/src/settings/public-config.ts` -- **Config location resolution:** `packages/server/src/config/location.ts` - -### Settings Documents - -| Document | Purpose | File | Notes | -|----------|---------|------|-------| -| Config | User preferences, binaries, models | `~/.config/codenomad/config.yaml` | Canonical format | -| State | Recent folders, session metadata | `~/.config/codenomad/state.yaml` | Canonical format | -| Config (legacy) | Migration fallback | `~/.config/codenomad/config.json` | Supported as input fallback | - -## Testing - -- **Route tests:** Fastify inject in `__tests__/` subdirectories -- **Example:** `packages/server/src/server/__tests__/network-addresses.test.ts` -- **No integration tests** for external services - -### Route Test Pattern - -```typescript -// packages/server/src/server/routes/__tests__/example.test.ts -import { createApp } from "./helpers" - -test("GET /api/examples", async () => { - const app = createApp() - const response = await app.inject({ - method: "GET", - url: "/api/examples" - }) - expect(response.statusCode).toBe(200) -}) -``` - -## Background Processes - -- **Manager:** `packages/server/src/background-processes/manager.ts` -- **Spawned via:** `spawn` with persistent output tracking -- **Output streaming:** SSE events for real-time UI updates -- **Process lifecycle:** start → running → stop/error - -## Workspaces - -- **Workspace manager:** `packages/server/src/workspaces/manager.ts` -- **Runtime:** `packages/server/src/workspaces/runtime.ts` -- **Git worktrees:** `packages/server/src/workspaces/git-worktrees.ts` -- **Spawn spec:** `packages/server/src/workspaces/spawn.ts` - -### Workspace Lifecycle - -1. Create workspace (folder path) -2. Spawn OpenCode server process -3. Manage via workspace runtime -4. Clean up on delete - -## Authentication - -- **Auth manager:** `packages/server/src/auth/manager.ts` -- **Session manager:** `packages/server/src/auth/session-manager.ts` -- **Token manager:** `packages/server/src/auth/token-manager.ts` -- **Password hashing:** `packages/server/src/auth/password-hash.ts` - -### Auth Flow - -1. Server generates bootstrap token on startup -2. UI exchanges token for session cookie -3. Subsequent requests use session cookie -4. Credentials stored in auth file (hashed with scrypt) +- Resolution: `packages/server/src/config/location.ts` +- Settings: `packages/server/src/settings/service.ts` +- Canonical files: `~/.config/codenomad/config.yaml` and `state.yaml` +- Legacy migration input only: `~/.config/codenomad/config.json` + +## Current Paths + +- Workspace/location manager: `packages/server/src/workspaces/manager.ts` +- Shared service: `packages/server/src/workspaces/opencode-service.ts` +- CLI lifecycle: `packages/server/src/workspaces/opencode-cli-service.ts` +- Host lifecycle: `packages/server/src/workspaces/host-opencode-service.ts` +- WSL lifecycle: `packages/server/src/workspaces/wsl-opencode-service.ts` +- Spawn/path helpers: `packages/server/src/workspaces/spawn.ts` +- OpenCode event bridge: `packages/server/src/workspaces/instance-events.ts` +- Instance proxy: `packages/server/src/server/http-server.ts` +- CodeNomad SSE: `packages/server/src/server/routes/events.ts` +- Git reads/mutations: `packages/server/src/workspaces/git-status.ts`, `git-mutations.ts` +- Yolo: `packages/server/src/permissions/`, `packages/server/src/server/routes/yolo.ts` + +Deleted paths such as `packages/server/src/workspaces/runtime.ts`, `packages/server/src/background-processes/`, `packages/server/src/plugins/`, and `packages/opencode-plugin/` are not valid extension points. diff --git a/.opencode/skills/codenomad-architecture-guide/references/ui-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/ui-conventions.md index d9a64e66e..157e4005e 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/ui-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/ui-conventions.md @@ -23,7 +23,7 @@ - **Merge helper:** `packages/ui/src/lib/i18n/messages/merge.ts` - **Duplicate keys:** Throw at build time -### Supported Locales (7) +### Supported Locales (9) | Locale | Code | Direction | |--------|------|-----------| @@ -34,6 +34,8 @@ | Japanese | `ja` | LTR | | Simplified Chinese | `zh-Hans` | LTR | | Hebrew | `he` | RTL | +| German | `de` | LTR | +| Nepali | `ne` | LTR | ### Adding a New String diff --git a/AGENTS.md b/AGENTS.md index 395089a76..61d07f496 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ The UI uses a small custom i18n layer (no ICU/messageformat). When building feat - **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`. - **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code. - **Adding a new language:** add a new `messages//` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`. -- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`). +- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.yaml`; `config.json` is migration input only). - **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply. ## File Length Guidelines (Highlight Only) @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 968b54669..6e6d15094 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thank you for your interest in contributing! This guide will help you get starte ## Prerequisites - **Node.js 18+** and npm -- **OpenCode CLI** in your `PATH` (the server connects to the OpenCode binary to manage workspaces) +- **OpenCode CLI** in your `PATH` (CodeNomad uses one shared native V2 service for all workspace locations) ## Quick Start @@ -107,9 +107,26 @@ Then open a pull request on GitHub targeting the `dev` branch. | `packages/ui` | SolidJS frontend — reactive UI components and stores | | `packages/electron-app` | Electron desktop shell | | `packages/tauri-app` | Tauri desktop shell (experimental) | -| `packages/opencode-plugin` | OpenCode plugin integration | | `packages/cloudflare` | Cloudflare deployment adapters | +### OpenCode V2 Boundaries + +- Server and UI must use the same reviewed `@opencode-ai/client` version. The selected `opencode2` CLI is updated independently and validated through service health and API compatibility; startup must not reject it solely for a different version string. 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` uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. CodeNomad owns no private port, database, registration, or daemon PID and never stops the daemon on backend shutdown. +- WSL requires Windows localhost forwarding and runs the Linux CLI lifecycle inside the distribution; never inspect or signal Linux PIDs from Windows. +- OpenCode owns the global daemon's standard state and database. Configured allowed environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. +- Explicit **Stop Workspace** evicts the native location/resources. Closing a tab or window only detaches that local UI and must never delete or evict the workspace. +- OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. +- The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. +- Shell mode (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from background shells and interactive PTYs. +- Location-scoped background shells use `client.shell.*` and are listed in the Status panel. The UI refreshes them on Shell events and reconnect, displays native metadata, and supports ownership-checked removal. `client.pty.*` remains reserved for interactive terminals. `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. +- Native desktop identity is channel plus config profile: one singleton process/backend per profile, multiple UUID windows, focus on second launch by default, and `--new-window` for another window. Stable, dev, and non-default profiles isolate native/browser/client state; OpenCode sessions/messages stay shared while tabs, drafts, and views are per-window. +- Desktop restore uses a V3 per-window envelope over the V2 content-addressed partition graph. Preserve atomic publication/migration, ownership write fencing, and post-commit conservative garbage collection in both Electron and Tauri. +- Native SideCar/browser previews are sandboxed without same-origin access, so DOM comment inspection is web-only. + ### Key UI Files | Path | Purpose | @@ -123,7 +140,7 @@ Then open a pull request on GitHub targeting the `dev` branch. | `packages/ui/src/components/session/session-view.tsx` | Main session view | | `packages/ui/src/lib/i18n/messages/` | Translation files (en, es, fr, ja, ru, he, zh-Hans) | -> For a comprehensive map of all six functional areas (server, UI, desktop, speech/audio, build, Cloudflare), SDK integration patterns, and feature traces, load the `codenomad-architecture-guide` skill: +> For the package map, native OpenCode V2 integration, ownership boundaries, and feature traces, load the `codenomad-architecture-guide` skill: > `.opencode/skills/codenomad-architecture-guide/SKILL.md` ### Styling diff --git a/DESKTOP_V2_COMPARISON.md b/DESKTOP_V2_COMPARISON.md new file mode 100644 index 000000000..d3af840ec --- /dev/null +++ b/DESKTOP_V2_COMPARISON.md @@ -0,0 +1,151 @@ +# OpenCode Desktop V2 Comparison + +## Review Baseline + +This review compares: + +- CodeNomad `DEV-v2` at `f03a17a4`, plus the fixes recorded below. +- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/v2` at `cc15c2a488` (2026-08-20). +- CodeNomad's pinned `@opencode-ai/client` and managed CLI line, `0.0.0-beta-17595`, whose approximate OpenCode source baseline is `ea1ff90e42`. + +The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. The old `upstream/opencode-2-0` branch and the intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. + +This is an incremental review. It does not repeat issues already closed by CodeNomad parity commits including `4b96f462`, `4359b4bf`, `249a96e7`, `ef70a8b7`, `affdb96f`, `e1987b9c`, `5484f9c9`, `b5f3fc6e`, `2c9ced63`, and `db0464f7`. + +## Result + +CodeNomad implements the important V2 architecture rather than emulating the V1 desktop model. It uses the native client contract, locations, shared service, sessions, messages, Forms, permissions, providers, Shells, worktrees, and event stream. Its multi-window and cross-host restore implementation is broader than the official Electron-only desktop implementation. + +The comparison found five concrete CodeNomad defects and one obsolete configuration block. All six are fixed in the commits accompanying this document. The remaining differences are either an SDK/runtime upgrade, a scoped workflow defect, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors. + +## Closed Findings + +### Message history pagination + +**Previous behavior:** `packages/ui/src/stores/session-api.ts` sent both `order: "desc"` and `cursor` when loading older messages. The V2 server rejects that combination with `InvalidCursorError`, so transcripts longer than 200 messages could not load their older pages. + +**Official behavior:** `packages/client/src/solid/data.ts` sends `order` only for the initial page and follows the opaque native cursor without another ordering selector. OpenCode fixed its own caller in `28b4cade9e`. + +**Resolution:** CodeNomad now omits `order` on continuation requests and retains `cursor.next` as the sole pagination authority. A regression test rejects any cursor request that also includes `order`. + +### External navigation schemes + +**Previous behavior:** Electron treated every scheme except `file:`, `data:`, and `javascript:` as external. Tauri sent every rejected navigation to the privileged native opener. Renderer-controlled links could therefore invoke handlers such as `vscode:` or `ms-settings:`. + +**Official behavior:** `packages/desktop/src/main/files/external-url.ts` permits only `http:`, `https:`, and `mailto:` external URLs. + +**Resolution:** Both CodeNomad hosts now use the same scheme allowlist. Registered renderer origins still navigate internally; permitted web and mail links open externally; all other schemes are denied. + +### Wildcard service endpoints + +**Previous behavior:** CodeNomad rejected a healthy OpenCode service that advertised `0.0.0.0`, even though the CLI supports wildcard binding. + +**Official behavior:** `packages/desktop/src/main/service/background-service.ts` connects to a wildcard-bound service through `127.0.0.1`. + +**Resolution:** CodeNomad rewrites only the advertised wildcard hostname to loopback before authenticated health checks and client construction. Existing loopback URL strings retain their previous identity and formatting. Non-loopback remote addresses remain rejected. + +### Failed plugin inventory + +**Previous behavior:** Metadata projection called `startsWith` on every plugin ID. Current OpenCode V2 can report a failed plugin without an ID, which would break metadata refresh after a client upgrade. + +**Official behavior:** `packages/schema/src/plugin.ts` defines active and failed plugin variants, with an optional ID for failures. + +**Resolution:** CodeNomad now accepts only string IDs when projecting its current plugin-name list. An ID-less failed record can no longer prevent project, MCP, and plugin metadata from loading. + +### Deprecated models + +**Previous behavior:** CodeNomad discarded model status and exposed deprecated models in the selector. + +**Official behavior:** `packages/app/src/context/global-sync/utils.ts` excludes models whose status is `deprecated`. + +**Resolution:** Deprecated models are filtered while building the CodeNomad provider catalog. Active, alpha, beta, current default, and newly discovered models retain their existing behavior. + +### Inactive Tauri asset configuration + +`tauri.conf.json` declared an unrestricted asset protocol scope even though the asset protocol feature is disabled and CodeNomad has no asset URL caller. It was not an exploitable file-read path in the reviewed build, but the unused broad scope was misleading and unsafe if the feature were enabled later. The configuration block has been deleted. + +## Existing Parity + +The review reconfirmed these areas and found no current incompatibility: + +- Shared authenticated OpenCode service discovery and one daemon per host or WSL environment. +- Multiple logical CodeNomad workspaces over one native location, including duplicate-folder instances. +- Project-wide session inventory, native session and message cursors, lazy history, and ancestor hydration. +- Optimistic prompt admission with client-minted identity and authoritative event reconciliation. +- Native Forms, permissions, provider authentication, commands, agents, variants, attachments, and instructions. +- One upstream event stream with reconnect generation fencing and targeted authoritative refresh. +- Native background Shell listing/removal and ownership-checked Shell/PTY proxy routes. +- Root/worktree location ownership, session evacuation before worktree deletion, and WSL translation. +- Independent multi-window tabs and content-addressed restore state across Electron and Tauri. +- Strict proxy route allowlisting, traversal protection, selector stripping, authentication isolation, and location ownership checks. + +Service stop removal is intentional: CodeNomad does not own the shared daemon. Upstream session sharing is disabled, so its absence is not a parity gap. Upstream's temporary SSE heartbeat change was reverted and requires no CodeNomad change. + +## Remaining Correctness Work + +### Active-location MCP and plugin status + +**Priority:** Medium. **Client upgrade required:** No. + +`packages/ui/src/lib/hooks/use-instance-metadata.ts` currently queries MCP and plugin state with the instance root. The active session may belong to a worktree or nested location with different `.opencode` configuration. Official Desktop derives the status location from the selected session in `packages/app/src/pages/session.tsx` and `status-popover-body.tsx`. + +The CodeNomad metadata request and cache authority should be keyed by the active `SessionInfo.location`, and MCP toggles should use that same location. This needs a focused state change rather than a root fallback patch because switching tabs must not display or mutate another location's MCP state. + +### Undo without redo + +**Priority:** High. **Client upgrade required:** No. + +CodeNomad exposes native `session.revert.stage` for undo but does not expose `session.revert.clear` for redo. Official Desktop implements both in `packages/app/src/pages/session/use-session-commands.tsx`. An accidental undo can therefore only be reversed indirectly; submitting another prompt commits the staged boundary. + +Add a redo command that clears the staged revert and restores the prompt/viewport behavior. The proxy allowlist must admit only the matching native clear route. + +### Signed desktop releases + +**Priority:** Medium. **Client upgrade required:** No. + +The reviewed Windows release artifacts are not Authenticode-signed, and the macOS workflow does not establish a Developer ID and notarization identity. Official Desktop configures Windows signing and macOS signing/notarization in `packages/desktop/electron-builder.config.ts`. + +This is distribution hardening rather than V2 API parity. Release jobs should fail unless Windows signatures, macOS identity, and notarization validate against the expected publisher. + +## Runtime Upgrade + +**Priority:** High, but separate from this compatibility patch. + +CodeNomad remains pinned to `0.0.0-beta-17595`. Current `upstream/v2` includes later runtime fixes that settle unexpected typed plugin/tool failures instead of leaving calls pending and preserve Unicode boundaries during compaction truncation. + +The client and managed CLI target should move together only after generated type, proxy route, event, plugin inventory, Forms, session, and real workspace smoke tests pass. The plugin ID guard in this review removes one known upgrade blocker. The current pin remains functional and startup still accepts another API-compatible CLI after authenticated validation. + +## Optional Feature Gaps + +These are official Desktop capabilities, not migration blockers: + +- **Interactive PTY UI:** CodeNomad proxies native PTY lifecycle routes but has no embedded terminal, connect-ticket WebSocket, resize, reconnect, or restore UI. The existing external terminal action and background Shell panel are not equivalent. +- **Session export:** Official Desktop paginates and exports a complete session. CodeNomad has no export command or allowlisted export route. +- **References and MCP resources:** Official prompt suggestions can attach configured references and MCP resources. CodeNomad's picker currently offers agents, files, and commands only. + +Implement these when product scope requires them. They should use the existing native V2 APIs; no compatibility abstraction or V1 fallback is needed. + +## Ponytail Cleanup + +The same change removes dead migration-era code without changing persistence, restore, daemon ownership, or multi-window behavior: + +- Unused client connection subscription records and notification machinery. +- Unread spawn classification and WSL metadata. +- One-element event type sets. +- Unread workspace creation state and parameters. +- Dead reasoning, preview, timeline, filesystem-event, preload, and render imports/helpers. +- Obsolete background Shell rename translations for a UI action that does not exist. +- Assigned-but-unread test request captures. + +The implementation and tests finish at 109 added and 282 deleted lines, a net reduction of 173 lines. + +## Validation + +- Electron native suite: 158 passed. +- Tauri Rust suite: 104 passed. +- Focused server suites: 62 passed, plus 18 spawn tests after final cleanup. +- Focused UI suites: 22 passed, plus 2 filesystem tests after final cleanup. +- UI, Electron, and server TypeScript typechecks passed. +- UI production build passed. +- `cargo fmt --check` and `git diff --check` passed. +- Final independent diff review found no production correctness or security finding. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md new file mode 100644 index 000000000..61989fdc7 --- /dev/null +++ b/MIGRATION_V2.md @@ -0,0 +1,139 @@ +# OpenCode V2 Migration + +## Summary + +This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspace runtime architecture with the experimental native OpenCode V2 client and one shared OpenCode service. It intentionally provides no V1 runtime fallback. + +The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format. + +Server and UI pin `@opencode-ai/client` to `0.0.0-beta-17595`. The selected `opencode2` CLI is managed independently: CodeNomad's updater targets the reviewed client-compatible release, but startup accepts another compatible CLI after authenticated health and API validation instead of enforcing an exact version. + +The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md). + +## Native V2 Adoption + +- Use native locations and `SessionInfo.location` as the authority for workspace, session, file, event, Shell, PTY, and Git worktree ownership. +- Use native APIs for projects, sessions, messages, prompts, commands, models, agents, providers, MCP, permissions, Forms, files, VCS, instructions, Shells, and PTYs. +- Use 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 `@opencode-ai/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. +- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering and reviewed upstream compatibility routes remain where applicable. +- Replace shell-mode prompts with native `session.shell`. +- Replace CodeNomad background processes with native `shell.*` resources. The Status UI lists and removes Shells; create/output/timeout routes remain available through the ownership-checked proxy. Interactive `pty.*` terminals remain separate. +- Store voice-mode instructions with `session.instructions.entry` and synchronize them before prompts, commands, and session Shell calls. + +## Shared Service Model + +- OpenCode V2 explicitly confirms that the intended architecture is [one shared process for all workspaces and clients](https://github.com/anomalyco/opencode/issues/43898#issuecomment-5372607267); workload slowdowns must be profiled and fixed within that topology rather than worked around with private servers. +- Replace one OpenCode runtime per workspace with one externally owned global service in the selected host or WSL environment. +- Discover or start it through the selected CLI's official `service status`, `service start`, and `service get password` commands. +- Accept only bounded, authenticated loopback health endpoints and pin one service identity while connected. +- Use OpenCode's standard service registration, state, and database. CodeNomad owns no private daemon port, database, registration, or PID. +- Pass configured startup environment variables and `NODE_EXTRA_CA_CERTS` only when starting a missing service. Strip legacy `OPENCODE_DB` and `XDG_STATE_HOME` overrides rather than taking ownership of OpenCode storage. +- Never stop the global daemon during CodeNomad shutdown. Backend shutdown clears only CodeNomad's cached connection and logical workspace state. +- Run the Linux CLI inside the selected WSL distribution and require Windows localhost forwarding. No cross-namespace PID fallback or process signaling remains. + +## Workspace, Location, and Tab Model + +- A CodeNomad workspace is now a logical UUID-backed instance over a native OpenCode location, not an OpenCode process. +- A normal folder launch always creates a new logical instance and tab, even when the same or canonically equivalent directory is already open. +- The explicit **Open** action selects an existing instance instead of creating another one. +- Duplicate-folder instances share the same daemon and native location but keep independent logical IDs, tabs, selection, drafts, and view state. +- The workspace catalog is shared by the backend. Tab membership, order, active selection, SideCars, drafts, attachments, and view state are local to each native window. +- Closing a tab or window detaches only local UI state. **Stop Workspace** deletes the selected logical instance and evicts the native location only after its final logical owner is removed. +- Restore matches duplicate-folder tabs by normalized-path occurrence rather than collapsing them into one instance. +- Owned Git worktrees are resolved server-side and participate in the same location, request, and event-routing rules as the root directory. + +## Sessions, Streaming, and Reconciliation + +- Query a complete project-scoped session inventory across root and worktree subpaths without one request per parent. +- Follow native `cursor.next` values for session and message pagination. The proxy decodes session cursors only to validate embedded directory/project scope, strips competing selectors, and forwards the original cursor unchanged. +- Hydrate only missing ancestor chains with `session.get` and fetch active status for later session pages. +- Load message history lazily in native pages, prepend older pages without duplicate IDs, and reject delayed REST responses that would overwrite newer event state. +- Route location-scoped events to every owning logical workspace and resolve locationless session, permission, Form, Shell, and PTY events through native ownership. +- Use one upstream event subscription and browser `EventSource` for web, Electron, and Tauri. +- Treat events as volatile projections, not durable history. Internal stream generations and browser reconnects trigger targeted authoritative refreshes for workspaces, sessions, active state, pending permissions/Forms, loaded messages, catalogs, and invalidated file/config state. +- Preserve the Solid projection controller across reconnects and merge live records into REST history rather than clearing usable state. + +## UI and Memory Optimizations + +- Virtualize session lists and message timelines with `virtua` to bound mounted DOM for large histories. +- Preserve user-controlled scroll position, bottom-follow intent, oversized streaming hold points, and anchor-based restore across live updates. +- Keep native cursors authoritative; do not infer completion from page length. +- Bound instance logs and validate restore-state counts, IDs, paths, snapshots, string budgets, partition sizes, and graph sizes. +- Reconcile only affected resources after native events or reconnects instead of periodically reloading full message history. +- Keep optimistic prompts visible before native admission and replace temporary parts with authoritative native parts without duplicating output. + +## Forms, Permissions, and Providers + +- Merge pending permissions and Forms into one ordered interruption UI while preserving their separate native reply/cancel APIs. +- Reconcile pending requests from every owned root/worktree location after reconnect and remove stale local requests only from authoritative results. +- Carry location for global Forms through the proxy without inventing a synthetic session. +- Support native provider API-key, OAuth, command, and interactive Form authentication, including dynamic required fields and custom choices. +- Keep Yolo policy server-owned: persist enabled session families, inherit policy across descendants, deduplicate duplicate-instance delivery by permission ID, retry within a fixed bound, and synchronize state to every window. + +## Worktree Safety + +Before deleting a Git worktree, CodeNomad now: + +1. Resolves the native project and inventories every session with native cursors. +2. Selects sessions whose native location belongs to the worktree. +3. Refuses deletion while affected sessions are active. +4. Moves affected sessions to the root location. +5. Re-inventories until the moves are authoritative. +6. Removes the Git worktree inside the same rollback boundary. +7. Restores moved sessions if verification or deletion fails. + +Git status, diff, stage, unstage, commit, worktree creation, and worktree removal remain CodeNomad server operations where V2 does not provide equivalent transactional behavior. + +## Proxy and Security Boundaries + +- Expose only reviewed method/path pairs; new upstream APIs are unavailable until explicitly allowlisted. +- Verify workspace ownership for native locations, sessions, projects, cursors, Shell/PTY CWDs, imported session locations, and prompt file URIs before forwarding. +- Reject encoded path traversal, foreign locations/projects, forged cursors, and browser-supplied workspace selectors. +- Translate host/WSL paths only after ownership validation. +- Strip CodeNomad cookies, browser authorization, forwarding headers, and incoming `x-opencode-*` headers; inject shared-service authentication server-side. +- Block upstream cookies and authentication challenges and avoid logging unredacted secret-bearing request bodies. +- Sandbox native SideCar/browser previews without `allow-same-origin`; native hosts do not inspect embedded cross-origin DOM. + +## Desktop and Restore Restructuring + +- Run one native singleton and one CodeNomad backend per channel/config profile. Stable, development, and non-default config identities use isolated singleton, browser-storage, backend, and client-state scopes. +- Focus the most-recent local window on a second launch unless `--new-window` is supplied. +- Give each Electron or Tauri window a UUID and independent tab/restore record while sharing the backend and global OpenCode data. +- Persist one record per window in a V3 envelope over a V2 content-addressed partition graph. +- Split workspace/session documents and chunk attachments so unrelated state does not rewrite one monolithic snapshot. +- Validate hashes, canonical JSON, allowed fields, graph references, and size/count limits. A corrupt leaf can be discarded while valid sibling state survives. +- Prepare immutable partitions before atomically publishing the root; serialize and fence writes against ownership loss, renderer-token mismatch, shutdown, and migration races. +- Coordinate Electron/Tauri ownership with participant markers, process-start identity, stale-owner recovery, and verified release. +- Copy legacy Electron/Tauri client state non-destructively on first migration and refuse to overwrite unsupported future formats. +- Store the stable/default cross-host state under `~/.codenomad/client-state/v2`; development and non-default profiles use derived profile-specific locations. +- Restore every persisted UUID window, exact active tab/session selection, drafts, attachments, expansion, scroll/follow state, idle markers, interrupted generations, bounds, and zoom. +- Fence late workspace creation and cleanup so cancelled restore requests cannot leak or delete the wrong logical instance. + +## Removed Legacy Architecture + +The migration deletes rather than maintains these superseded systems: + +- The complete `packages/opencode-plugin` package, its packaging script, desktop resources, setup hooks, environment plumbing, and plugin README. +- Plugin POST/SSE channels, handlers, voice synchronization routes, and the custom plugin-to-CodeNomad event bridge. +- Per-workspace OpenCode runtime processes, loopback servers, clients, authentication, binary selection, launch cleanup, process identity, process-tree signaling, and runtime tests. +- The `.codenomad/worktreeMap.json` mapping layer and UI-side OpenCode workspace/worktree-client matching. +- The custom background-process manager, persistence, HTTP routes, UI store, and output dialog. +- Legacy Question queues, request event handling, state, components, and tests, replaced by native Forms. +- The V1 message/delta buffer and periodic full-history event reload strategy, replaced by native events plus authoritative reconciliation. +- The duplicate Rust-native Tauri SSE transport, including batching, coalescing, cookie forwarding, pong handling, reconnect code, commands, managed state, and tests. +- The desktop native-event adapter made unnecessary by the shared browser `EventSource` path. +- Message/part deletion controls and compatibility companions for operations not offered by the pinned V2 protocol. +- The server and UI dependency on `@opencode-ai/sdk` and the runtime V1 compatibility path. + +## Validation + +Historical migration checkpoints recorded passing repository CI, focused server/UI suites and typechecks, production desktop builds, native Electron/Tauri tests, and packaged Windows singleton/multi-window smoke coverage. These results describe completed checkpoints, not the current dirty worktree. + +The remaining migration smoke gate is an interactive run against the selected real OpenCode service: open a folder, open an existing session, send and receive a prompt, reload and restore it, and exercise the native background Shell proxy lifecycle. See [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md) for all other release, correctness, runtime-upgrade, and optional feature work. + +## Review Notes + +- The generated V2 client remains experimental. Review its installed declarations and release notes on every dependency upgrade; public `@opencode-ai/sdk` examples are not authoritative for this branch. +- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), and `node_modules/@opencode-ai/client/dist/promise/`. +- This branch intentionally has no OpenCode V1 fallback or private OpenCode database. diff --git a/README.md b/README.md index c22b85c09..f1fb239a9 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ npx @neuralnomads/codenomad-dev --password --launch SideCars let you open local web tools inside CodeNomad as tabs. +Native desktop previews are sandboxed without same-origin access. DOM comment inspection is available only in the web client. +
Configuration diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 290f7f798..0b679468e 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -16,6 +16,10 @@ Executive summary of the entire project - **start here!** **MVP development philosophy** - Focus on functionality, NOT performance ⚡ +### [DESKTOP_V2_COMPARISON.md](../DESKTOP_V2_COMPARISON.md) + +Incremental comparison with official OpenCode Desktop V2, including parity, closed defects, remaining correctness work, runtime upgrade scope, and optional feature gaps. + --- ## Specification Documents @@ -50,47 +54,12 @@ Executive summary of the entire project - **start here!** - File structure - TypeScript interfaces -- Process management logic -- SDK integration patterns +- Shared OpenCode service and location ownership +- Native `@opencode-ai/client` integration - IPC communication - Error handling strategies -**Read this to understand:** How to actually build it - -### [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. +**Read this to understand:** Current implementation boundaries --- @@ -101,15 +70,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 +82,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 +89,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 +99,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._ diff --git a/dev-docs/MVP-PRINCIPLES.md b/dev-docs/MVP-PRINCIPLES.md index f16579c1a..f66784a25 100644 --- a/dev-docs/MVP-PRINCIPLES.md +++ b/dev-docs/MVP-PRINCIPLES.md @@ -120,7 +120,7 @@ The MVP (Minimum Viable Product) is about proving the concept and getting feedba **Simple approach:** -- Direct SDK calls +- Direct generated Promise client calls - Basic error handling - Simple retry (if at all) diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 253270a9d..a3da1e23e 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -2,33 +2,20 @@ ## 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 -``` -packages/opencode-client/ -├── docs/ # Comprehensive documentation -│ ├── architecture.md # System architecture & design -│ ├── user-interface.md # UI/UX specifications -│ ├── technical-implementation.md # Technical details & patterns -│ ├── build-roadmap.md # Phased development plan -│ └── SUMMARY.md # This file -├── tasks/ -│ ├── README.md # Task management guide -│ ├── todo/ # Tasks to implement -│ │ ├── 001-project-setup.md -│ │ ├── 002-empty-state-ui.md -│ │ ├── 003-process-manager.md -│ │ ├── 004-sdk-integration.md -│ │ └── 005-session-picker-modal.md -│ └── done/ # Completed tasks (empty) -└── README.md # Project overview - +```text +packages/server/ Fastify control API and shared OpenCode service +packages/ui/ SolidJS UI and native Promise clients +packages/electron-app Electron host +packages/tauri-app/ Tauri host +dev-docs/ Development documentation ``` ## Documentation Overview @@ -81,9 +68,9 @@ packages/opencode-client/ - Technology stack details - Project file structure - State management patterns -- Process management implementation -- SDK integration approach -- SSE event handling +- Shared OpenCode service and location ownership +- Native `@opencode-ai/client` integration +- `/api/events` multiplexing - IPC communication - Error handling strategies - Performance optimizations @@ -92,75 +79,11 @@ packages/opencode-client/ - Complete project structure - TypeScript interfaces -- Process spawning logic -- SDK client management +- 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 - Process Manager** (4-5 hours) - -- Spawn OpenCode server processes -- Parse stdout for port extraction -- Kill processes on command -- Handle errors and timeouts -- Auto-cleanup on app quit - -**004 - SDK Integration** (3-4 hours) - -- Create SDK client per instance -- 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 @@ -169,18 +92,19 @@ packages/opencode-client/ - **Level 2**: Session tabs (multiple per instance) - Allows working on multiple projects with multiple conversations each -### 2. Process Management in Main Process +### 2. Shared Service Management -- Electron main process spawns servers -- Parses stdout to get port -- IPC sends port to renderer -- Ensures clean shutdown on app quit +- CodeNomad uses the official host or WSL CLI lifecycle to connect to one externally owned global service +- Workspace folders become validated native locations +- UI traffic stays behind the CodeNomad proxy +- Shutdown clears only CodeNomad's in-memory connection state and never stops the daemon +- Explicit Stop Workspace evicts its location; tab/window close only detaches local UI -### 3. One SDK Client Per Instance +### 3. One Shared Service, Location-Scoped Clients -- Each instance has its own HTTP client -- Connects to different port (different server) -- Isolated state prevents cross-contamination +- 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 ### 4. SolidJS for Reactivity @@ -206,15 +130,6 @@ packages/opencode-client/ ## 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 @@ -232,73 +147,14 @@ packages/opencode-client/ - 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: @@ -307,42 +163,18 @@ packages/opencode-client/ - SolidJS docs: https://solidjs.com - Kobalte UI: https://kobalte.dev -## Questions to Resolve - -Before starting implementation, clarify: - -1. Exact OpenCode CLI syntax for spawning server -2. Expected stdout format for port extraction -3. SDK package location and version -4. Any platform-specific gotchas -5. Icon and branding assets location - -## 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! 🚀 +## Current OpenCode Baseline + +- Experimental protocol client: server and UI use the same reviewed version; the runtime `opencode2` CLI is independently updated and checked through service/API compatibility, not an exact version gate +- Service: one externally owned global endpoint with a CodeNomad-pinned host or WSL identity +- Workspaces: native locations/directories +- Database: OpenCode's global default; configured startup environment applies only when CodeNomad starts a missing daemon +- Desktop: one singleton process/backend per channel/config profile, multiple UUID windows, and isolated stable/dev/non-default native state +- Restore: V3 per-window envelope over a V2 content-addressed partition graph; OpenCode sessions/messages are shared while tabs/drafts/views are local +- Events: volatile native stream with authoritative reconnect reconciliation +- Proxy: explicit method/path allowlist; upstream additions are not automatic +- Shell mode and instructions: native session APIs, separate from background Shell and PTY management +- Background Shells: location-scoped native `shell.*` entries in Status, refreshed on Shell events/reconnect with metadata and ownership-checked removal; output uses native cursor pagination +- PTYs: separate native interactive terminals, not background-process records +- Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted +- Git mutations and Yolo: CodeNomad-owned diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 21f94654f..63b1c1803 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -2,311 +2,91 @@ ## Overview -CodeNomad is a cross-platform desktop application built with Electron that provides a multi-instance, multi-session interface for interacting with OpenCode servers. Each instance manages its own OpenCode server process and can handle multiple concurrent sessions. +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. -## High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Electron Main Process │ -│ - Window management │ -│ - Process spawning (opencode serve) │ -│ - IPC bridge to renderer │ -│ - File system operations │ -└────────────────┬────────────────────────────────────────┘ - │ IPC -┌────────────────┴────────────────────────────────────────┐ -│ Electron Renderer Process │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ SolidJS Application │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ Instance Manager │ │ │ -│ │ │ - Spawns/kills OpenCode servers │ │ │ -│ │ │ - Manages SDK clients per instance │ │ │ -│ │ │ - Handles port allocation │ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ State Management (SolidJS Stores) │ │ │ -│ │ │ - instances[] │ │ │ -│ │ │ - sessions[] per instance │ │ │ -│ │ │ - normalized message store per session │ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ UI Components │ │ │ -│ │ │ - InstanceTabs │ │ │ -│ │ │ - SessionTabs │ │ │ -│ │ │ - MessageSection │ │ │ -│ │ │ - PromptInput │ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - │ HTTP/SSE -┌────────────────┴────────────────────────────────────────┐ -│ Multiple OpenCode Server Processes │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Instance 1 │ │ Instance 2 │ │ Instance 3 │ │ -│ │ Port: 4096 │ │ Port: 4097 │ │ Port: 4098 │ │ -│ │ ~/project-a │ │ ~/project-a │ │ ~/api │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -## Component Layers - -### 1. Main Process Layer (Electron) - -**Responsibilities:** - -- Create and manage application window -- Spawn OpenCode server processes as child processes -- Parse server stdout to extract port information -- Handle process lifecycle (start, stop, restart) -- Provide IPC handlers for renderer requests -- Manage native OS integrations (file dialogs, menus) - -**Key Modules:** - -- `main.ts` - Application entry point -- `process-manager.ts` - OpenCode server process spawning -- `ipc-handlers.ts` - IPC communication handlers -- `menu.ts` - Native application menu - -### 2. Renderer Process Layer (SolidJS) - -**Responsibilities:** - -- Render UI components -- Manage application state -- Handle user interactions -- Communicate with OpenCode servers via HTTP/SSE -- Real-time message streaming - -**Key Modules:** - -- `App.tsx` - Root component -- `stores/` - State management -- `components/` - UI components -- `contexts/` - SolidJS context providers -- `lib/` - Utilities and helpers - -### 3. Communication Layer - -**HTTP API Communication:** - -- SDK client per instance -- RESTful API calls for session/config/file operations -- Error handling and retries - -**SSE (Server-Sent Events):** - -- One EventSource per instance -- Real-time message updates -- Event type routing -- Reconnection logic - -**CLI Proxy Paths:** - -- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance. -- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces//instance`), which acts as the base URL for both REST and SSE calls. -- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session. - -## Data Flow - -### Instance Creation Flow - -1. User selects folder via Electron file dialog -2. Main process receives folder path via IPC -3. Main process spawns `opencode serve --port 0` -4. Main process parses stdout for port number -5. Main process sends port + PID back to renderer -6. Renderer creates SDK client for that port -7. Renderer fetches initial session list -8. Renderer displays session picker - -### Message Streaming Flow - -1. User submits prompt in active session -2. Renderer POSTs to `/session/:id/message` -3. SSE connection receives `MessageUpdated` events -4. Events are routed to correct instance → session -5. Message state updates trigger UI re-render -6. Messages display with auto-scroll - -### Child Session Creation Flow - -1. OpenCode server creates child session -2. SSE emits `SessionUpdated` event with `parentId` -3. Renderer adds session to instance's session list -4. New session tab appears automatically -5. Optional: Auto-switch to new tab - -## State Management - -### Instance State - -``` -instances: Map - activeSessionId: string | null - logs: string[] -}> -``` - -### Session State - -``` -Session: { - id: string - title: string - parentId: string | null - messages: Message[] - agent: string - model: { providerId: string, modelId: string } - status: 'idle' | 'streaming' | 'error' -} +```text +Desktop host -> CodeNomad server -> one shared OpenCode service + ^ | + | +-> CodeNomad /api/* and /api/events + +------ UI clients through /workspaces/:id/instance/api/* ``` -### Message State - -``` -Message: { - id: string - sessionId: string - type: 'user' | 'assistant' - parts: Part[] - timestamp: number - status: 'sending' | 'sent' | 'streaming' | 'complete' | 'error' -} -``` - -## Tab Hierarchy - -### Level 1: Instance Tabs - -Each tab represents one OpenCode server instance: - -- Label: Folder name (with counter if duplicate) -- Icon: Folder icon -- Close button: Stops server and closes tab -- "+" button: Opens folder picker for new instance - -### Level 2: Session Tabs - -Each instance has multiple session tabs: - -- Main session tab (always present) -- Child session tabs (auto-created) -- Logs tab (shows server output) -- "+" button: Creates new session - -### Tab Behavior - -**Instance Tab Switching:** - -- Preserves session tabs -- Switches active SDK client -- Updates SSE event routing - -**Session Tab Switching:** - -- Loads messages for that session -- Updates agent/model controls -- Preserves scroll position - -## Technology Stack - -### Core - -- **Electron** - Desktop wrapper -- **SolidJS** - Reactive UI framework -- **TypeScript** - Type safety -- **Vite** - Build tool +There is no `@opencode-ai/sdk` integration and no `packages/opencode-plugin` package. -### UI +## Shared Service And Locations -- **TailwindCSS** - Styling -- **Kobalte** - Accessible UI primitives -- **Shiki** - Code syntax highlighting -- **Marked** - Markdown parsing +`packages/server/src/workspaces/opencode-service.ts` runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle, validates the authenticated loopback endpoint, and pins that identity while active. It connects to one externally owned global daemon and never stops it on backend shutdown. CodeNomad owns no private daemon port, database, registration, or PID. -### Communication +OpenCode owns the daemon's standard state and database. Configured allowed environment variables and `NODE_EXTRA_CA_CERTS` apply only if CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. WSL support requires Windows localhost forwarding and executes the Linux CLI lifecycle inside the selected distribution without cross-namespace PID operations. -- **OpenCode SDK** - API client -- **EventSource** - SSE streaming -- **Node Child Process** - Process spawning +`packages/server/src/workspaces/manager.ts` treats selected folders as native OpenCode locations: -## Error Handling +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. On explicit **Stop Workspace**, evict the location and its resources from the global service, then remove CodeNomad's logical workspace. -### Process Errors +Workspaces are not OpenCode processes and do not own ports or PIDs. Closing an ordinary tab or native window only detaches local UI state and never evicts the location. -- Server fails to start → Show error in instance tab -- Server crashes → Attempt auto-restart once -- Port already in use → Find next available port +## Native Profiles, Windows, And Client State -### Network Errors +Electron and Tauri run one native singleton process and one CodeNomad backend per channel/config profile. A second launch focuses the most-recent window by default; `--new-window` creates another UUID-backed window. Stable, dev, and non-default config profiles isolate singleton identity, backend/browser storage, and client state. -- API call fails → Show inline error, allow retry -- SSE disconnects → Auto-reconnect with backoff -- Timeout → Show timeout error, allow manual retry +OpenCode sessions and messages remain shared through the global daemon. Window membership, tabs, drafts, view state, and native bounds are local to each UUID window. Client-state V3 is a per-window envelope over the V2 content-addressed partition graph: immutable partitions are prepared before atomic root publication, writes and migrations are fenced by current ownership, and garbage collection runs after publication while retaining every partition referenced by any window. -### User Errors +Native SideCar/browser previews use a sandbox without `allow-same-origin`, so they cannot inspect the embedded DOM. DOM comment inspection is available only in the web client. -- Invalid folder selection → Show error dialog -- Permission denied → Show actionable error message -- Out of memory → Graceful degradation message +## API Boundaries -## Performance Considerations +CodeNomad control APIs live under `/api/*`. Important routes include: -**Note: Performance optimization is NOT a focus for MVP. These are future considerations.** +- `/api/workspaces` and `/api/workspaces/:id/worktrees/*` +- `/api/workspaces/:id/worktrees/:slug/git-status|git-diff|git-stage|git-unstage|git-commit` +- `/api/events` and `/api/client-connections/pong` +- `/api/storage`, `/api/settings`, `/api/filesystem`, `/api/speech` -### Message Rendering (Post-MVP) +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. -- Start with simple list rendering - no virtual scrolling -- No message limits initially -- Only optimize if users report issues -- Virtual scrolling can be added in Phase 8 if needed +Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId`; Yolo notifications use `/api/events`. -### State Updates +## Client And Events -- SolidJS fine-grained reactivity handles most cases -- No special optimizations needed for MVP -- Batching/debouncing can be added later if needed +`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. -### Memory Management (Post-MVP) +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. -- No memory management in MVP -- Let browser/OS handle it -- Add limits only if problems arise in testing +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`. -## Security Considerations +## Feature Ownership -- No remote code execution -- Server spawned with user permissions -- No eval() or dangerous innerHTML -- Sanitize markdown rendering -- Validate all IPC messages -- HTTPS only for external requests +| Feature | Owner | +|---|---| +| Sessions, messages, permission/question APIs | OpenCode V2 | +| Shell mode | `client.session.shell` | +| Conversation instructions | `client.session.instructions.entry` | +| Background Shell management | Location-scoped OpenCode V2 `shell.*` API through the ownership-checking proxy; Status panel UI | +| Interactive PTY management | Separate OpenCode V2 `pty.*` API | +| 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 | -## Extensibility Points +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. -### Plugin System (Future) +## Persistence -- Custom slash commands -- Custom message renderers -- Theme extensions -- Keybinding customization +CodeNomad configuration resolves through `packages/server/src/config/location.ts`: `config.yaml`, `state.yaml`, and `instances/` under `~/.config/codenomad/`. `config.json` is migration input only. -### Configuration (Future) +## Key Files -- Per-instance settings -- Global preferences -- Workspace-specific configs -- Import/export settings +- `packages/server/src/index.ts` +- `packages/server/src/server/http-server.ts` +- `packages/server/src/workspaces/opencode-service.ts` +- `packages/server/src/workspaces/manager.ts` +- `packages/server/src/workspaces/instance-events.ts` +- `packages/server/src/workspaces/git-mutations.ts` +- `packages/server/src/permissions/auto-accept-manager.ts` +- `packages/ui/src/lib/sdk-manager.ts` +- `packages/ui/src/lib/api-client.ts` +- `packages/ui/src/stores/session-api.ts` +- `packages/ui/src/stores/session-actions.ts` diff --git a/dev-docs/build-roadmap.md b/dev-docs/build-roadmap.md deleted file mode 100644 index 1fd48cb34..000000000 --- a/dev-docs/build-roadmap.md +++ /dev/null @@ -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 Electron app that can spawn OpenCode servers - -### Tasks - -1. ✅ **001-project-setup** - Electron + SolidJS + Vite boilerplate -2. ✅ **002-empty-state-ui** - Empty state UI with folder selection -3. ✅ **003-process-manager** - Spawn and manage OpenCode server processes -4. ✅ **004-sdk-integration** - Connect to server via SDK -5. ✅ **005-session-picker-modal** - Select/create session modal - -### Deliverables - -- App launches successfully -- Can select folder -- Server spawns automatically -- Session picker appears -- Can create/select session - -### Success Criteria - -- User can launch app → select folder → see session picker -- Server process runs in background -- 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** - One SDK client per instance - -### 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-plugin-system** - Extension API for custom tools - -### Deliverables - -- Virtual scrolling for performance -- Cross-session search -- Workspace persistence -- Theme editor -- Plugin loader - -### 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 SDK 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 diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index bbfc6443e..d360e7a66 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -1,642 +1,95 @@ -# Technical Implementation Details +# Technical Implementation -## Technology Stack +## OpenCode Dependency -### Core Technologies +Server and UI use the same reviewed experimental `@opencode-ai/client` release. Import the generated Promise client from `@opencode-ai/client`. The runtime CLI is independently updated and startup validates service health and API compatibility without requiring the client package's exact version string. Every upgrade must review OpenCode release notes, current documentation, installed declarations, and proxy/API parity. -- **Electron** v28+ - Desktop application wrapper -- **SolidJS** v1.8+ - Reactive UI framework -- **TypeScript** v5.3+ - Type-safe development -- **Vite** v5+ - Fast build tool and dev server +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/`. -### UI & Styling +## Server Integration -- **TailwindCSS** v4+ - Utility-first styling -- **Kobalte** - Accessible UI primitives for SolidJS -- **Shiki** - Syntax highlighting for code blocks -- **Marked** - Markdown parsing -- **Lucide** - Icon library +`OpenCodeSharedService` is the sole service adapter. Host and WSL paths both execute the selected CLI's official `service status`, `service start`, and `service get password` commands, validate the authenticated loopback endpoint, pin its identity while active, and never stop the externally owned global daemon on backend shutdown. -### Communication +OpenCode owns the daemon's standard state, database, and registration; CodeNomad has no private port, database, registration, or daemon PID. Allowed configured environment variables and the current `NODE_EXTRA_CA_CERTS` are passed only to `service start` for a missing daemon. Existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` are ignored. WSL requires Windows localhost forwarding and runs this lifecycle inside Linux without Windows PID operations. -- **OpenCode SDK** (@opencode-ai/sdk) - API client -- **EventSource API** - Server-sent events -- **Node Child Process** - Process management +Workspace creation passes a native location: -### Development Tools - -- **electron-vite** - Electron + Vite integration -- **electron-builder** - Application packaging -- **ESLint** - Code linting -- **Prettier** - Code formatting - -## Project Structure - -``` -packages/opencode-client/ -├── electron/ -│ ├── main/ -│ │ ├── main.ts # Electron main entry -│ │ ├── window.ts # Window management -│ │ ├── process-manager.ts # OpenCode server spawning -│ │ ├── ipc.ts # IPC handlers -│ │ └── menu.ts # Application menu -│ ├── preload/ -│ │ └── index.ts # Preload script (IPC bridge) -│ └── resources/ -│ └── icon.png # Application icon -├── src/ -│ ├── components/ -│ │ ├── instance-tabs.tsx # Level 1 tabs -│ │ ├── session-tabs.tsx # Level 2 tabs -│ │ ├── message-stream-v2.tsx # Messages display (normalized store) -│ │ ├── message-item.tsx # Single message -│ │ ├── tool-call.tsx # Tool execution display -│ │ ├── prompt-input.tsx # Input with attachments -│ │ ├── agent-selector.tsx # Agent dropdown -│ │ ├── model-selector.tsx # Model dropdown -│ │ ├── session-picker.tsx # Startup modal -│ │ ├── logs-view.tsx # Server logs -│ │ └── empty-state.tsx # No instances view -│ ├── stores/ -│ │ ├── instances.ts # Instance state -│ │ ├── sessions.ts # Session state per instance -│ │ └── ui.ts # UI state (active tabs, etc) -│ ├── lib/ -│ │ ├── sdk-manager.ts # SDK client management -│ │ ├── sse-manager.ts # SSE connection handling -│ │ ├── port-finder.ts # Find available ports -│ │ └── markdown.ts # Markdown rendering utils -│ ├── hooks/ -│ │ ├── use-instance.ts # Instance operations -│ │ ├── use-session.ts # Session operations -│ │ └── use-messages.ts # Message operations -│ ├── types/ -│ │ ├── instance.ts # Instance types -│ │ ├── session.ts # Session types -│ │ └── message.ts # Message types -│ ├── App.tsx # Root component -│ ├── main.tsx # Renderer entry -│ └── index.css # Global styles -├── docs/ # Documentation -├── tasks/ # Task tracking -├── package.json -├── tsconfig.json -├── electron.vite.config.ts -├── tailwind.config.js -└── README.md -``` - -## State Management - -### Instance Store - -```typescript -interface InstanceState { - instances: Map - activeInstanceId: string | null - - // Actions - createInstance(folder: string): Promise - removeInstance(id: string): Promise - setActiveInstance(id: string): void -} - -interface Instance { - id: string // UUID - folder: string // Absolute path - port: number // Server port - pid: number // Process ID - status: InstanceStatus - client: OpenCodeClient // SDK client - eventSource: EventSource | null // SSE connection - sessions: Map - activeSessionId: string | null - logs: LogEntry[] -} - -type InstanceStatus = - | "starting" // Server spawning - | "ready" // Server connected - | "error" // Failed to start - | "stopped" // Server killed - -interface LogEntry { - timestamp: number - level: "info" | "error" | "warn" - message: string -} -``` - -### Session Store - -```typescript -interface SessionState { - // Per instance - getSessions(instanceId: string): Session[] - getActiveSession(instanceId: string): Session | null - - // Actions - createSession(instanceId: string, agent: string): Promise - deleteSession(instanceId: string, sessionId: string): Promise - setActiveSession(instanceId: string, sessionId: string): void - updateSession(instanceId: string, sessionId: string, updates: Partial): void -} - -interface Session { - id: string - instanceId: string - title: string - parentId: string | null - agent: string - model: { - providerId: string - modelId: string - } - version: string - time: { created: number; updated: number } - revert?: { - messageID?: string - partID?: string - snapshot?: string - diff?: string - } -} - -// Message content lives in the normalized message-v2 store -// keyed by instanceId/sessionId/messageId - -type SessionStatus = - | "idle" // No activity - | "streaming" // Assistant responding - | "error" // Error occurred - -``` - -### UI Store - -```typescript -interface UIState { - // Tab state - instanceTabOrder: string[] - sessionTabOrder: Map // instanceId -> sessionIds - - // Modal state - showSessionPicker: string | null // instanceId or null - showSettings: boolean - - // Actions - reorderInstanceTabs(newOrder: string[]): void - reorderSessionTabs(instanceId: string, newOrder: string[]): void - openSessionPicker(instanceId: string): void - closeSessionPicker(): void -} -``` - -## Process Management - -### Server Spawning - -**Strategy:** Spawn with port 0 (random), parse stdout for actual port - -```typescript -interface ProcessManager { - spawn(folder: string): Promise - kill(pid: number): Promise - restart(pid: number, folder: string): Promise -} - -interface ProcessInfo { - pid: number - port: number - stdout: Readable - stderr: Readable -} - -// Implementation approach: -// 1. Check if opencode binary exists -// 2. Spawn: spawn('opencode', ['serve', '--port', '0'], { cwd: folder }) -// 3. Listen to stdout -// 4. Parse line matching: "Server listening on port 4096" -// 5. Resolve promise with port -// 6. Timeout after 10 seconds -``` - -### Port Parsing - -```typescript -// Expected output from opencode serve: -// > Starting OpenCode server... -// > Server listening on port 4096 -// > API available at http://localhost:4096 - -function parsePort(output: string): number | null { - const match = output.match(/port (\d+)/) - return match ? parseInt(match[1], 10) : null -} -``` - -### Error Handling - -**Server fails to start:** - -- Parse stderr for error message -- Display in instance tab with retry button -- Common errors: Port in use, permission denied, binary not found - -**Server crashes after start:** - -- Detect via process 'exit' event -- Attempt auto-restart once -- If restart fails, show error state -- Preserve session data for manual restart - -## Communication Layer - -### SDK Client Management - -```typescript -interface SDKManager { - createClient(port: number): OpenCodeClient - destroyClient(port: number): void - getClient(port: number): OpenCodeClient | null -} - -// One client per instance -// Client lifecycle tied to instance lifecycle -``` - -### SSE Event Handling - -```typescript -interface SSEManager { - connect(instanceId: string, port: number): void - disconnect(instanceId: string): void - - // Event routing - onMessageUpdate(handler: (instanceId: string, event: MessageUpdateEvent) => void): void - onSessionUpdate(handler: (instanceId: string, event: SessionUpdateEvent) => void): void - onError(handler: (instanceId: string, error: Error) => void): void -} - -// Event flow: -// 1. EventSource connects to /event endpoint -// 2. Events arrive as JSON -// 3. Route to correct instance store -// 4. Update reactive state -// 5. UI auto-updates via signals -``` - -### Reconnection Logic - -```typescript -// SSE disconnects: -// - Network issue -// - Server restart -// - Tab sleep (browser optimization) - -class SSEConnection { - private reconnectAttempts = 0 - private maxReconnectAttempts = 5 - private reconnectDelay = 1000 // Start with 1s - - reconnect() { - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - this.emitError(new Error("Max reconnection attempts reached")) - return - } - - setTimeout(() => { - this.connect() - this.reconnectAttempts++ - this.reconnectDelay *= 2 // Exponential backoff - }, this.reconnectDelay) - } -} +```ts +await client.location.get({ location: { directory } }) ``` -## Message Rendering - -### Markdown Processing - -```typescript -// Use Marked + Shiki for syntax highlighting -import { marked } from "marked" -import { markedHighlight } from "marked-highlight" -import { getHighlighter } from "shiki" - -const highlighter = await getHighlighter({ - themes: ["github-dark", "github-light"], - langs: ["typescript", "javascript", "python", "bash", "json"], -}) - -marked.use( - markedHighlight({ - highlight(code, lang) { - return highlighter.codeToHtml(code, { - lang, - theme: isDark ? "github-dark" : "github-light", - }) - }, - }), -) -``` - -### Tool Call Rendering - -```typescript -interface ToolCallComponent { - tool: string // "bash", "edit", "read" - input: any // Tool-specific input - output?: any // Tool-specific output - status: "pending" | "running" | "success" | "error" - expanded: boolean // Collapse state -} - -// Render logic: -// - Default: Collapsed, show summary -// - Click: Toggle expanded state -// - Running: Show spinner -// - Complete: Show checkmark -// - Error: Show error icon + message -``` - -### Streaming Updates - -```typescript -// Messages stream in via SSE -// Update strategy: Replace existing message parts - -function handleMessagePartUpdate(event: MessagePartEvent) { - const session = getSession(event.sessionId) - const message = session.messages.find((m) => m.id === event.messageId) - - if (!message) { - // New message - session.messages.push(createMessage(event)) - } else { - // Update existing - const partIndex = message.parts.findIndex((p) => p.id === event.partId) - if (partIndex === -1) { - message.parts.push(event.part) - } else { - message.parts[partIndex] = event.part - } - } - - // SolidJS reactivity triggers re-render -} -``` - -## Performance Considerations - -**MVP Approach: Don't optimize prematurely** - -### Message Rendering (MVP) - -**Simple approach - no optimization:** - -```typescript -// Render all messages - no virtual scrolling, no limits - - {(message) => } - - -// SolidJS will handle reactivity efficiently -// Only optimize if users report issues -``` - -### State Update Batching - -**Not needed for MVP:** - -- SolidJS reactivity is efficient enough -- SSE updates will just trigger normal re-renders -- Add batching only if performance issues arise - -### Memory Management - -**Not needed for MVP:** +`WorkspaceManager` records the returned directory/workspace ID. Explicit **Stop Workspace** evicts that location/resources and removes the logical workspace without stopping the global daemon. Ordinary tab/window close only detaches local UI state and does not call the delete/eviction path. -- No message limits -- No pruning -- No lazy loading -- Let users create as many messages as they want -- Optimize later if problems occur +## Native Windows And Restore State -**When to add optimizations (post-MVP):** +Each channel/config profile has one native singleton process and one backend. A second launch focuses the MRU window unless `--new-window` requests another UUID window; stable, dev, and non-default config profiles use isolated native/browser/client-state scopes. -- Users report slowness with large sessions -- Measurable performance degradation -- Memory usage becomes problematic -- See Phase 8 tasks for virtual scrolling and optimization +OpenCode sessions/messages are shared service data. Each window separately persists tab membership, drafts, view state, and native bounds in the client-state V3 envelope. Snapshot V2 is a SHA-256 content-addressed partition graph. Electron and Tauri prepare immutable partitions, fence migration/root replacement on current ownership and renderer authority, atomically publish the envelope, then conservatively sweep partitions no window references. -## IPC Communication +## UI Integration -### Main Process → Renderer +`packages/ui/src/lib/sdk-manager.ts` constructs clients with `OpenCode.make()` at `/workspaces/:id/instance/`. Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`; pass native `directory`/`location` inputs when required. -```typescript -// Events sent from main to renderer -type MainToRenderer = { - "instance:started": { id: string; port: number; pid: number } - "instance:error": { id: string; error: string } - "instance:stopped": { id: string } - "instance:log": { id: string; entry: LogEntry } -} -``` - -### Renderer → Main Process +Session actions use native APIs directly: -```typescript -// Commands sent from renderer to main -type RendererToMain = { - "folder:select": () => Promise - "instance:create": (folder: string) => Promise<{ port: number; pid: number }> - "instance:stop": (pid: number) => Promise - "app:quit": () => void -} +```ts +await client.session.prompt({ sessionID, text, files }) +await client.session.shell({ sessionID, command }) +await client.session.instructions.entry.put({ sessionID, key, value }) ``` -### Preload Script (Bridge) - -```typescript -// Expose safe IPC methods to renderer -contextBridge.exposeInMainWorld("electronAPI", { - selectFolder: () => ipcRenderer.invoke("folder:select"), - createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder), - stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid), - onInstanceStarted: (callback) => ipcRenderer.on("instance:started", callback), - onInstanceError: (callback) => ipcRenderer.on("instance:error", callback), -}) -``` - -## Error Handling Strategy - -### Network Errors - -```typescript -// HTTP request fails -try { - const response = await client.session.list() -} catch (error) { - if (error.code === "ECONNREFUSED") { - // Server not responding - showError("Cannot connect to server. Is it running?") - } else if (error.code === "ETIMEDOUT") { - // Request timeout - showError("Request timed out. Retry?", { retry: true }) - } else { - // Unknown error - showError(error.message) - } -} -``` +Shell mode and conversation instructions are upstream features and remain separate from native background Shells and interactive V2 PTYs. None requires a CodeNomad plugin. -### SSE Errors - -```typescript -eventSource.onerror = (error) => { - // Connection lost - if (eventSource.readyState === EventSource.CLOSED) { - // Attempt reconnect - reconnectSSE() - } -} -``` - -### User Input Errors - -```typescript -// Validate before sending -function validatePrompt(text: string): string | null { - if (!text.trim()) { - return "Message cannot be empty" - } - if (text.length > 10000) { - return "Message too long (max 10000 characters)" - } - return null -} -``` +Native background Shells are location-scoped and listed in the Status panel. `packages/ui/src/stores/shell-store.ts` refreshes the list on native Shell events and reconnect, exposes native metadata, and supports removal. The proxy verifies Shell `cwd` ownership before ID-scoped operations and forwards native output cursors unchanged. Interactive `pty.*` terminals remain separate. -## Security Measures +## Routing And Security -### IPC 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 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. -- Use `contextIsolation: true` -- Whitelist allowed IPC channels -- Validate all data from renderer -- No `nodeIntegration` in renderer +Never trust a browser-supplied worktree path. Resolve workspace/worktree ownership server-side. -### Process Security +Native SideCar/browser preview iframes are sandboxed without `allow-same-origin`; DOM comment inspection is therefore web-only. -- Spawn OpenCode with user permissions only -- No shell execution of user input -- Sanitize file paths +## CodeNomad-Owned Mutations -### Content Security +Git status/diff and mutations remain CodeNomad APIs. Stage, unstage and commit execute validated Git commands in `packages/server/src/workspaces/git-mutations.ts`; the UI calls `/api/workspaces/:id/worktrees/:slug/git-*`. -- Sanitize markdown before rendering -- Use DOMPurify for HTML sanitization -- No `dangerouslySetInnerHTML` without sanitization -- CSP headers in renderer +Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, observes native permission events, replies with `client.permission.reply`, and publishes `yolo.stateChanged`/`yolo.autoAccepted` over `/api/events`. -## Testing Strategy (Future) +## Events -### Unit Tests +`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. -- State management logic -- Utility functions -- Message parsing +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.*`; background-process refresh events include `shell.created`, `shell.exited`, and `shell.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. -### Integration Tests +## Current Structure -- Process spawning -- SDK client operations -- SSE event handling +```text +packages/server/src/ + server/routes/ CodeNomad /api routes + workspaces/manager.ts workspace/location ownership + workspaces/opencode-service.ts + workspaces/instance-events.ts + workspaces/git-status.ts + workspaces/git-mutations.ts + permissions/ Yolo and permission policy -### E2E Tests - -- Complete user flows -- Multi-instance scenarios -- Error recovery - -## Build & Packaging - -### Development - -```bash -npm run dev # Start Electron + Vite dev server -npm run dev:main # Main process only -npm run dev:renderer # Renderer only +packages/ui/src/ + lib/api-client.ts CodeNomad API and /api/events + lib/sdk-manager.ts native OpenCode Promise clients + stores/opencode-client.ts root client authority + stores/session-api.ts session queries/lifecycle + stores/session-actions.ts prompt, Shell, instructions + stores/shell-store.ts location-scoped native background Shell state/actions ``` -### Production +Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored. -```bash -npm run build # Build all -npm run build:main # Build main process -npm run build:renderer # Build renderer -npm run package # Create distributable -``` - -### Distribution - -- macOS: DMG + auto-update -- Windows: NSIS installer + auto-update -- Linux: Electron portable tar.gz + Tauri deb - -## Configuration Files - -### electron.vite.config.ts - -```typescript -import { defineConfig } from "electron-vite" -import solid from "vite-plugin-solid" - -export default defineConfig({ - main: { - build: { - rollupOptions: { - external: ["electron"], - }, - }, - }, - preload: { - build: { - rollupOptions: { - external: ["electron"], - }, - }, - }, - renderer: { - plugins: [solid()], - resolve: { - alias: { - "@": "/src", - }, - }, - }, -}) -``` +## Validation -### tsconfig.json - -```json -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": ["ES2020", "DOM"], - "jsx": "preserve", - "jsxImportSource": "solid-js", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "paths": { - "@/*": ["./src/*"] - } - } -} -``` +- Run root typecheck or the relevant server/UI workspace typecheck. +- Run focused tests for service lifecycle, instance proxy, event bridge, Git mutations, or Yolo when changing those boundaries. +- Update server API types and UI consumers together. diff --git a/package-lock.json b/package-lock.json index 0e815cfb2..22e5b6ca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,7 @@ "packages/server", "packages/ui", "packages/electron-app", - "packages/tauri-app", - "packages/opencode-plugin" + "packages/tauri-app" ] } }, @@ -73,6 +72,7 @@ "version": "7.28.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1575,10 +1575,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@codenomad/codenomad-opencode-plugin": { - "resolved": "packages/opencode-plugin", - "link": true - }, "node_modules/@codenomad/tauri-app": { "resolved": "packages/tauri-app", "link": true @@ -3188,6 +3184,84 @@ "node": ">= 10.0.0" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@neuralnomads/codenomad": { "resolved": "packages/server", "link": true @@ -3228,52 +3302,6 @@ "node": ">= 8" } }, - "node_modules/@opencode-ai/plugin": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz", - "integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.3.7", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.92", - "@opentui/solid": ">=0.1.92" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz", - "integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==", - "license": "MIT" - }, - "node_modules/@opencode-ai/plugin/node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.17.8", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.8.tgz", - "integrity": "sha512-6MKmsj2ujZyL44jy+12dpwWYDYKPS9fUr+0wVQxaIlPYQ/eAt8T8T3QrybplJ5ZtHfZUX+esXZ02x2UYYm7oEw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, "node_modules/@pinojs/redact": { "version": "0.4.0", "license": "MIT" @@ -3872,6 +3900,12 @@ "solid-js": "^1.8.6" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@suid/base": { "version": "0.11.0", "license": "MIT", @@ -4295,6 +4329,7 @@ "version": "7.20.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4340,7 +4375,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, "license": "MIT", "dependencies": { @@ -4396,6 +4433,7 @@ "version": "22.19.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4470,6 +4508,7 @@ "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cac": "^6.7.14", "colorette": "^2.0.20", @@ -4552,6 +4591,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4754,7 +4794,6 @@ "version": "5.3.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -4772,7 +4811,6 @@ "version": "2.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -4793,7 +4831,6 @@ "version": "2.3.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4807,14 +4844,12 @@ "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -5128,7 +5163,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5192,6 +5226,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5682,7 +5717,6 @@ "version": "4.1.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -5812,7 +5846,6 @@ "version": "1.2.2", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -5824,7 +5857,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -6112,7 +6144,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -6190,6 +6222,7 @@ "version": "24.13.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -6356,7 +6389,6 @@ "version": "24.13.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -6368,7 +6400,6 @@ "version": "10.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -6382,7 +6413,6 @@ "version": "6.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -6394,7 +6424,6 @@ "version": "2.0.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6811,6 +6840,28 @@ "license": "MIT", "optional": true }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "license": "MIT" @@ -7129,8 +7180,7 @@ "node_modules/fs-constants": { "version": "1.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -8347,8 +8397,7 @@ "node_modules/isarray": { "version": "1.0.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.6", @@ -8398,6 +8447,7 @@ "version": "1.21.7", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8545,6 +8595,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, "node_modules/lazy-val": { "version": "1.0.5", "dev": true, @@ -8554,7 +8610,6 @@ "version": "1.0.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -8566,7 +8621,6 @@ "version": "2.3.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8580,14 +8634,12 @@ "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8652,26 +8704,22 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", @@ -8683,8 +8731,7 @@ "node_modules/lodash.union": { "version": "4.6.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -8738,6 +8785,7 @@ "node_modules/marked": { "version": "12.0.2", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9027,6 +9075,37 @@ "version": "2.1.3", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/mz": { "version": "2.7.0", "dev": true, @@ -9102,6 +9181,21 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.27", "dev": true, @@ -9498,6 +9592,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9645,8 +9740,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process-warning": { "version": "3.0.0", @@ -9708,6 +9802,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "license": "MIT", @@ -9895,7 +10005,6 @@ "version": "3.6.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -9909,7 +10018,6 @@ "version": "1.1.3", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -10212,6 +10320,7 @@ "version": "4.52.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10435,6 +10544,7 @@ "node_modules/seroval": { "version": "1.3.2", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -10758,6 +10868,7 @@ "node_modules/solid-js": { "version": "1.9.10", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -10898,7 +11009,6 @@ "version": "1.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -11232,7 +11342,6 @@ "version": "2.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -11425,6 +11534,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11674,6 +11784,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11944,6 +12055,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "dev": true, @@ -12021,6 +12145,7 @@ "version": "5.4.21", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12879,6 +13004,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13073,6 +13199,7 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13302,9 +13429,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -13361,7 +13488,6 @@ "version": "4.1.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -13375,7 +13501,6 @@ "version": "3.0.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -13395,6 +13520,7 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13434,18 +13560,6 @@ "dev": true, "license": "MIT" }, - "packages/opencode-plugin": { - "name": "@codenomad/codenomad-opencode-plugin", - "version": "0.19.0", - "license": "MIT", - "dependencies": { - "@opencode-ai/plugin": "1.3.7" - }, - "devDependencies": { - "@types/node": "^22.18.0", - "typescript": "^5.6.3" - } - }, "packages/server": { "name": "@neuralnomads/codenomad", "version": "0.19.0", @@ -13454,7 +13568,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/sdk": "^1.17.8", + "@opencode-ai/client": "0.0.0-beta-17595", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -13478,6 +13592,48 @@ "typescript": "^5.6.3" } }, + "packages/server/node_modules/@opencode-ai/client": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-17595.tgz", + "integrity": "sha512-uDM6jztQiyXulYQL9G7kx283cv+0WhgQ98AzfClKQnV5mOB5wVBTGIjD1FGRwyxhUNGJBrfaP+NqOwWFjZhoeg==", + "license": "MIT", + "dependencies": { + "@opencode-ai/protocol": "0.0.0-beta-17595", + "@opencode-ai/schema": "0.0.0-beta-17595" + }, + "peerDependencies": { + "effect": "4.0.0-beta.107", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "packages/server/node_modules/@opencode-ai/protocol": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-17595.tgz", + "integrity": "sha512-du77bQXIvOvqzyqS/I5gJMY3FZhXH2Oi3gs3ywAOnHCGRkwmVE9ofvFwR4G/Rzx/oVUOuGkyN28si29AluXGWw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-17595", + "effect": "4.0.0-beta.107" + } + }, + "packages/server/node_modules/@opencode-ai/schema": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-17595.tgz", + "integrity": "sha512-448/fW6HIHd2JT+TSgm4izcAWl51f17GR6WcfGbS9SyWmXFNnX1Fi03xNRgf7dnul9yyceXEsMbreUvteKxAYg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.107" + } + }, "packages/server/node_modules/commander": { "version": "12.1.0", "license": "MIT", @@ -13485,6 +13641,19 @@ "node": ">=18" } }, + "packages/server/node_modules/effect": { + "version": "4.0.0-beta.107", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.107.tgz", + "integrity": "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "uuid": "^14.0.1" + } + }, "packages/server/node_modules/fuzzysort": { "version": "2.0.4", "license": "MIT" @@ -13504,7 +13673,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "^1.17.8", + "@opencode-ai/client": "0.0.0-beta-17595", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", @@ -13530,6 +13699,7 @@ "yaml": "^2.4.2" }, "devDependencies": { + "@types/debug": "^4.1.13", "@vite-pwa/assets-generator": "^1.0.2", "autoprefixer": "10.4.21", "postcss": "8.5.6", @@ -13539,6 +13709,61 @@ "vite-plugin-pwa": "^1.2.0", "vite-plugin-solid": "^2.10.0" } + }, + "packages/ui/node_modules/@opencode-ai/client": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-17595.tgz", + "integrity": "sha512-uDM6jztQiyXulYQL9G7kx283cv+0WhgQ98AzfClKQnV5mOB5wVBTGIjD1FGRwyxhUNGJBrfaP+NqOwWFjZhoeg==", + "license": "MIT", + "dependencies": { + "@opencode-ai/protocol": "0.0.0-beta-17595", + "@opencode-ai/schema": "0.0.0-beta-17595" + }, + "peerDependencies": { + "effect": "4.0.0-beta.107", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "packages/ui/node_modules/@opencode-ai/protocol": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-17595.tgz", + "integrity": "sha512-du77bQXIvOvqzyqS/I5gJMY3FZhXH2Oi3gs3ywAOnHCGRkwmVE9ofvFwR4G/Rzx/oVUOuGkyN28si29AluXGWw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-17595", + "effect": "4.0.0-beta.107" + } + }, + "packages/ui/node_modules/@opencode-ai/schema": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-17595.tgz", + "integrity": "sha512-448/fW6HIHd2JT+TSgm4izcAWl51f17GR6WcfGbS9SyWmXFNnX1Fi03xNRgf7dnul9yyceXEsMbreUvteKxAYg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.107" + } + }, + "packages/ui/node_modules/effect": { + "version": "4.0.0-beta.107", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.107.tgz", + "integrity": "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "uuid": "^14.0.1" + } } } } diff --git a/package.json b/package.json index 30ee74dd3..5e3ae0a59 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "packages/server", "packages/ui", "packages/electron-app", - "packages/tauri-app", - "packages/opencode-plugin" + "packages/tauri-app" ] }, "scripts": { diff --git a/packages/electron-app/README.md b/packages/electron-app/README.md index 6e9a6809e..30ca08e34 100644 --- a/packages/electron-app/README.md +++ b/packages/electron-app/README.md @@ -9,6 +9,10 @@ The Electron app wraps the CodeNomad UI and Server into a standalone executable. - Global keyboard shortcuts - Application menu integration +Each channel/config profile runs one native singleton process and one backend. A second launch focuses the most-recent window unless `--new-window` requests another UUID-backed window; stable, dev, and non-default config profiles keep native/browser/client state isolated. OpenCode sessions/messages remain shared, while tabs, drafts, and views belong to each window. + +Restore state uses a V3 per-window envelope over the V2 content-addressed partition graph, with atomic publication/migration, ownership-fenced writes, and post-commit conservative garbage collection. + ## Development To run the Electron app in development mode: diff --git a/packages/electron-app/electron/main/client-state-cross-host.test.ts b/packages/electron-app/electron/main/client-state-cross-host.test.ts index 1d7e0b382..01691fc05 100644 --- a/packages/electron-app/electron/main/client-state-cross-host.test.ts +++ b/packages/electron-app/electron/main/client-state-cross-host.test.ts @@ -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")) }) diff --git a/packages/electron-app/electron/main/client-state-cross-host.ts b/packages/electron-app/electron/main/client-state-cross-host.ts index 33cbbd82d..89e22a315 100644 --- a/packages/electron-app/electron/main/client-state-cross-host.ts +++ b/packages/electron-app/electron/main/client-state-cross-host.ts @@ -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" diff --git a/packages/electron-app/electron/main/client-state-envelope.ts b/packages/electron-app/electron/main/client-state-envelope.ts new file mode 100644 index 000000000..20ec6f6ec --- /dev/null +++ b/packages/electron-app/electron/main/client-state-envelope.ts @@ -0,0 +1,208 @@ +import { createHash, randomUUID } from "node:crypto" +import { isDeepStrictEqual } from "node:util" +import { + CLIENT_STATE_PARTITION_ENVELOPE_VERSION, + CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + MAX_CLIENT_STATE_ROOT_BYTES, + validateClientStatePartitionRoot, + validatePartitionKeys, +} from "./client-state-partitions" +import { normalizeNativeWindowState, type NativeWindowState } from "./window-state" + +export const CLIENT_STATE_MONOLITHIC_VERSION = 1 +export const CLIENT_STATE_ENVELOPE_VERSION = 3 +export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024 +export const MAX_CLIENT_STATE_WINDOWS = 16 + +export interface ClientWindowStateRecord { + restoreEnabled: boolean + snapshot?: unknown + window?: NativeWindowState + partitionProtocolVersion?: typeof CLIENT_STATE_PARTITION_PROTOCOL_VERSION + partitionKeys?: string[] +} + +export interface PersistedClientState { + version: typeof CLIENT_STATE_ENVELOPE_VERSION + activeWindowId: string + windowOrder: string[] + windows: Record +} + +export interface ParsedClientState { + state: PersistedClientState + unsupportedFutureEnvelope: boolean +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const hasOwn = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key) +const hasOnlyKeys = (value: Record, keys: readonly string[]) => + Object.keys(value).every((key) => keys.includes(key)) + +export function isWindowId(value: unknown): value is string { + return typeof value === "string" && UUID.test(value) +} + +export function createClientState(windowId: string = randomUUID()): PersistedClientState { + if (!isWindowId(windowId)) throw new TypeError("Invalid client state window ID") + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: windowId, + windowOrder: [windowId], + windows: { [windowId]: { restoreEnabled: true } }, + } +} + +export function deterministicLegacyWindowId(content: string | Buffer): string { + const bytes = createHash("sha256").update(content).digest().subarray(0, 16) + bytes[6] = (bytes[6]! & 0x0f) | 0x50 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex = bytes.toString("hex") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function strictWindowState(value: unknown): NativeWindowState | undefined { + const normalized = normalizeNativeWindowState(value) + return normalized && isDeepStrictEqual(value, normalized) ? normalized : undefined +} + +function snapshotSize(value: unknown): number | undefined { + const serialized = JSON.stringify(value) + return serialized === undefined ? undefined : Buffer.byteLength(serialized, "utf8") +} + +function parseRecord(value: unknown): ClientWindowStateRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const record = value as Record + if (!hasOnlyKeys(record, ["restoreEnabled", "snapshot", "window", "partitionProtocolVersion", "partitionKeys"]) + || typeof record.restoreEnabled !== "boolean") return undefined + + const window = hasOwn(record, "window") ? strictWindowState(record.window) : undefined + if (hasOwn(record, "window") && !window) return undefined + const hasProtocol = hasOwn(record, "partitionProtocolVersion") + const hasPartitionKeys = hasOwn(record, "partitionKeys") + const size = hasOwn(record, "snapshot") ? snapshotSize(record.snapshot) : undefined + if (hasOwn(record, "snapshot") && size === undefined) return undefined + + let partitionKeys: string[] | undefined + if (hasProtocol || hasPartitionKeys) { + partitionKeys = validatePartitionKeys(record.partitionKeys) + const rootKeys = validateClientStatePartitionRoot(record.snapshot) + if (!hasProtocol || !hasPartitionKeys + || record.partitionProtocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION + || !hasOwn(record, "snapshot") || !partitionKeys || !rootKeys + || size! > MAX_CLIENT_STATE_ROOT_BYTES + || rootKeys.length !== partitionKeys.length + || rootKeys.some((key, index) => key !== partitionKeys![index])) return undefined + } else if (size !== undefined && size > MAX_CLIENT_SNAPSHOT_BYTES) { + return undefined + } + + return { + restoreEnabled: record.restoreEnabled, + ...(hasOwn(record, "snapshot") ? { snapshot: record.snapshot } : {}), + ...(window ? { window } : {}), + ...(partitionKeys ? { + partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + partitionKeys, + } : {}), + } +} + +function parseV3(envelope: Record): PersistedClientState | undefined { + if (!hasOnlyKeys(envelope, ["version", "activeWindowId", "windowOrder", "windows"]) + || !isWindowId(envelope.activeWindowId) + || !Array.isArray(envelope.windowOrder) + || envelope.windowOrder.length > MAX_CLIENT_STATE_WINDOWS + || !envelope.windows || typeof envelope.windows !== "object" || Array.isArray(envelope.windows)) return undefined + + const windowOrder = envelope.windowOrder + if (windowOrder.some((id) => !isWindowId(id)) || new Set(windowOrder).size !== windowOrder.length + || (windowOrder.length > 0 && !windowOrder.includes(envelope.activeWindowId))) return undefined + const source = envelope.windows as Record + if (Object.keys(source).length !== windowOrder.length || Object.keys(source).some((id) => !windowOrder.includes(id))) return undefined + const windows: Record = {} + for (const id of windowOrder) { + if (!hasOwn(source, id)) return undefined + const record = parseRecord(source[id]) + if (!record) return undefined + windows[id] = record + } + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: envelope.activeWindowId, + windowOrder: [...windowOrder], + windows, + } +} + +function parseLegacy(envelope: Record, windowId: string): PersistedClientState | undefined { + const version = envelope.version + if (version === CLIENT_STATE_PARTITION_ENVELOPE_VERSION) { + if (!hasOnlyKeys(envelope, ["version", "restoreEnabled", "snapshot", "window", "protocolVersion", "partitionKeys"]) + || typeof envelope.restoreEnabled !== "boolean" + || envelope.protocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION + || !hasOwn(envelope, "snapshot")) return undefined + const partitionKeys = validatePartitionKeys(envelope.partitionKeys) + const rootKeys = validateClientStatePartitionRoot(envelope.snapshot) + const window = hasOwn(envelope, "window") ? strictWindowState(envelope.window) : undefined + const size = snapshotSize(envelope.snapshot) + if (!partitionKeys || !rootKeys || size === undefined || size > MAX_CLIENT_STATE_ROOT_BYTES + || rootKeys.length !== partitionKeys.length + || rootKeys.some((key, index) => key !== partitionKeys[index]) + || (hasOwn(envelope, "window") && !window)) return undefined + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: windowId, + windowOrder: [windowId], + windows: { [windowId]: { + restoreEnabled: envelope.restoreEnabled, + snapshot: envelope.snapshot, + ...(window ? { window } : {}), + partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + partitionKeys, + } }, + } + } + + if (version !== CLIENT_STATE_MONOLITHIC_VERSION + || !hasOnlyKeys(envelope, ["version", "restoreEnabled", "snapshot", "window"]) + || (hasOwn(envelope, "restoreEnabled") && typeof envelope.restoreEnabled !== "boolean")) return undefined + const window = hasOwn(envelope, "window") ? strictWindowState(envelope.window) : undefined + const size = hasOwn(envelope, "snapshot") ? snapshotSize(envelope.snapshot) : undefined + if ((hasOwn(envelope, "snapshot") && (size === undefined || size > MAX_CLIENT_SNAPSHOT_BYTES)) + || (hasOwn(envelope, "window") && !window)) return undefined + return { + version: CLIENT_STATE_ENVELOPE_VERSION, + activeWindowId: windowId, + windowOrder: [windowId], + windows: { [windowId]: { + restoreEnabled: typeof envelope.restoreEnabled === "boolean" ? envelope.restoreEnabled : true, + ...(hasOwn(envelope, "snapshot") ? { snapshot: envelope.snapshot } : {}), + ...(window ? { window } : {}), + } }, + } +} + +export function parseClientState(value: string, legacyWindowId?: string): ParsedClientState { + try { + const candidate = JSON.parse(value) as unknown + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new TypeError("Invalid client state envelope") + const envelope = candidate as Record + const state = envelope.version === CLIENT_STATE_ENVELOPE_VERSION + ? parseV3(envelope) + : parseLegacy(envelope, legacyWindowId ?? deterministicLegacyWindowId(value)) + if (!state) throw new TypeError("Invalid client state envelope") + return { state, unsupportedFutureEnvelope: false } + } catch (error) { + console.warn("[client-state] unsupported state file", error) + const fallbackWindowId = legacyWindowId ?? randomUUID() + const state = createClientState(fallbackWindowId) + state.windows[fallbackWindowId]!.restoreEnabled = false + return { state, unsupportedFutureEnvelope: true } + } +} + +export function retainedPartitionKeys(state: PersistedClientState): string[] { + return [...new Set(state.windowOrder.flatMap((id) => state.windows[id]!.partitionKeys ?? []))].sort() +} diff --git a/packages/electron-app/electron/main/client-state-ipc.test.ts b/packages/electron-app/electron/main/client-state-ipc.test.ts index 15f1698e8..50f72e41d 100644 --- a/packages/electron-app/electron/main/client-state-ipc.test.ts +++ b/packages/electron-app/electron/main/client-state-ipc.test.ts @@ -3,7 +3,7 @@ import test from "node:test" import type { IpcMainInvokeEvent } from "electron" import { setupClientStateIPC } from "./client-state-ipc" -function harness() { +function harness(persisted = true) { const handlers = new Map unknown>() const listeners = new Map void>() const frame = { url: "http://127.0.0.1:3000/app" } @@ -15,35 +15,48 @@ function harness() { const window = { isDestroyed: () => false, webContents } let current: typeof window | null = window const calls: string[] = [] + const windowId = "11111111-1111-4111-8111-111111111111" const state = { - claimClientStateAccess: (token: unknown) => { calls.push(`claim:${token}`); return true }, - assertRendererAccessToken: (token: unknown) => calls.push(`assert:${token}`), - loadClientState: () => ({ isPrimary: true }), + activeWindowId: windowId, + claimClientStateAccess: (token: unknown, id: string) => { calls.push(`claim:${token}:${id}`); return true }, + assertRendererAccessToken: (token: unknown, id: string) => calls.push(`assert:${token}:${id}`), + loadClientState: (id: string) => { calls.push(`load:${id}`); return { isPrimary: true } }, saveClientState: () => true, + commitClientStatePartitions: () => true, + loadClientStatePartition: () => null, setRestoreEnabled: () => true, clearClientState: () => true, - resetRendererAccessToken: () => calls.push("reset"), + resetRendererAccessToken: (id: string) => calls.push(`reset:${id}`), } const bind = setupClientStateIPC( { handle: (channel, listener) => handlers.set(channel, listener) }, state as never, - () => current as never, + (sender) => current && sender === (webContents as never) ? { id: windowId, persisted, window: current as never } : undefined, () => ["http://127.0.0.1:3000"], ) bind(window as never) - return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window } + return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window, windowId } } +test("ephemeral local renderers receive secondary state without claiming a missing V3 record", async () => { + const h = harness(false) + const event = { sender: h.webContents, senderFrame: h.frame } + assert.equal(await h.handlers.get("client-state:claimAccess")!(event as never, "token"), false) + assert.deepEqual(h.calls, []) + await assert.rejects(h.handlers.get("client-state:load")!(event as never, "token") as Promise, /unavailable/) +}) + test("IPC channels enforce the current main sender, frame, origin, and token", async () => { const h = harness() assert.deepEqual([...h.handlers.keys()], [ "client-state:claimAccess", "client-state:load", "client-state:save", + "client-state:commitPartitions", "client-state:loadPartition", "client-state:setRestoreEnabled", "client-state:clear", ]) const event = { sender: h.webContents, senderFrame: h.frame } await h.handlers.get("client-state:claimAccess")!(event as never, "token") await h.handlers.get("client-state:load")!(event as never, "token") - assert.deepEqual(h.calls, ["claim:token", "assert:token"]) + assert.deepEqual(h.calls, [`claim:token:${h.windowId}`, `assert:token:${h.windowId}`, `load:${h.windowId}`]) for (const invalid of [ { sender: {}, senderFrame: h.frame }, @@ -52,13 +65,46 @@ test("IPC channels enforce the current main sender, frame, origin, and token", a ]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise) }) -test("only the registered current window can reset renderer authority", () => { +test("two local renderers claim and use independent window tokens", async () => { + const handlers = new Map() + const calls: string[] = [] + const makeWindow = (id: string, contentsId: number) => { + const frame = { url: "http://127.0.0.1:3000/app" } + const webContents = { id: contentsId, mainFrame: frame, getURL: () => frame.url, on: () => {} } + return { id, window: { isDestroyed: () => false, webContents }, frame, webContents } + } + const first = makeWindow("11111111-1111-4111-8111-111111111111", 1) + const second = makeWindow("22222222-2222-4222-8222-222222222222", 2) + const records = [first, second] + setupClientStateIPC({ handle: (channel, handler) => { + assert.equal(handlers.has(channel), false, `duplicate handler ${channel}`) + handlers.set(channel, handler) + } }, { + claimClientStateAccess: (token: unknown, id: string) => { calls.push(`claim:${id}:${token}`); return true }, + assertRendererAccessToken: (token: unknown, id: string) => calls.push(`assert:${id}:${token}`), + loadClientState: (id: string) => { calls.push(`load:${id}`); return {} }, + } as never, (sender) => records.find((record) => record.webContents === (sender as never)) as never, () => ["http://127.0.0.1:3000"]) + for (const [record, token] of [[first, "one"], [second, "two"]] as const) { + const event = { sender: record.webContents, senderFrame: record.frame } + await handlers.get("client-state:claimAccess")!(event, token) + await handlers.get("client-state:load")!(event, token) + } + assert.deepEqual(calls, [ + `claim:${first.id}:one`, `assert:${first.id}:one`, `load:${first.id}`, + `claim:${second.id}:two`, `assert:${second.id}:two`, `load:${second.id}`, + ]) + assert.equal(handlers.size, 7) +}) + +test("only a registered local window can reset its renderer authority", () => { const h = harness() h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/next") h.listeners.get("render-process-gone")!() - assert.deepEqual(h.calls, ["reset", "reset"]) + assert.equal(h.calls.length, 2) + assert.match(h.calls[0]!, /^reset:[0-9a-f-]{36}$/) + assert.equal(h.calls[1], h.calls[0]) h.setCurrent(null) h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/late") h.listeners.get("destroyed")!() - assert.deepEqual(h.calls, ["reset", "reset"]) + assert.equal(h.calls.length, 2) }) diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts index 51b0b0c5f..78e1029b0 100644 --- a/packages/electron-app/electron/main/client-state-ipc.ts +++ b/packages/electron-app/electron/main/client-state-ipc.ts @@ -30,47 +30,54 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | n export function setupClientStateIPC( ipcMain: IPCRegistrar, clientState: ClientStateManager, - getMainWindow: () => BrowserWindow | null, + resolveWindow: (sender: IpcMainInvokeEvent["sender"]) => { id: string; window: BrowserWindow; persisted?: boolean } | undefined, getAllowedOrigins: (window: BrowserWindow | null) => string[], ) { const validate = (event: IpcMainInvokeEvent) => { - const window = getMainWindow() + const record = resolveWindow(event.sender) + const window = record?.window ?? null validateSender(event, window, getAllowedOrigins(window)) + return record! } const handle = ( channel: string, - operation: (argument: unknown, token: unknown) => unknown, + operation: (argument: unknown, token: unknown, windowId: string) => unknown, ) => ipcMain.handle(channel, async (event, token: unknown, argument: unknown) => { - validate(event) - clientState.assertRendererAccessToken(token) - return operation(argument, token) + const { id: windowId, persisted } = validate(event) + if (persisted === false) throw new Error("Client state persistence is unavailable for this window") + clientState.assertRendererAccessToken(token, windowId) + return operation(argument, token, windowId) }) ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => { - validate(event) - return clientState.claimClientStateAccess(token) + const record = validate(event) + return record.persisted === false ? false : clientState.claimClientStateAccess(token, record.id) }) - handle("client-state:load", () => clientState.loadClientState()) - handle("client-state:save", (snapshot, token) => clientState.saveClientState(snapshot, token)) - handle("client-state:setRestoreEnabled", (enabled, token) => { + handle("client-state:load", (_argument, _token, windowId) => clientState.loadClientState(windowId)) + handle("client-state:save", (snapshot, token, windowId) => clientState.saveClientState(snapshot, token, windowId)) + handle("client-state:commitPartitions", (payload, token, windowId) => clientState.commitClientStatePartitions(payload, token, windowId)) + handle("client-state:loadPartition", (key, token, windowId) => clientState.loadClientStatePartition(key, token, windowId)) + handle("client-state:setRestoreEnabled", (enabled, token, windowId) => { if (typeof enabled !== "boolean") throw new Error("Restore enabled must be a boolean") - return clientState.setRestoreEnabled(enabled, token) + return clientState.setRestoreEnabled(enabled, token, windowId) }) - handle("client-state:clear", (_argument, token) => clientState.clearClientState(token)) + handle("client-state:clear", (_argument, token, windowId) => clientState.clearClientState(token, windowId)) return (window: BrowserWindow): void => { window.webContents.on("did-navigate", (_event, url) => { - if (getMainWindow() === window && shouldResetRendererAccessTokenForNavigation( + const record = resolveWindow(window.webContents) + if (record && record.persisted !== false && shouldResetRendererAccessTokenForNavigation( url, false, true, (target) => isAllowedRendererOrigin(target, getAllowedOrigins(window)), )) { - clientState.resetRendererAccessToken() + clientState.resetRendererAccessToken(record.id) } }) const resetDestroyedRenderer = () => { - if (getMainWindow() === window) clientState.resetRendererAccessToken() + const record = resolveWindow(window.webContents) + if (record && record.persisted !== false) clientState.resetRendererAccessToken(record.id) } window.webContents.on("render-process-gone", resetDestroyedRenderer) window.webContents.on("destroyed", resetDestroyedRenderer) diff --git a/packages/electron-app/electron/main/client-state-lifecycle.test.ts b/packages/electron-app/electron/main/client-state-lifecycle.test.ts deleted file mode 100644 index 9f65b2116..000000000 --- a/packages/electron-app/electron/main/client-state-lifecycle.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import assert from "node:assert/strict" -import { setTimeout as delay } from "node:timers/promises" -import test from "node:test" -import type { App, BrowserWindow } from "electron" -import { ClientStateLifecycle } from "./client-state-lifecycle" -import type { ClientStateManager } from "./client-state" -import type { CliProcessManager } from "./process-manager" -import type { WindowStateTracker } from "./window-state" - -const tick = () => new Promise((resolve) => setImmediate(resolve)) -function harness(options: { - flush?: () => Promise - stop?: () => Promise - nativeFlush?: () => Promise - otherWindow?: boolean - sessionEndCleanupTimeoutMs?: number - sessionEndReleaseTimeoutMs?: number - release?: () => Promise -} = {}) { - const windows = new Map void>() - const appEvents = new Map void>() - const calls: string[] = [] - let exits = 0 - const window = { - on: (name: string, handler: (event?: { preventDefault(): void }) => void) => windows.set(name, handler), - isDestroyed: () => false, - close: () => { calls.push("close"); windows.get("close")?.({ preventDefault: () => assert.fail("approved close prevented") }) }, - hide: () => { calls.push("hide") }, - show: () => { calls.push("show") }, - webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:43123/workspace", executeJavaScript: () => { calls.push("renderer"); return options.flush?.() ?? Promise.resolve() } }, - } as unknown as BrowserWindow - const other = { isDestroyed: () => false, hide: () => { calls.push("hide-other") } } as unknown as BrowserWindow - const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App - const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release"); await options.release?.() } } as ClientStateManager - const cli = { shutdown: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager - const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: options.sessionEndCleanupTimeoutMs, sessionEndReleaseTimeoutMs: options.sessionEndReleaseTimeoutMs }) - lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker) - lifecycle.registerAppEvents() - const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented } - return { appEvents, calls, close, exits: () => exits, lifecycle, window, windows } -} - -test("close flushes renderer/native once before approval, even when repeated or renderer fails", async (t) => { - await t.test("ordinary", async () => { - const h = harness({ otherWindow: true }) - assert.equal(h.close(), true) - await tick() - assert.deepEqual(h.calls, ["renderer", "native", "close"]) - }) - await t.test("coalesced", async () => { - let release!: () => void - const h = harness({ otherWindow: true, flush: () => new Promise((resolve) => { release = resolve }) }) - assert.equal(h.close(), true); assert.equal(h.close(), true) - assert.deepEqual(h.calls, ["renderer"]) - release(); await tick() - assert.deepEqual(h.calls, ["renderer", "native", "close"]) - }) - await t.test("renderer failure", async () => { - const h = harness({ otherWindow: true, flush: async () => { throw new Error("failed") } }) - assert.equal(h.close(), true); await tick() - assert.deepEqual(h.calls, ["renderer", "native", "close"]) - }) -}) - -test("late old-window detach preserves replacement tracker during shutdown", async () => { - const h = harness() - const replacement = { on: () => {} } as unknown as BrowserWindow - h.lifecycle.attachMainWindow(replacement, { flush: async () => { h.calls.push("replacement-native") } } as unknown as WindowStateTracker) - h.lifecycle.detachMainWindow(h.window) - h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) - await (h.lifecycle as any).shutdown - assert.deepEqual(h.calls, ["hide", "renderer", "replacement-native", "stop", "release"]) -}) - -test("Windows session end vetoes termination until cleanup exits explicitly", async () => { - const h = harness() - let prevented = false - h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) - h.windows.get("session-end")?.() - await (h.lifecycle as any).sessionEnd; await tick() - assert.equal(prevented, true) - assert.deepEqual(h.calls, ["renderer", "native", "stop", "release"]) - assert.equal(h.exits(), 1) -}) - -test("session end force-exits after the bounded window when an ordinary shutdown is hung", async () => { - const h = harness({ flush: () => new Promise(() => {}), sessionEndCleanupTimeoutMs: 10 }) - let prevented = false - h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) - h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) - await delay(25) - assert.equal(prevented, true) - assert.deepEqual(h.calls, ["hide", "renderer", "release"]) - assert.equal(h.exits(), 1) -}) - -test("ordinary quit hides promptly and waits for CLI stop confirmation", async () => { - let confirmStop!: () => void - const h = harness({ stop: () => new Promise((resolve) => { confirmStop = resolve }) }) - h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) - await tick() - assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop"]) - assert.equal(h.exits(), 0) - confirmStop() - await (h.lifecycle as any).shutdown; await tick() - assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) - assert.equal(h.exits(), 1) -}) - -test("ordinary quit does not exit when CLI cleanup is unconfirmed", async () => { - const h = harness({ stop: async () => { throw new Error("unconfirmed") } }) - h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) - await assert.rejects((h.lifecycle as any).shutdown, /unconfirmed/) - await tick() - assert.equal(h.exits(), 0) - assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "show"]) -}) - -test("Windows session-end rejection fails open at the bounded deadline", async () => { - const h = harness({ stop: async () => { throw new Error("unconfirmed") }, sessionEndCleanupTimeoutMs: 10 }) - h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) - h.windows.get("query-session-end")?.({ preventDefault: () => {} }) - await delay(25) - assert.equal(h.exits(), 1) - assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) -}) - -test("Windows fail-open bounds a hanging primary release before app.exit", async () => { - const h = harness({ - flush: () => new Promise(() => {}), - release: () => new Promise(() => {}), - sessionEndCleanupTimeoutMs: 30, - sessionEndReleaseTimeoutMs: 10, - }) - h.windows.get("query-session-end")?.({ preventDefault: () => {} }) - - await delay(25) - assert.deepEqual(h.calls, ["renderer", "release"]) - assert.equal(h.exits(), 0) - await delay(15) - assert.equal(h.exits(), 1) -}) - -test("CLI termination waits for the native snapshot flush", async () => { - let release!: () => void - const h = harness({ nativeFlush: () => new Promise((resolve) => { release = resolve }) }) - h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) - await tick() - assert.deepEqual(h.calls, ["hide", "renderer", "native"]) - assert.equal(h.exits(), 0) - release(); await (h.lifecycle as any).shutdown - assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) -}) - -test("closing the final window hides it before requesting quit", () => { - const h = harness() - assert.equal(h.close(), true) - assert.deepEqual(h.calls, ["hide", "quit"]) -}) diff --git a/packages/electron-app/electron/main/client-state-lifecycle.ts b/packages/electron-app/electron/main/client-state-lifecycle.ts deleted file mode 100644 index c0e7405c5..000000000 --- a/packages/electron-app/electron/main/client-state-lifecycle.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { App, BrowserWindow } from "electron" -import type { ClientStateManager } from "./client-state" -import type { CliProcessManager } from "./process-manager" -import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" -import type { WindowStateTracker } from "./window-state" - -interface ClientStateLifecycleDependencies { - app: App - clientStateManager: ClientStateManager - cliManager: CliProcessManager - getMainWindow(): BrowserWindow | null - getAllWindows(): BrowserWindow[] - getAllowedRendererOrigins(window?: BrowserWindow | null): string[] - isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean - rendererFlushTimeoutMs?: number - sessionEndCleanupTimeoutMs?: number - sessionEndReleaseTimeoutMs?: number - isWindows?: boolean -} - -export class ClientStateLifecycle { - private shutdown: Promise | null = null - private sessionEnd: Promise | null = null - private exitAllowed = false - private trackedMainWindow: BrowserWindow | null = null - private windowStateTracker: WindowStateTracker | null = null - private windowsHiddenForShutdown = false - private primaryRelease: Promise | null = null - - constructor(private readonly dependencies: ClientStateLifecycleDependencies) {} - - attachMainWindow(window: BrowserWindow, tracker: WindowStateTracker | null): void { - this.trackedMainWindow = window - this.windowStateTracker = tracker - let closeApproved = false - let closeInProgress = false - - window.on("close", (event) => { - if (this.exitAllowed || closeApproved) return - event.preventDefault() - if (this.shutdown) return - - const hasOtherWindow = this.dependencies - .getAllWindows() - .some((candidate) => candidate !== window && !candidate.isDestroyed()) - if (!hasOtherWindow) { - window.hide() - this.dependencies.app.quit() - } else if (!closeInProgress) { - closeInProgress = true - void this.flushForClose(window).finally(() => { - closeApproved = true - try { - window.close() - } catch (error) { - closeApproved = false - closeInProgress = false - console.warn("[client-state] main-window close failed", error) - } - }) - } - }) - - if (this.dependencies.isWindows ?? process.platform === "win32") { - window.on("query-session-end", (event) => { - if (this.exitAllowed) return - event.preventDefault() - this.promoteToSessionEnd(window) - }) - window.on("session-end", () => this.promoteToSessionEnd(window)) - } - } - - detachMainWindow(window: BrowserWindow): void { - if (this.trackedMainWindow !== window) return - this.trackedMainWindow = null - this.windowStateTracker = null - } - - registerAppEvents(): void { - const { app } = this.dependencies - app.on("before-quit", (event) => { - if (this.exitAllowed) return - event.preventDefault() - this.hideWindows() - void this.startShutdown(this.dependencies.getMainWindow()).then(() => this.exit(), (error) => { - if (!this.sessionEnd) this.restoreWindowAfterRejectedShutdown(this.dependencies.getMainWindow()) - console.warn("[client-state] desktop shutdown remains pending because cleanup was not contained", error) - }) - }) - app.on("window-all-closed", () => app.quit()) - } - - private async flushForClose(window: BrowserWindow): Promise { - await this.runStage("renderer main-window close flush", () => this.flushRenderer(window)) - await this.runStage("native main-window close flush", () => this.flushNative()) - } - - private startShutdown(window: BrowserWindow | null): Promise { - if (this.shutdown) return this.shutdown - const stages = (async () => { - await this.runStage("renderer shutdown flush", () => this.flushRenderer(window)) - await this.runStage("native shutdown flush", () => this.flushNative()) - await this.dependencies.cliManager.shutdown() - await this.releasePrimary() - })() - this.shutdown = stages.catch((error) => { - this.shutdown = null - throw error - }) - return this.shutdown - } - - private hideWindows(): void { - for (const window of this.dependencies.getAllWindows()) { - if (!window.isDestroyed()) { - window.hide() - this.windowsHiddenForShutdown = true - } - } - } - - private restoreWindowAfterRejectedShutdown(preferred: BrowserWindow | null): void { - if (!this.windowsHiddenForShutdown) return - this.windowsHiddenForShutdown = false - const window = preferred && !preferred.isDestroyed() - ? preferred - : this.dependencies.getAllWindows().find((candidate) => !candidate.isDestroyed()) - if (window) window.show() - } - - private promoteToSessionEnd(window: BrowserWindow): void { - if (this.exitAllowed || this.sessionEnd) return - const cleanup = this.startShutdown(window) - this.sessionEnd = new Promise((resolve) => { - const timeoutMs = this.dependencies.sessionEndCleanupTimeoutMs ?? 5_000 - const releaseTimeoutMs = Math.min(timeoutMs, this.dependencies.sessionEndReleaseTimeoutMs ?? 250) - const releaseTimer = setTimeout(() => { - void this.releasePrimary() - }, Math.max(0, timeoutMs - releaseTimeoutMs)) - const exitTimer = setTimeout(() => { - console.warn(`[client-state] OS session-end cleanup exceeded ${timeoutMs}ms; exiting without containment`) - resolve() - }, timeoutMs) - void cleanup.then(() => { - clearTimeout(releaseTimer) - clearTimeout(exitTimer) - resolve() - }, (error) => { - console.warn("[client-state] OS session-end cleanup was not contained; waiting for forced exit", error) - }) - }).then(() => this.exit()) - } - - private releasePrimary(): Promise { - if (!this.primaryRelease) { - this.primaryRelease = this.runStage("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary()) - } - return this.primaryRelease - } - - private async flushRenderer(window: BrowserWindow | null): Promise { - const result = await flushRendererClientStateBeforeShutdown( - window, - this.dependencies.clientStateManager.isPrimary, - (url) => this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(window)), - this.dependencies.rendererFlushTimeoutMs, - ) - if (result === "untrusted-origin") { - console.warn("[client-state] skipped renderer flush for an untrusted origin") - } - } - - private async flushNative(): Promise { - if (this.windowStateTracker) await this.windowStateTracker.flush() - else await this.dependencies.clientStateManager.flush() - } - - private async runStage(name: string, operation: () => Promise): Promise { - try { - await operation() - } catch (error) { - console.warn(`[client-state] ${name} failed; continuing`, error) - } - } - - private exit(): void { - if (this.exitAllowed) return - this.exitAllowed = true - this.dependencies.app.exit(0) - } -} diff --git a/packages/electron-app/electron/main/client-state-navigation.test.ts b/packages/electron-app/electron/main/client-state-navigation.test.ts index 8c40b0a91..9df356217 100644 --- a/packages/electron-app/electron/main/client-state-navigation.test.ts +++ b/packages/electron-app/electron/main/client-state-navigation.test.ts @@ -5,13 +5,14 @@ import { join } from "node:path" import test from "node:test" import { ClientStateManager } from "./client-state" import { ClientStateNavigationController, shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" +import { SerializedLifecycle } from "./serialized-lifecycle" const tick = () => new Promise((resolve) => setImmediate(resolve)) function window(executeJavaScript: () => Promise = async () => {}) { return { isDestroyed: () => false, webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript } } } -function controller(win: ReturnType, manager: { isPrimary: boolean }, report: (error: unknown) => void = (error) => assert.fail(String(error))) { - return new ClientStateNavigationController(win as never, { clientStateManager: manager, isTrustedOrigin: () => true, reportFlushError: report }) +function controller(win: ReturnType, manager: { isPrimary: boolean }, report: (error: unknown) => void = (error) => assert.fail(String(error)), lifecycle?: SerializedLifecycle) { + return new ClientStateNavigationController(win as never, { clientStateManager: manager, isTrustedOrigin: () => true, reportFlushError: report, lifecycle }) } function managerHarness(t: test.TestContext) { const directory = mkdtempSync(join(tmpdir(), "codenomad-navigation-")) @@ -87,3 +88,53 @@ test("queued navigation preserves order and distinct generations", async () => { await Promise.all([first, second]) assert.deepEqual(calls, ["start-1", "end-1", "run-2"]) }) + +test("queued navigation exposes whether work was invalidated before it mutates navigation state", async () => { + const calls: string[] = [] + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const navigation = controller(window(), { isPrimary: true }) + const first = navigation.navigate(async (_window, generation) => { + await gate + if (navigation.isCurrent(generation)) calls.push("stale") + }) + const second = navigation.navigate((_window, generation) => { + if (navigation.isCurrent(generation)) calls.push("current") + }) + release() + await Promise.all([first, second]) + assert.deepEqual(calls, ["current"]) +}) + +test("shutdown authority cancels navigation dispatch after an in-flight renderer flush", async () => { + const calls: string[] = [] + let flushStarted!: () => void, releaseFlush!: () => void + const started = new Promise((resolve) => { flushStarted = resolve }) + const gate = new Promise((resolve) => { releaseFlush = resolve }) + const lifecycle = new SerializedLifecycle() + const navigation = controller(window(async () => { calls.push("navigation-flush"); flushStarted(); await gate }), { isPrimary: true }, undefined, lifecycle) + const pending = navigation.navigate(() => { calls.push("reload") }) + await started + + const shutdown = lifecycle.stop(async () => { calls.push("shutdown-flush") }) + releaseFlush() + await Promise.all([pending, shutdown]) + + assert.deepEqual(calls, ["navigation-flush", "shutdown-flush"]) +}) + +test("shutdown skips renderer flushes for navigation still queued", async () => { + const calls: string[] = [] + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const lifecycle = new SerializedLifecycle() + const navigation = controller(window(async () => { calls.push("flush") }), { isPrimary: true }, undefined, lifecycle) + const first = lifecycle.enqueue(() => gate) + const pending = navigation.navigate(() => { calls.push("reload") }) + const shutdown = lifecycle.stop(async () => { calls.push("shutdown") }) + + release() + await Promise.all([first, pending, shutdown]) + + assert.deepEqual(calls, ["shutdown"]) +}) diff --git a/packages/electron-app/electron/main/client-state-navigation.ts b/packages/electron-app/electron/main/client-state-navigation.ts index feed061d9..75f6bda02 100644 --- a/packages/electron-app/electron/main/client-state-navigation.ts +++ b/packages/electron-app/electron/main/client-state-navigation.ts @@ -1,27 +1,34 @@ import type { BrowserWindow } from "electron" import type { ClientStateManager } from "./client-state" import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" +import { SerializedLifecycle } from "./serialized-lifecycle" interface ClientStateNavigationDependencies { clientStateManager: Pick isTrustedOrigin(url: string): boolean reportFlushError(error: unknown): void + lifecycle?: SerializedLifecycle } export class ClientStateNavigationController { - private queue: Promise = Promise.resolve() + private readonly lifecycle: SerializedLifecycle private generation = 0 constructor( private readonly window: BrowserWindow, private readonly dependencies: ClientStateNavigationDependencies, - ) {} + ) { + this.lifecycle = dependencies.lifecycle ?? new SerializedLifecycle() + } navigate(operation: (window: BrowserWindow, generation: number) => void | Promise): Promise { const generation = ++this.generation - const request = this.queue.catch(() => {}).then(() => this.performNavigation(operation, generation)) - this.queue = request - return request + if (this.lifecycle.stopped) return Promise.resolve() + return this.lifecycle.enqueue(() => this.performNavigation(operation, generation)) + } + + isCurrent(generation: number): boolean { + return generation === this.generation } private async performNavigation( @@ -29,7 +36,7 @@ export class ClientStateNavigationController { generation: number, ): Promise { const { window } = this - if (window.isDestroyed() || window.webContents.isDestroyed()) return + if (this.lifecycle.stopped || window.isDestroyed() || window.webContents.isDestroyed()) return try { await flushRendererClientStateBeforeShutdown( @@ -41,7 +48,7 @@ export class ClientStateNavigationController { this.dependencies.reportFlushError(error) } - if (window.isDestroyed() || window.webContents.isDestroyed()) return + if (this.lifecycle.stopped || window.isDestroyed() || window.webContents.isDestroyed()) return await operation(window, generation) } } diff --git a/packages/electron-app/electron/main/client-state-partitions.ts b/packages/electron-app/electron/main/client-state-partitions.ts new file mode 100644 index 000000000..df4a482c2 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-partitions.ts @@ -0,0 +1,221 @@ +import { createHash, randomUUID } from "node:crypto" +import { link, lstat, mkdir, open, readFile, readdir, rm, unlink } from "node:fs/promises" +import { join } from "node:path" +import { hasErrorCode } from "./client-state-process" + +export const CLIENT_STATE_PARTITION_PROTOCOL_VERSION = 1 +export const CLIENT_STATE_PARTITION_ENVELOPE_VERSION = 2 +export const MAX_CLIENT_STATE_ROOT_BYTES = 1024 * 1024 +export const MAX_CLIENT_STATE_PARTITION_COMMIT_BYTES = 256 * 1024 * 1024 +const MAX_PARTITION_BYTES = 1024 * 1024 +const MAX_PARTITION_KEYS = 4096 +const PARTITION_KEY = /^[0-9a-f]{64}$/ +const PARTITION_DIRECTORY = "partitions" + +export interface ClientStatePartitionCommit { + protocolVersion: 1 + snapshot: unknown + partitions: Record + partitionKeys: string[] +} + +export interface ValidatedClientStatePartitionCommit { + snapshot: unknown + partitions: Array + partitionKeys: string[] +} + +const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex") + +export function isPartitionKey(value: unknown): value is string { + return typeof value === "string" && PARTITION_KEY.test(value) +} + +export function validatePartitionKeys(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length > MAX_PARTITION_KEYS) return undefined + const keys: string[] = [] + for (const key of value) { + if (!isPartitionKey(key) || (keys.length > 0 && keys[keys.length - 1]! >= key)) return undefined + keys.push(key) + } + return keys +} + +export function validateClientStatePartitionRoot(value: unknown): string[] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + const root = value as Record + if (root.version !== CLIENT_STATE_PARTITION_ENVELOPE_VERSION || !isPartitionKey(root.sessionPartition)) return undefined + const partitionKeys = validatePartitionKeys(root.partitionKeys) + return partitionKeys?.includes(root.sessionPartition) ? partitionKeys : undefined +} + +export function validateClientStatePartitionCommit(value: unknown): ValidatedClientStatePartitionCommit { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid client state partition commit") + } + const candidate = value as Partial + if (candidate.protocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION) { + throw new TypeError("Unsupported client state partition protocol") + } + if (!candidate.partitions || typeof candidate.partitions !== "object" || Array.isArray(candidate.partitions)) { + throw new TypeError("Invalid client state partitions") + } + const partitionKeys = validatePartitionKeys(candidate.partitionKeys) + if (!partitionKeys) throw new TypeError("Invalid client state partition keys") + const partitions = Object.entries(candidate.partitions).sort(([left], [right]) => left.localeCompare(right)) + if (partitions.length > MAX_PARTITION_KEYS) throw new RangeError("Too many client state partitions") + + const serializedSnapshot = JSON.stringify(candidate.snapshot) + if (serializedSnapshot === undefined) throw new TypeError("Client state root must be JSON-serializable") + const normalizedSnapshot = JSON.parse(serializedSnapshot) as unknown + const rootPartitionKeys = validateClientStatePartitionRoot(normalizedSnapshot) + if (!rootPartitionKeys || rootPartitionKeys.some((key, index) => key !== partitionKeys[index]) + || rootPartitionKeys.length !== partitionKeys.length) { + throw new TypeError("Client state root partition keys do not match the commit") + } + if (partitions.length !== rootPartitionKeys.length + || partitions.some(([key], index) => key !== rootPartitionKeys[index])) { + throw new TypeError("Client state partitions do not match the root") + } + const rootBytes = Buffer.byteLength(serializedSnapshot, "utf8") + if (rootBytes > MAX_CLIENT_STATE_ROOT_BYTES) throw new RangeError("Client state root exceeds the 1 MiB limit") + let commitBytes = rootBytes + for (const [key, content] of partitions) { + if (!isPartitionKey(key) || typeof content !== "string") { + throw new TypeError("Invalid client state partition reference") + } + const size = Buffer.byteLength(content, "utf8") + if (size > MAX_PARTITION_BYTES) throw new RangeError("Client state partition exceeds the 1 MiB limit") + commitBytes += size + if (commitBytes > MAX_CLIENT_STATE_PARTITION_COMMIT_BYTES) throw new RangeError("Client state partition commit exceeds the 256 MiB limit") + if (digest(content) !== key) throw new TypeError("Client state partition digest mismatch") + } + return { snapshot: normalizedSnapshot, partitions, partitionKeys: rootPartitionKeys } +} + +export async function syncDirectory(path: string): Promise { + let directory + try { + directory = await open(path, "r") + await directory.sync() + } catch (error) { + // Windows' stdlib cannot open directories for fsync; do not mask other I/O failures. + if (process.platform === "win32" && (hasErrorCode(error, "EISDIR") || hasErrorCode(error, "EPERM") || hasErrorCode(error, "EINVAL"))) return + throw error + } finally { + await directory?.close() + } +} + +export class ClientStatePartitionStore { + private readonly directory: string + + constructor(root: string) { + this.directory = join(root, PARTITION_DIRECTORY) + } + + async prepare(commit: ValidatedClientStatePartitionCommit, authorityValid: () => void): Promise { + await mkdir(this.directory, { recursive: true }) + await this.assertDirectory() + let published = false + for (const [key, content] of commit.partitions) published = await this.writeImmutable(key, content, authorityValid) || published + if (published) { + await this.assertDirectory() + await syncDirectory(this.directory) + authorityValid() + } + for (const key of commit.partitionKeys) { + const content = await this.readVerified(key) + authorityValid() + if (content === null) throw new Error(`Missing client state partition ${key}`) + } + } + + async load(key: string, authorityValid: () => void): Promise { + if (!await this.assertDirectory(true)) return null + const content = await this.readVerified(key) + authorityValid() + return content + } + + async sweep(partitionKeys: readonly string[], authorityValid: () => void): Promise { + const retained = new Set(partitionKeys) + if (!await this.assertDirectory(true)) return + let entries: string[] + try { + entries = await readdir(this.directory) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return + throw error + } + authorityValid() + for (const entry of entries) { + if (!isPartitionKey(entry) || retained.has(entry)) continue + const path = join(this.directory, entry) + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) continue + await unlink(path) + authorityValid() + } + } + + private async assertDirectory(allowMissing = false): Promise { + try { + const stats = await lstat(this.directory) + if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error("Invalid client state partition directory") + return true + } catch (error) { + if (allowMissing && hasErrorCode(error, "ENOENT")) return false + throw error + } + } + + private async readVerified(key: string): Promise { + try { + const path = join(this.directory, key) + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) return null + const bytes = await readFile(path) + if (bytes.length > MAX_PARTITION_BYTES || digest(bytes) !== key) return null + return bytes.toString("utf8") + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return null + throw error + } + } + + private async writeImmutable(key: string, content: string, authorityValid: () => void): Promise { + const path = join(this.directory, key) + const existing = await this.readVerified(key) + authorityValid() + if (existing !== null) return false + try { + await lstat(path) + throw new Error(`Invalid existing client state partition ${key}`) + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error + } + + const temporary = join(this.directory, `.${key}.${process.pid}.${randomUUID()}.tmp`) + try { + const file = await open(temporary, "wx", 0o600) + try { + await file.writeFile(content, "utf8") + await file.sync() + } finally { + await file.close() + } + authorityValid() + try { + await link(temporary, path) + } catch (error) { + if (!hasErrorCode(error, "EEXIST") || await this.readVerified(key) === null) throw error + return false + } + authorityValid() + return true + } finally { + await rm(temporary, { force: true }).catch(() => {}) + } + } +} diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts index 18ee8d3ab..2a14e391b 100644 --- a/packages/electron-app/electron/main/client-state.test.ts +++ b/packages/electron-app/electron/main/client-state.test.ts @@ -1,15 +1,41 @@ import assert from "node:assert/strict" -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" import { writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" import { ClientStateManager, type ClientStateWriter } from "./client-state" +import { deterministicLegacyWindowId, parseClientState } from "./client-state-envelope" +import { MAX_CLIENT_STATE_PARTITION_COMMIT_BYTES } from "./client-state-partitions" -function harness(t: test.TestContext, initial?: object) { +test("legacy migration UUIDs are deterministic from exact envelope bytes", () => { + const vectors = [ + ["{\"version\":1}", "2430f1a2-ad29-52d0-8678-85488a4c89e2"], + ["{ \"version\": 1, \"restoreEnabled\": false }", "e6a1425a-ebb1-502f-b79c-d92772fa763b"], + ] as const + for (const [content, expected] of vectors) { + assert.equal(deterministicLegacyWindowId(content), expected) + assert.equal(parseClientState(content).state.activeWindowId, expected) + } +}) + +test("V3 permits a temporarily empty local-window set and activates the next new record", async (t) => { + const h = harness(t) + const manager = h.create() + const closed = manager.activeWindowId + assert.equal(await manager.removeWindow(closed), true) + assert.deepEqual(manager.windowIds, []) + const next = "99999999-9999-4999-8999-999999999999" + assert.equal(await manager.addWindow(next), next) + assert.equal(manager.activeWindowId, next) + assert.deepEqual(manager.windowIds, [next]) +}) + +function harness(t: test.TestContext, initial?: unknown) { const directory = mkdtempSync(join(tmpdir(), "codenomad-state-")) const statePath = join(directory, "client-state.json") - if (initial) writeFileSync(statePath, JSON.stringify(initial)) + if (initial !== undefined) writeFileSync(statePath, JSON.stringify(initial)) let failing = false let writes = 0 const managers: ClientStateManager[] = [] @@ -29,6 +55,11 @@ function harness(t: test.TestContext, initial?: object) { return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes } } +function persistedRecord(path: string, windowId?: string): Record { + const envelope = JSON.parse(readFileSync(path, "utf8")) + return envelope.windows[windowId ?? envelope.activeWindowId] +} + test("renderer access is exclusive per document and resettable", async (t) => { const manager = harness(t, { version: 1, restoreEnabled: true }).create() assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/) @@ -71,7 +102,7 @@ test("cross-host ownership is required in addition to each host-local election", crossHostDependencies, processOwner: { pid: 8102, runToken: "electron", processStartIdentity: "electron-start" }, }) - assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) await secondary.drainAndReleasePrimary() await primary.drainAndReleasePrimary() @@ -100,8 +131,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() }) @@ -113,7 +144,7 @@ test("legacy migration prefers disabled and ignores malformed candidates", async writeFileSync(join(electron, "client-state.json"), "malformed") writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: false, snapshot: { savedAt: 1 } })) const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(JSON.parse(readFileSync(join(root, "shared", "client-state.json"), "utf8")).restoreEnabled, false) await manager.drainAndReleasePrimary() }) @@ -130,19 +161,57 @@ 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" }\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(persistedRecord(v2State).snapshot.source, "v2-save") + assert.equal(readFileSync(legacyShared, "utf8"), legacyBytes) + assert.equal(await manager.setRestoreEnabled(false), true) + assert.equal(persistedRecord(v2State).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("unshipped partitioned V2 shared state is not copied over shipped V1 migration", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), shared = join(root, "shared"), v2 = join(shared, "v2") + mkdirSync(electron, { recursive: true }); mkdirSync(shared, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(shared, "client-state.json"), JSON.stringify({ version: 2, restoreEnabled: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { source: "shipped-v1" } })) + + const manager = new ClientStateManager(electron, undefined, { + crossHostElectionDirectory: join(v2, "election"), + legacySharedStatePath: join(shared, "client-state.json"), + }) + assert.deepEqual(manager.loadClientState().snapshot, { source: "shipped-v1" }) + assert.equal(JSON.parse(readFileSync(join(v2, "client-state.json"), "utf8")).version, 1) await manager.drainAndReleasePrimary() }) @@ -151,12 +220,12 @@ test("ownership loss immediately disables restore reads and mutations", async (t version: 1, restoreEnabled: true, snapshot: { tabs: ["must-stop"] }, - window: { width: 900, height: 700 }, + window: { bounds: { x: 0, y: 0, width: 900, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }, }) const manager = h.create() writeFileSync(join(h.directory, "election", "primary.owner.json", "owner.json"), "malformed") assert.equal(manager.isPrimary, false) - assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(manager.getWindowState(), undefined) assert.equal(await manager.saveClientState({ ignored: true }), false) }) @@ -167,8 +236,8 @@ test("failed preference and clear writes roll memory and suppression back", asyn await manager.saveClientState({ kept: true }) h.fail(true) await assert.rejects(manager.setRestoreEnabled(false), /injected write failure/) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true } }) - assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).restoreEnabled, true) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true }, partitionProtocolVersion: 1 }) + assert.equal(persistedRecord(h.statePath).restoreEnabled, true) await assert.rejects(manager.clearClientState(), /injected write failure/) h.fail(false) await manager.setRestoreEnabled(true) @@ -199,10 +268,10 @@ test("disabling restore atomically removes snapshot/window and survives restart" const before = h.writes() assert.equal(await manager.setRestoreEnabled(false), true) assert.equal(h.writes(), before + 1) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(manager.getWindowState(), undefined) - const disabled = JSON.stringify({ version: 1, restoreEnabled: false }) - assert.equal(readFileSync(h.statePath, "utf8"), disabled) + const disabled = readFileSync(h.statePath, "utf8") + assert.equal(persistedRecord(h.statePath).restoreEnabled, false) await manager.drainAndReleasePrimary() const restarted = h.create() assert.equal(await restarted.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true) @@ -221,13 +290,44 @@ test("drain freezes mutations and waits for admitted writes", async (t) => { let settled = false const drain = manager.drainAndReleasePrimary().finally(() => { settled = true }) await assert.rejects(manager.saveClientState({ late: true }), /frozen for shutdown/) + await assert.rejects(manager.addWindow(), /frozen for shutdown/) await new Promise((resolve) => setImmediate(resolve)) assert.equal(settled, false) assert.equal(manager.isPrimary, true) + assert.equal(existsSync(join(h.directory, "client-state.primary.lock")), true) + assert.equal(existsSync(join(h.directory, "election", "primary.owner.json", "owner.json")), true) release() await Promise.all([admitted, drain]) assert.equal(manager.isPrimary, false) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { admitted: true }) + assert.equal(existsSync(join(h.directory, "client-state.primary.lock")), false) + assert.equal(existsSync(join(h.directory, "election", "primary.owner.json")), false) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { admitted: true }) +}) + +test("a failed drain keeps ownership and permits a persisted retry", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + h.fail(true) + await assert.rejects(manager.saveClientState({ failed: true }), /injected write failure/) + await assert.rejects(manager.drainAndReleasePrimary(), /injected write failure/) + assert.equal(manager.isPrimary, true) + assert.equal(existsSync(join(h.directory, "client-state.primary.lock")), true) + + h.fail(false) + await manager.saveClientState({ retried: true }) + await manager.drainAndReleasePrimary() + assert.equal(manager.isPrimary, false) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { retried: true }) +}) + +test("a non-primary manager does not claim that a new V3 window was persisted", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const primary = h.create() + const secondary = h.create() + assert.equal(secondary.isPrimary, false) + assert.equal(await secondary.addWindow("11111111-1111-4111-8111-111111111111"), null) + assert.equal(secondary.windowIds.length, 1) + await Promise.all([secondary.drainAndReleasePrimary(), primary.drainAndReleasePrimary()]) }) test("an old writer cannot replace a successor after PID reuse", async (t) => { @@ -249,14 +349,14 @@ test("an old writer cannot replace a successor after PID reuse", async (t) => { release() await assert.rejects(staleWrite, /ownership changed before atomic replacement/) await assert.rejects(oldDrain, /ownership changed before atomic replacement/) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { successor: true }) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { successor: true }) }) test("future envelopes are preserved until a successful explicit clear", async (t) => { const future = { version: 7, restoreEnabled: false, snapshot: { future: true }, futurePreference: "keep" } const h = harness(t, future) const manager = h.create(undefined, { pid: process.pid, runToken: "future-before-restart", processStartIdentity: "old-start" }) - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) assert.equal(await manager.saveClientState({ ignored: true }), true) assert.equal(await manager.setRestoreEnabled(false), false) assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) @@ -264,9 +364,10 @@ test("future envelopes are preserved until a successful explicit clear", async ( const restarted = h.create() assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) assert.equal(await restarted.clearClientState(), true) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: false }) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).version, 3) + assert.equal(persistedRecord(h.statePath).restoreEnabled, false) assert.equal(await restarted.saveClientState({ supported: true }), true) - assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { supported: true }) + assert.deepEqual(persistedRecord(h.statePath).snapshot, { supported: true }) }) test("failed future-envelope clear leaves persistence blocked", async (t) => { @@ -280,3 +381,352 @@ test("failed future-envelope clear leaves persistence blocked", async (t) => { assert.equal(writes, 1) assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) }) + +const partitionKey = (content: string) => createHash("sha256").update(content).digest("hex") +const partitionRoot = (partitionKeys: string[], fields: Record = {}) => ({ + ...fields, + version: 2, + sessionPartition: partitionKeys[0], + partitionKeys, +}) + +test("envelope numeric fields follow JavaScript JSON number semantics", () => { + const key = "a".repeat(64) + const windowId = "11111111-1111-4111-8111-111111111111" + const envelopes = (version: string) => version.startsWith("1") + ? `{"version":${version},"restoreEnabled":true}` + : version.startsWith("2") + ? `{"version":${version},"restoreEnabled":true,"snapshot":{"version":2.0,"sessionPartition":"${key}","partitionKeys":["${key}"]},"protocolVersion":1.0,"partitionKeys":["${key}"]}` + : `{"version":${version},"activeWindowId":"${windowId}","windowOrder":["${windowId}"],"windows":{"${windowId}":{"restoreEnabled":true}}}` + for (const version of ["1", "1.0", "2", "2.0", "3", "3.0"]) { + assert.equal(parseClientState(envelopes(version), windowId).unsupportedFutureEnvelope, false, version) + } + for (const version of ["1.5", "2.5", "3.5"]) { + assert.equal(parseClientState(envelopes(version), windowId).unsupportedFutureEnvelope, true, version) + } + assert.equal(parseClientState(envelopes("2").replace("1.0", "1.5"), windowId).unsupportedFutureEnvelope, true) + assert.equal(parseClientState(envelopes("2").replace("2.0", "2.5"), windowId).unsupportedFutureEnvelope, true) +}) + +test("V1 and V2 migrate in memory without load rewrites and mutate as V3", async (t) => { + const content = "legacy partition", key = partitionKey(content) + for (const initial of [ + { version: 1, restoreEnabled: true, snapshot: { legacy: 1 } }, + { version: 2, restoreEnabled: true, snapshot: partitionRoot([key]), protocolVersion: 1, partitionKeys: [key] }, + ]) { + const h = harness(t, initial) + const before = readFileSync(h.statePath, "utf8") + const manager = h.create() + assert.equal(readFileSync(h.statePath, "utf8"), before) + assert.match(manager.activeWindowId, /^[0-9a-f-]{36}$/) + await manager.saveWindowState({ bounds: { x: 1, y: 2, width: 900, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }) + const persisted = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(persisted.version, 3) + assert.equal(persisted.activeWindowId, manager.activeWindowId) + } +}) + +test("V3 window records isolate state, tokens, partitions, clear, and removal", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + const windowA = manager.activeWindowId + const windowB = "11111111-1111-4111-8111-111111111111" + await manager.addWindow(windowB) + + manager.claimClientStateAccess("token-a", windowA) + manager.claimClientStateAccess("token-b", windowB) + assert.throws(() => manager.assertRendererAccessToken("token-a", windowB), /has not been claimed/) + await manager.saveClientState({ window: "a" }, "token-a", windowA) + await manager.saveClientState({ window: "b" }, "token-b", windowB) + await manager.saveWindowState({ bounds: { x: 1, y: 2, width: 900, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }, windowA) + await manager.saveWindowState({ bounds: { x: 3, y: 4, width: 1000, height: 800 }, maximized: true, fullscreen: false, zoomFactor: 1.25 }, windowB) + assert.deepEqual(manager.loadClientState(windowA).snapshot, { window: "a" }) + assert.deepEqual(manager.loadClientState(windowB).snapshot, { window: "b" }) + assert.notDeepEqual(manager.getWindowState(windowA), manager.getWindowState(windowB)) + + const contentA = "partition a", keyA = partitionKey(contentA) + const contentB = "partition b", keyB = partitionKey(contentB) + await manager.commitClientStatePartitions({ protocolVersion: 1, snapshot: partitionRoot([keyA]), partitions: { [keyA]: contentA }, partitionKeys: [keyA] }, "token-a", windowA) + await manager.commitClientStatePartitions({ protocolVersion: 1, snapshot: partitionRoot([keyB]), partitions: { [keyB]: contentB }, partitionKeys: [keyB] }, "token-b", windowB) + assert.equal(await manager.loadClientStatePartition(keyB, "token-a", windowA), null) + assert.equal(await manager.loadClientStatePartition(keyB, "token-b", windowB), contentB) + await manager.clearClientState("token-a", windowA) + assert.equal(existsSync(join(h.directory, "partitions", keyA)), false) + assert.equal(existsSync(join(h.directory, "partitions", keyB)), true) + assert.equal(await manager.loadClientStatePartition(keyB, "token-b", windowB), contentB) + + assert.equal(await manager.removeWindow(windowA), true) + const envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.activeWindowId, windowB) + assert.deepEqual(envelope.windowOrder, [windowB]) + assert.deepEqual(Object.keys(envelope.windows), [windowB]) + assert.equal(existsSync(join(h.directory, "partitions", keyB)), true) + assert.equal(await manager.removeWindow(windowB), true) + assert.equal(existsSync(join(h.directory, "partitions", keyB)), false) +}) + +test("concurrent window list mutations preserve the limit and a valid active cursor", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + const additions = Array.from({ length: 16 }, (_, index) => manager.addWindow(`00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`)) + const results = await Promise.allSettled(additions) + assert.equal(results.filter(({ status }) => status === "fulfilled").length, 15) + assert.equal(results.filter(({ status }) => status === "rejected").length, 1) + let envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.windowOrder.length, 16) + assert.equal(Object.keys(envelope.windows).length, 16) + + const keep = envelope.windowOrder.slice(0, 2) + await Promise.all(envelope.windowOrder.slice(2).map((id: string) => manager.removeWindow(id))) + const removals = await Promise.all([manager.removeWindow(keep[0]), manager.removeWindow(keep[1])]) + assert.deepEqual(removals, [true, true]) + envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.windowOrder.length, 0) + assert.deepEqual(Object.keys(envelope.windows), envelope.windowOrder) + assert.match(envelope.activeWindowId, /^[0-9a-f-]{36}$/) +}) + +test("delayed focus operations serialize no-op decisions with earlier focus writes", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let block = false + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const manager = h.create(async (path, value) => { + await writeFile(path, value) + if (block) { block = false; started(); await gate } + }) + const original = manager.activeWindowId + const other = "11111111-1111-4111-8111-111111111111" + await manager.addWindow(other) + + block = true + const delayed = manager.saveClientState({ blocked: true }) + await began + const focusOther = manager.setActiveWindow(other) + const focusOriginal = manager.setActiveWindow(original) + release() + await Promise.all([delayed, focusOther, focusOriginal]) + + assert.equal(manager.activeWindowId, original) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).activeWindowId, original) +}) + +test("queued focus rejects a window removed by an earlier queued mutation", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let block = false + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const manager = h.create(async (path, value) => { + await writeFile(path, value) + if (block) { block = false; started(); await gate } + }) + const removed = "22222222-2222-4222-8222-222222222222" + await manager.addWindow(removed) + + block = true + const delayed = manager.saveClientState({ blocked: true }) + await began + const removal = manager.removeWindow(removed) + const focus = manager.setActiveWindow(removed) + release() + await Promise.all([delayed, removal]) + await assert.rejects(focus, /Unknown client state window/) + + const envelope = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(envelope.windows[removed], undefined) + assert.equal(envelope.windowOrder.includes(envelope.activeWindowId), true) +}) + +test("invalid V3 remains byte-frozen until explicit clear", async (t) => { + const windowId = "11111111-1111-4111-8111-111111111111" + const invalid = ` { "version": 3, "activeWindowId": "${windowId}", "windowOrder": ["${windowId}"], "windows": { "${windowId}": { "restoreEnabled": true, "unknown": 1 } } } ` + const h = harness(t) + writeFileSync(h.statePath, invalid) + const manager = h.create() + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(readFileSync(h.statePath, "utf8"), invalid) + assert.equal(await manager.clearClientState(), true) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).version, 3) +}) + +test("partition commits validate protocol and hashes", async (t) => { + assert.equal(MAX_CLIENT_STATE_PARTITION_COMMIT_BYTES, 256 * 1024 * 1024) + const manager = harness(t, { version: 1, restoreEnabled: true }).create() + const content = "partition" + const key = partitionKey(content) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 2, snapshot: partitionRoot([key]), partitions: { [key]: content }, partitionKeys: [key], + }), /Unsupported.*protocol/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: "wrong" }, partitionKeys: [key], + }), /digest mismatch/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: content }, partitionKeys: [key, key], + }), /partition keys/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: {}, partitionKeys: [key], + }), /do not match the root/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: content, [partitionKey("extra")]: "extra" }, partitionKeys: [key], + }), /do not match the root/) + assert.throws(() => manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key]), partitions: { [key]: content }, partitionKeys: [partitionKey("other")], + }), /do not match the commit/) + + const partitions: Record = {} + const partitionKeys: string[] = [] + for (let index = 0; index < 9; index++) { + const value = `${index}${"x".repeat(1024 * 1024 - 1)}` + const valueKey = partitionKey(value) + partitions[valueKey] = value + partitionKeys.push(valueKey) + } + partitionKeys.sort() + assert.equal(await manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot(partitionKeys), partitions, partitionKeys, + }), true) +}) + +test("partition commit/read preserves the old root on failure and clear sweeps", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + const oldContent = "old partition", oldKey = partitionKey(oldContent) + const nextContent = "next partition", nextKey = partitionKey(nextContent) + await manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([oldKey], { root: "old" }), partitions: { [oldKey]: oldContent }, partitionKeys: [oldKey], + }) + assert.equal(await manager.loadClientStatePartition(oldKey), oldContent) + assert.deepEqual(persistedRecord(h.statePath), { + restoreEnabled: true, snapshot: partitionRoot([oldKey], { root: "old" }), partitionProtocolVersion: 1, partitionKeys: [oldKey], + }) + + h.fail(true) + await assert.rejects(manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([nextKey], { root: "next" }), partitions: { [nextKey]: nextContent }, partitionKeys: [nextKey], + }), /injected write failure/) + assert.deepEqual(manager.loadClientState().snapshot, partitionRoot([oldKey], { root: "old" })) + assert.equal(await manager.loadClientStatePartition(oldKey), oldContent) + assert.equal(existsSync(join(h.directory, "partitions", nextKey)), true) + + h.fail(false) + await manager.clearClientState() + assert.equal(existsSync(join(h.directory, "partitions", oldKey)), false) + assert.equal(existsSync(join(h.directory, "partitions", nextKey)), false) +}) + +test("supported v2 window writes persist metadata and monolithic saves collect partitions", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create(undefined, { pid: process.pid, runToken: "partition-window", processStartIdentity: "before-restart" }) + const content = "window partition", key = partitionKey(content) + await manager.commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([key], { root: true }), partitions: { [key]: content }, partitionKeys: [key], + }) + const window = { bounds: { x: 10, y: 20, width: 1000, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1.25 } + await manager.saveWindowState(window) + await manager.flush() + const partitioned = JSON.parse(readFileSync(h.statePath, "utf8")) + assert.equal(partitioned.version, 3) + assert.equal(persistedRecord(h.statePath).partitionProtocolVersion, 1) + assert.deepEqual(persistedRecord(h.statePath).partitionKeys, [key]) + await manager.drainAndReleasePrimary() + + const restarted = h.create() + assert.deepEqual(restarted.getWindowState(), window) + assert.equal(await restarted.loadClientStatePartition(key), content) + await restarted.saveClientState({ monolithic: true }) + assert.deepEqual(persistedRecord(h.statePath), { + restoreEnabled: true, snapshot: { monolithic: true }, window, + }) + assert.equal(existsSync(join(h.directory, "partitions", key)), false) +}) + +test("malformed and future v2 roots fence writes and GC until explicit clear", async (t) => { + const content = "keep orphan", key = partitionKey(content) + for (const initial of [ + { version: 2, restoreEnabled: true, snapshot: {}, protocolVersion: 2, partitionKeys: [key] }, + { version: 2, restoreEnabled: true, snapshot: {}, protocolVersion: 1, partitionKeys: [key, key] }, + { version: 2, restoreEnabled: true, snapshot: {}, protocolVersion: 1, partitionKeys: [key.toUpperCase()] }, + { version: 2, restoreEnabled: true, snapshot: "x".repeat(1024 * 1024), protocolVersion: 1, partitionKeys: [key] }, + { version: 3, restoreEnabled: true, snapshot: {}, protocolVersion: 1, partitionKeys: [key] }, + { version: 2, restoreEnabled: true, snapshot: partitionRoot([key]), protocolVersion: 1, partitionKeys: [key], extra: true }, + { version: 2, restoreEnabled: true, snapshot: partitionRoot([key]), protocolVersion: 1, partitionKeys: [partitionKey("other")] }, + { version: 1, restoreEnabled: true, snapshot: {}, extra: true }, + { version: 1.5, restoreEnabled: true }, + { version: "2", restoreEnabled: true }, + "not an envelope", + ]) { + const h = harness(t, initial) + const directory = join(h.directory, "partitions") + mkdirSync(directory) + writeFileSync(join(directory, key), content) + const manager = h.create() + const before = h.writes() + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) + assert.equal(await manager.loadClientStatePartition(key), null) + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(await manager.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true) + await manager.flush() + assert.equal(h.writes(), before) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), initial) + assert.equal(readFileSync(join(directory, key), "utf8"), content) + assert.equal(await manager.clearClientState(), true) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).version, 3) + assert.equal(persistedRecord(h.statePath).restoreEnabled, false) + assert.equal(existsSync(join(directory, key)), false) + } + + const malformed = harness(t) + writeFileSync(malformed.statePath, "{not json") + const manager = malformed.create() + assert.equal(await manager.saveClientState({ ignored: true }), true) + await manager.flush() + assert.equal(readFileSync(malformed.statePath, "utf8"), "{not json") +}) + +test("v1 roots with partition metadata are fenced until clear", async (t) => { + const content = "not referenced by v1", key = partitionKey(content) + const h = harness(t, { + version: 1, restoreEnabled: true, snapshot: { monolithic: true }, protocolVersion: 1, partitionKeys: [key], + }) + const directory = join(h.directory, "partitions") + mkdirSync(directory) + writeFileSync(join(directory, key), content) + const manager = h.create() + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null, partitionProtocolVersion: 1 }) + assert.equal(await manager.loadClientStatePartition(key), null) + const before = readFileSync(h.statePath, "utf8") + await manager.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }) + assert.equal(readFileSync(h.statePath, "utf8"), before) +}) + +test("partition directory and GC reject unsafe filesystem entries", async (t) => { + const occupied = harness(t, { version: 1, restoreEnabled: true }) + writeFileSync(join(occupied.directory, "partitions"), "not a directory") + await assert.rejects(occupied.create().commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([partitionKey("occupied")]), partitions: { [partitionKey("occupied")]: "occupied" }, partitionKeys: [partitionKey("occupied")], + }), /partition directory|EEXIST/) + + const linked = harness(t, { version: 1, restoreEnabled: true }) + const target = join(linked.directory, "partition-target") + mkdirSync(target) + symlinkSync(target, join(linked.directory, "partitions"), process.platform === "win32" ? "junction" : "dir") + await assert.rejects(linked.create().commitClientStatePartitions({ + protocolVersion: 1, snapshot: partitionRoot([partitionKey("linked")]), partitions: { [partitionKey("linked")]: "linked" }, partitionKeys: [partitionKey("linked")], + }), /Invalid client state partition directory/) + + const gc = harness(t, { version: 1, restoreEnabled: true }) + const directory = join(gc.directory, "partitions") + const removable = partitionKey("orphan") + mkdirSync(directory) + writeFileSync(join(directory, removable), "orphan") + writeFileSync(join(directory, "unrelated.txt"), "keep") + mkdirSync(join(directory, "f".repeat(64))) + await gc.create().clearClientState() + assert.equal(existsSync(join(directory, removable)), false) + assert.equal(existsSync(join(directory, "unrelated.txt")), true) + assert.equal(existsSync(join(directory, "f".repeat(64))), true) +}) diff --git a/packages/electron-app/electron/main/client-state.ts b/packages/electron-app/electron/main/client-state.ts index 98b09320b..24fa2ad42 100644 --- a/packages/electron-app/electron/main/client-state.ts +++ b/packages/electron-app/electron/main/client-state.ts @@ -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,43 +18,51 @@ import { crossHostParticipants, resolveCrossHostElectionDirectory, resolveCrossHostStatePath, + resolveLegacyCrossHostStatePath, resolveLegacyTauriDataDirectory, type CrossHostLeaseDependencies, } from "./client-state-cross-host" -import { normalizeNativeWindowState } from "./window-state" +import { normalizeNativeWindowState, type NativeWindowState } from "./window-state" +import { + CLIENT_STATE_PARTITION_PROTOCOL_VERSION, + ClientStatePartitionStore, + isPartitionKey, + validateClientStatePartitionCommit, + syncDirectory, +} from "./client-state-partitions" +import { + CLIENT_STATE_ENVELOPE_VERSION, + CLIENT_STATE_MONOLITHIC_VERSION, + MAX_CLIENT_SNAPSHOT_BYTES, + MAX_CLIENT_STATE_WINDOWS, + createClientState, + isWindowId, + parseClientState, + retainedPartitionKeys, + type ClientWindowStateRecord, + type ParsedClientState, + type PersistedClientState, +} from "./client-state-envelope" -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 - -export interface WindowBounds { - x: number - y: number - width: number - height: number -} - -export interface NativeWindowState { - bounds: WindowBounds - maximized: boolean - fullscreen: boolean - zoomFactor: number -} +export { MAX_CLIENT_SNAPSHOT_BYTES } from "./client-state-envelope" +export type { NativeWindowState, WindowBounds } from "./window-state" export interface ClientStateLoadResult { isPrimary: boolean restoreEnabled: boolean snapshot: unknown | null + partitionProtocolVersion: typeof CLIENT_STATE_PARTITION_PROTOCOL_VERSION } -interface PersistedClientState { - version: typeof CLIENT_STATE_VERSION +interface LegacyPersistedClientState { + version: typeof CLIENT_STATE_MONOLITHIC_VERSION restoreEnabled: boolean snapshot?: unknown - window?: NativeWindowState } export type ClientStateWriter = ( @@ -65,9 +73,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 { @@ -80,46 +88,18 @@ async function writeClientStateTemporary(temporaryPath: string, serializedState: } } -interface ParsedClientState { - state: PersistedClientState - unsupportedFutureEnvelope: boolean -} - -function parseClientState(value: string): ParsedClientState { - const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } - try { - const candidate = JSON.parse(value) as Record - if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) { - return { state: { ...defaults, restoreEnabled: false }, unsupportedFutureEnvelope: true } - } - if (!candidate || candidate.version !== CLIENT_STATE_VERSION) { - return { state: defaults, unsupportedFutureEnvelope: false } - } - - const state: PersistedClientState = { - version: CLIENT_STATE_VERSION, - restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : true, - } - if (Object.prototype.hasOwnProperty.call(candidate, "snapshot")) { - state.snapshot = candidate.snapshot - } - const windowState = normalizeNativeWindowState(candidate.window) - if (windowState) { - state.window = windowState - } - return { state, unsupportedFutureEnvelope: false } - } catch (error) { - console.warn("[client-state] ignored invalid state file", error) - return { state: defaults, unsupportedFutureEnvelope: false } - } -} - -function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: PersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined { +function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: LegacyPersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined { try { const candidate = JSON.parse(readFileSync(path, "utf8")) as Record - if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined - const parsed = parseClientState(JSON.stringify(candidate)).state - delete parsed.window + if (!candidate || candidate.version !== CLIENT_STATE_MONOLITHIC_VERSION) return undefined + const parsedEnvelope = parseClientState(JSON.stringify(candidate)) + if (parsedEnvelope.unsupportedFutureEnvelope) return undefined + const record = parsedEnvelope.state.windows[parsedEnvelope.state.activeWindowId]! + const parsed: LegacyPersistedClientState = { + version: CLIENT_STATE_MONOLITHIC_VERSION, + restoreEnabled: record.restoreEnabled, + ...(record.snapshot === undefined ? {} : { snapshot: record.snapshot }), + } const snapshot = candidate.snapshot as Record | undefined const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1 return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined } @@ -131,7 +111,7 @@ function legacyCandidate(path: string, host: "electron" | "tauri"): { host: stri function isFutureLegacyCandidate(path: string): boolean { try { const candidate = JSON.parse(readFileSync(path, "utf8")) as Record - return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_VERSION + return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_MONOLITHIC_VERSION } catch { return false } @@ -143,15 +123,16 @@ export class ClientStateManager { private readonly lockPath: string private readonly legacyTauriDataPath: string | null private readonly owner: ProcessOwner - private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } + private readonly partitions: ClientStatePartitionStore + private state: PersistedClientState = createClientState() private writeQueue: Promise = Promise.resolve() private drainAndReleasePromise: Promise | undefined private crossHostRegistration: CrossHostRegistration | undefined private primary = false - private persistenceSuppressed = false + private persistenceSuppressed = new Set() private unsupportedFutureEnvelope = false private frozen = false - private rendererAccessToken: string | undefined + private rendererAccessTokens = new Map() constructor( userDataPath: string, @@ -170,6 +151,7 @@ export class ClientStateManager { ? join(dirname(crossHostElectionDirectory), CLIENT_STATE_FILENAME) : resolveCrossHostStatePath() mkdirSync(dirname(this.statePath), { recursive: true }) + this.partitions = new ClientStatePartitionStore(dirname(this.statePath)) this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME) const registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME) @@ -191,13 +173,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,17 +200,25 @@ 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 - this.migrateLegacyStateIfNeeded(legacyPaths, options?.removeLegacyState) + this.migrateLegacyStateIfNeeded(legacyPaths) const futureLegacyBlocked = this.unsupportedFutureEnvelope const persisted = this.readState() this.state = futureLegacyBlocked - ? { version: CLIENT_STATE_VERSION, restoreEnabled: false } + ? (() => { + const state = createClientState() + state.windows[state.activeWindowId]!.restoreEnabled = false + return state + })() : persisted.state - this.persistenceSuppressed = !this.state.restoreEnabled + this.persistenceSuppressed = new Set(this.state.windowOrder.filter((id) => !this.state.windows[id]!.restoreEnabled)) this.unsupportedFutureEnvelope = futureLegacyBlocked || persisted.unsupportedFutureEnvelope } } @@ -239,46 +234,87 @@ export class ClientStateManager { } } - loadClientState(): ClientStateLoadResult { + get activeWindowId(): string { + return this.state.activeWindowId + } + + get windowIds(): string[] { + return [...this.state.windowOrder] + } + + setActiveWindow(windowId: string): Promise { + return this.mutateWindowListAndPersist((state) => { + if (!isWindowId(windowId) || !Object.prototype.hasOwnProperty.call(state.windows, windowId)) { + throw new Error("Unknown client state window") + } + if (state.activeWindowId === windowId) return true + if (!this.isPrimary || this.unsupportedFutureEnvelope) return false + state.activeWindowId = windowId + }) + } + + loadClientState(windowId = this.activeWindowId): ClientStateLoadResult { + const record = this.windowRecord(windowId) if (!this.isPrimary) { - return { isPrimary: false, restoreEnabled: false, snapshot: null } + return { isPrimary: false, restoreEnabled: false, snapshot: null, partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION } } return { isPrimary: true, - restoreEnabled: this.state.restoreEnabled, - snapshot: this.state.restoreEnabled ? (this.state.snapshot ?? null) : null, + restoreEnabled: record.restoreEnabled, + snapshot: record.restoreEnabled ? (record.snapshot ?? null) : null, + partitionProtocolVersion: CLIENT_STATE_PARTITION_PROTOCOL_VERSION, } } - getWindowState(): NativeWindowState | undefined { - return this.isPrimary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined + loadClientStatePartition(key: unknown, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + if (!isPartitionKey(key)) return Promise.reject(new TypeError("Invalid client state partition key")) + this.windowRecord(windowId) + const operation = this.writeQueue.catch(() => {}).then(async () => { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken, windowId) + const record = this.windowRecord(windowId) + if (!this.isPrimary || !record.restoreEnabled + || record.partitionProtocolVersion !== CLIENT_STATE_PARTITION_PROTOCOL_VERSION + || !record.partitionKeys?.includes(key)) return null + return this.partitions.load(key, () => this.assertReplacementAllowed(rendererToken, windowId)) + }) + this.writeQueue = operation.then(() => {}) + return operation } - claimClientStateAccess(token: unknown): true { + getWindowState(windowId = this.activeWindowId): NativeWindowState | undefined { + const record = this.windowRecord(windowId) + return this.isPrimary && !this.unsupportedFutureEnvelope && record.restoreEnabled ? record.window : undefined + } + + claimClientStateAccess(token: unknown, windowId = this.activeWindowId): true { + this.windowRecord(windowId) this.validateRendererAccessTokenValue(token) - if (this.rendererAccessToken === undefined) { - this.rendererAccessToken = token + const current = this.rendererAccessTokens.get(windowId) + if (current === undefined) { + this.rendererAccessTokens.set(windowId, token) return true } - if (this.rendererAccessToken !== token) { + if (current !== token) { throw new Error("Client state access token does not match the claimed renderer") } return true } - assertRendererAccessToken(token: unknown): void { + assertRendererAccessToken(token: unknown, windowId = this.activeWindowId): void { + this.windowRecord(windowId) this.validateRendererAccessTokenValue(token) - if (this.rendererAccessToken === undefined || this.rendererAccessToken !== token) { + if (this.rendererAccessTokens.get(windowId) !== token) { throw new Error("Client state access has not been claimed by this renderer") } } - resetRendererAccessToken(): void { - this.rendererAccessToken = undefined + resetRendererAccessToken(windowId = this.activeWindowId): void { + this.windowRecord(windowId) + this.rendererAccessTokens.delete(windowId) } - saveClientState(snapshot: unknown, rendererToken?: unknown): Promise { - const disposition = this.getMutationDisposition() + saveClientState(snapshot: unknown, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + const disposition = this.getMutationDisposition(windowId) if (disposition) return disposition const serialized = JSON.stringify(snapshot) @@ -290,32 +326,52 @@ export class ClientStateManager { } const normalizedSnapshot = JSON.parse(serialized) as unknown - return this.mutateAndPersist((state) => { - state.snapshot = normalizedSnapshot - }, true, rendererToken) + return this.mutateAndPersist(windowId, (record) => { + record.snapshot = normalizedSnapshot + delete record.partitionProtocolVersion + delete record.partitionKeys + }, true, rendererToken, undefined, + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) } - setRestoreEnabled(enabled: boolean, rendererToken?: unknown): Promise { - const disposition = this.getMutationDisposition(false) + commitClientStatePartitions(payload: unknown, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + const commit = validateClientStatePartitionCommit(payload) + const disposition = this.getMutationDisposition(windowId) + if (disposition) return disposition + return this.mutateAndPersist(windowId, (record) => { + record.snapshot = commit.snapshot + record.partitionProtocolVersion = CLIENT_STATE_PARTITION_PROTOCOL_VERSION + record.partitionKeys = commit.partitionKeys + }, true, rendererToken, + () => this.partitions.prepare(commit, () => this.assertReplacementAllowed(rendererToken, windowId)), + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) + } + + setRestoreEnabled(enabled: boolean, rendererToken?: unknown, windowId = this.activeWindowId): Promise { + const disposition = this.getMutationDisposition(windowId, false) if (disposition) return disposition if (typeof enabled !== "boolean") { throw new TypeError("Restore enabled must be a boolean") } - return this.mutateAndPersist((state) => { - state.restoreEnabled = enabled + return this.mutateAndPersist(windowId, (record) => { + record.restoreEnabled = enabled if (enabled) { - this.persistenceSuppressed = false + this.persistenceSuppressed.delete(windowId) } else { - delete state.snapshot - delete state.window - this.persistenceSuppressed = true + delete record.snapshot + delete record.window + delete record.partitionProtocolVersion + delete record.partitionKeys + this.persistenceSuppressed.add(windowId) } - }, false, rendererToken) + }, false, rendererToken, undefined, + enabled ? undefined : () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) } - clearClientState(rendererToken?: unknown): Promise { + clearClientState(rendererToken?: unknown, windowId = this.activeWindowId): Promise { + this.windowRecord(windowId) if (!this.isPrimary) { return Promise.resolve(false) } @@ -325,27 +381,63 @@ export class ClientStateManager { const clearingFutureEnvelope = this.unsupportedFutureEnvelope - return this.mutateAndPersist((state) => { - delete state.snapshot - delete state.window + return this.mutateAndPersist(windowId, (record) => { + delete record.snapshot + delete record.window + delete record.partitionProtocolVersion + delete record.partitionKeys this.unsupportedFutureEnvelope = false - this.persistenceSuppressed = !clearingFutureEnvelope - }, false, rendererToken) + if (clearingFutureEnvelope) this.persistenceSuppressed.delete(windowId) + else this.persistenceSuppressed.add(windowId) + }, false, rendererToken, undefined, + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed(rendererToken, windowId))) } - saveWindowState(windowState: NativeWindowState): Promise { - const disposition = this.getMutationDisposition() + saveWindowState(windowState: NativeWindowState, windowId = this.activeWindowId): Promise { + const disposition = this.getMutationDisposition(windowId) if (disposition) return disposition const normalized = normalizeNativeWindowState(windowState) if (!normalized) { return Promise.resolve(false) } - return this.mutateAndPersist((state) => { - state.window = normalized + return this.mutateAndPersist(windowId, (record) => { + record.window = normalized }, true) } + addWindow(windowId = randomUUID()): Promise { + return this.mutateWindowListAndPersist((state) => { + if (!isWindowId(windowId)) throw new TypeError("Invalid client state window ID") + if (state.windowOrder.length >= MAX_CLIENT_STATE_WINDOWS) throw new RangeError("Too many client state windows") + if (state.windows[windowId]) throw new Error("Client state window already exists") + if (!this.isPrimary || this.unsupportedFutureEnvelope) return false + if (state.windowOrder.length === 0) state.activeWindowId = windowId + state.windowOrder.push(windowId) + state.windows[windowId] = { restoreEnabled: true } + }).then((persisted) => persisted ? windowId : null) + } + + removeWindow(windowId: string): Promise { + return this.mutateWindowListAndPersist((state) => { + if (!isWindowId(windowId) || !Object.prototype.hasOwnProperty.call(state.windows, windowId)) { + throw new Error("Unknown client state window") + } + if (!this.isPrimary || this.unsupportedFutureEnvelope) return false + state.windowOrder = state.windowOrder.filter((id) => id !== windowId) + delete state.windows[windowId] + if (state.activeWindowId === windowId && state.windowOrder.length > 0) state.activeWindowId = state.windowOrder[0]! + }, + () => this.partitions.sweep(retainedPartitionKeys(this.state), () => this.assertReplacementAllowed())) + .then((removed) => { + if (removed) { + this.rendererAccessTokens.delete(windowId) + this.persistenceSuppressed.delete(windowId) + } + return removed + }) + } + async flush(): Promise { await this.writeQueue } @@ -356,30 +448,46 @@ export class ClientStateManager { } this.frozen = true - this.drainAndReleasePromise = this.writeQueue.finally(() => { - this.primary = false - this.releaseOwnedProcessFiles() + // The primary locks stay held until the queued root publication and GC have both drained. + const drain = this.writeQueue.then( + () => { + this.primary = false + this.releaseOwnedProcessFiles() + }, + (error) => { + this.frozen = false + throw error + }, + ) + this.drainAndReleasePromise = drain + void drain.catch(() => { + if (this.drainAndReleasePromise === drain) this.drainAndReleasePromise = undefined }) - return this.drainAndReleasePromise + return drain } private readState(): ParsedClientState { try { return parseClientState(readFileSync(this.statePath, "utf8")) } catch (error) { - if (!hasErrorCode(error, "ENOENT")) { - console.warn("[client-state] failed to read state", error) + if (hasErrorCode(error, "ENOENT")) { + return { + state: createClientState(), + unsupportedFutureEnvelope: false, + } } + console.warn("[client-state] failed to read state", error) + const state = createClientState() + state.windows[state.activeWindowId]!.restoreEnabled = false return { - state: { version: CLIENT_STATE_VERSION, restoreEnabled: true }, - unsupportedFutureEnvelope: false, + state, + unsupportedFutureEnvelope: true, } } } private migrateLegacyStateIfNeeded( paths: ReadonlyArray, - removeLegacyState = (path: string) => rmSync(path, { force: true }), ): void { try { readFileSync(this.statePath) @@ -412,12 +520,51 @@ 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) - } + this.syncStateDirectorySync() + } 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 + } + try { + const serialized = bytes.toString("utf8") + const candidate = JSON.parse(serialized) as Record + if (candidate?.version !== CLIENT_STATE_MONOLITHIC_VERSION || parseClientState(serialized).unsupportedFutureEnvelope) return + } catch { + return + } + + 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) + this.syncStateDirectorySync() + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) throw error } } finally { if (descriptor !== undefined) closeSync(descriptor) @@ -425,7 +572,15 @@ export class ClientStateManager { } } - private getMutationDisposition(futureEnvelopeResult = true): Promise | undefined { + private windowRecord(windowId: string): ClientWindowStateRecord { + if (!isWindowId(windowId) || !Object.prototype.hasOwnProperty.call(this.state.windows, windowId)) { + throw new Error("Unknown client state window") + } + return this.state.windows[windowId]! + } + + private getMutationDisposition(windowId: string, futureEnvelopeResult = true): Promise | undefined { + this.windowRecord(windowId) if (!this.isPrimary) { return Promise.resolve(false) } @@ -439,50 +594,91 @@ export class ClientStateManager { } private mutateAndPersist( - mutate: (state: PersistedClientState) => void, + windowId: string, + mutate: (record: ClientWindowStateRecord, state: PersistedClientState) => void, skipWhenSuppressed = false, rendererToken?: unknown, + prepare?: () => Promise, + committed?: () => Promise, ): Promise { const operation = this.writeQueue.catch(() => {}).then(async () => { - if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) - if (skipWhenSuppressed && this.persistenceSuppressed) { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken, windowId) + if (skipWhenSuppressed && this.persistenceSuppressed.has(windowId)) { return } - const previousState = { ...this.state } - const previousPersistenceSuppressed = this.persistenceSuppressed + const previousState = JSON.parse(JSON.stringify(this.state)) as PersistedClientState + const previousPersistenceSuppressed = new Set(this.persistenceSuppressed) const previousUnsupportedFutureEnvelope = this.unsupportedFutureEnvelope try { - mutate(this.state) - await this.writeAtomically(JSON.stringify(this.state), rendererToken) + await prepare?.() + mutate(this.windowRecord(windowId), this.state) + await this.writeAtomically(JSON.stringify(this.state), rendererToken, windowId) } catch (error) { this.state = previousState this.persistenceSuppressed = previousPersistenceSuppressed this.unsupportedFutureEnvelope = previousUnsupportedFutureEnvelope throw error } + await committed?.().catch((error) => console.warn("[client-state] failed to sweep partitions", error)) }) this.writeQueue = operation return operation.then(() => true) } - private async writeAtomically(serializedState: string, rendererToken?: unknown): Promise { + private mutateWindowListAndPersist( + mutate: (state: PersistedClientState) => boolean | void, + committed?: () => Promise, + ): Promise { + if (this.frozen) return Promise.reject(new Error("Client state persistence is frozen for shutdown")) + const operation = this.writeQueue.catch(() => {}).then(async () => { + const previousState = JSON.parse(JSON.stringify(this.state)) as PersistedClientState + try { + const result = mutate(this.state) + if (result !== undefined) return result + await this.writeAtomically(JSON.stringify(this.state)) + } catch (error) { + this.state = previousState + throw error + } + await committed?.().catch((error) => console.warn("[client-state] failed to sweep partitions", error)) + return true + }) + this.writeQueue = operation.then(() => {}) + return operation + } + + private async writeAtomically(serializedState: string, rendererToken?: unknown, windowId = this.activeWindowId): Promise { const temporaryPath = join( dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`, ) try { await this.writeState(temporaryPath, serializedState) - this.assertReplacementAllowed(rendererToken) + this.assertReplacementAllowed(rendererToken, windowId) await rename(temporaryPath, this.statePath) + await syncDirectory(dirname(this.statePath)) } catch (error) { await rm(temporaryPath, { force: true }).catch(() => {}) throw error } } - private assertReplacementAllowed(rendererToken?: unknown): void { - if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) + private syncStateDirectorySync(): void { + let descriptor: number | undefined + try { + descriptor = openSync(dirname(this.statePath), "r") + fsyncSync(descriptor) + } catch (error) { + if (process.platform === "win32" && (hasErrorCode(error, "EISDIR") || hasErrorCode(error, "EPERM") || hasErrorCode(error, "EINVAL"))) return + throw error + } finally { + if (descriptor !== undefined) closeSync(descriptor) + } + } + + private assertReplacementAllowed(rendererToken?: unknown, windowId = this.activeWindowId): void { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken, windowId) if (!this.isPrimary || !isProcessOwnerLockOwned(this.lockPath, this.owner)) { throw new Error("Client state ownership changed before atomic replacement") } diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 661ad9ce8..cf939b85d 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,11 +1,24 @@ -import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" -import fs from "fs" +import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron" +import fs from "node:fs" import { requestMicrophoneAccess } from "./permissions" -import type { CliProcessManager, CliStatus } from "./process-manager" +import type { CliProcessManager } from "./process-manager" import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" import { setWorkspaceMenuEnabled } from "./menu" +import { requireHttpUrl } from "./navigation-security" -let wakeLockId: number | null = null +interface LocalSender { + id: string + window: BrowserWindow +} + +interface CliIPCDependencies { + resolveLocal(sender: IpcMainInvokeEvent["sender"]): LocalSender | undefined + getAllowedOrigins(window: BrowserWindow): string[] + openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }): Promise + newWindow(): Promise + nextFolder(windowId: string): string | null + acknowledgeFolder(windowId: string, folder: string, opened: boolean): void +} interface DialogOpenRequest { mode: "directory" | "file" @@ -20,217 +33,158 @@ interface DialogOpenResult { paths: string[] } -async function resolveLocalWorkspaceFolder( - mainWindow: BrowserWindow, - cliManager: CliProcessManager, - instanceId: string, - worktreeSlug: string, -): Promise { +function validateMainFrame(event: IpcMainInvokeEvent, window: BrowserWindow, allowedOrigins: string[]): void { + if (window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) { + throw new Error("Native IPC requires a registered main frame") + } + const current = new URL(window.webContents.getURL()) + const sender = new URL(event.senderFrame.url) + if (current.origin !== sender.origin || !allowedOrigins.includes(sender.origin)) { + throw new Error("Native IPC requires an allowed renderer origin") + } +} + +async function resolveLocalWorkspaceFolder(window: BrowserWindow, cliManager: CliProcessManager, instanceId: string, worktreeSlug: string): Promise { 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 cookie = (await window.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 }) + 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 }, - ) - if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable") - const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> } + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`, { headers, signal: AbortSignal.timeout(5_000) }) + if (!response.ok) throw new Error("Workspace worktrees are unavailable") + const payload = await response.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()) { - mainWindow.webContents.send("cli:status", status) - } - }) - - cliManager.on("ready", (status: CliStatus) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:ready", status) - } - }) - - cliManager.on("error", (error: Error) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:error", { message: error.message }) +export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCDependencies) { + let wakeLockId: number | null = null + const wakeLockWindows = new Set() + const wakeCleanupInstalled = new Set() + const local = (event: IpcMainInvokeEvent): LocalSender => { + const record = dependencies.resolveLocal(event.sender) + if (!record) throw new Error("Native operation is limited to local windows") + validateMainFrame(event, record.window, dependencies.getAllowedOrigins(record.window)) + return record + } + const anyTrusted = (event: IpcMainInvokeEvent): BrowserWindow => { + const window = BrowserWindow.fromWebContents(event.sender) + if (!window) throw new Error("Native operation requires a window") + validateMainFrame(event, window, dependencies.getAllowedOrigins(window)) + return window + } + const updateWakeLock = (): boolean => { + if (wakeLockWindows.size > 0) { + if (wakeLockId === null || !powerSaveBlocker.isStarted(wakeLockId)) wakeLockId = powerSaveBlocker.start("prevent-app-suspension") + return true } + if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) powerSaveBlocker.stop(wakeLockId) + wakeLockId = null + return false + } + + ipcMain.handle("cli:getStatus", async (event) => { local(event); return cliManager.getStatus() }) + ipcMain.handle("cli:restart", async (event) => { local(event); return cliManager.restart({ dev: process.env.NODE_ENV === "development" }) }) + ipcMain.handle("window:new", async (event) => { local(event); await dependencies.newWindow(); return { ok: true } }) + ipcMain.handle("window:nextFolder", async (event) => dependencies.nextFolder(local(event).id)) + ipcMain.handle("window:ackFolder", async (event, folder: unknown, opened: unknown) => { + const { id } = local(event) + if (typeof folder !== "string" || typeof opened !== "boolean") throw new Error("Invalid folder acknowledgement") + dependencies.acknowledgeFolder(id, folder, opened) + return { ok: true } }) - ipcMain.handle("cli:getStatus", async () => cliManager.getStatus()) - - ipcMain.handle("cli:restart", async () => { - const devMode = process.env.NODE_ENV === "development" - return cliManager.restart({ dev: devMode }) - }) - - ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { - const properties: OpenDialogOptions["properties"] = - request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] - if (request.mode === "file" && request.multiple) { - properties.push("multiSelections") - } - - const filters = request.filters?.map((filter) => ({ - name: filter.name ?? "Files", - extensions: filter.extensions, - })) - - const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow - const dialogOptions: OpenDialogOptions = { + ipcMain.handle("dialog:open", async (event, request: DialogOpenRequest): Promise => { + const { window } = local(event) + if (!request || (request.mode !== "directory" && request.mode !== "file")) throw new Error("Invalid dialog request") + const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] + if (request.mode === "file" && request.multiple) properties.push("multiSelections") + const result = await dialog.showOpenDialog(window, { title: request.title, defaultPath: request.defaultPath, properties, - filters, - } - const result = windowTarget - ? await dialog.showOpenDialog(windowTarget, dialogOptions) - : await dialog.showOpenDialog(dialogOptions) - + filters: request.filters?.map((filter) => ({ name: filter.name ?? "Files", extensions: filter.extensions })), + }) return { canceled: result.canceled, paths: result.filePaths } }) - ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise => { - if (!Array.isArray(paths)) { - return [] - } - - const directories = paths.filter((value): value is string => { - if (typeof value !== "string" || value.trim().length === 0) { - return false - } - try { - return fs.statSync(value).isDirectory() - } catch { - return false - } + ipcMain.handle("filesystem:getDirectoryPaths", async (event, paths: unknown): Promise => { + local(event) + if (!Array.isArray(paths)) return [] + return paths.filter((value): value is string => { + if (typeof value !== "string" || !value.trim()) return false + try { return fs.statSync(value).isDirectory() } catch { return false } }) - 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:openTarget", async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => { + const { window } = local(event) + const { target, instanceId, worktreeSlug, editor } = payload ?? {} + 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 folder = await resolveLocalWorkspaceFolder(window, cliManager, instanceId, worktreeSlug) + await openWorkspaceTarget(target as WorkspaceOpenTarget, folder, 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) + setWorkspaceMenuEnabled(local(event).window, enabled === true) return { ok: true } }) - ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { - const next = Boolean(enabled) - if (next) { - if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) { - return { enabled: true } - } - try { - wakeLockId = powerSaveBlocker.start("prevent-app-suspension") - } catch { - wakeLockId = null - return { enabled: false } - } - return { enabled: true } + ipcMain.handle("power:setWakeLock", async (event, enabled: boolean): Promise<{ enabled: boolean }> => { + const window = anyTrusted(event) + const id = window.webContents.id + if (!wakeCleanupInstalled.has(id)) { + wakeCleanupInstalled.add(id) + window.webContents.once("destroyed", () => { + wakeLockWindows.delete(id) + wakeCleanupInstalled.delete(id) + updateWakeLock() + }) } + if (enabled) wakeLockWindows.add(id) + else wakeLockWindows.delete(id) + try { return { enabled: updateWakeLock() } } catch { return { enabled: false } } + }) - if (wakeLockId !== null) { - try { - if (powerSaveBlocker.isStarted(wakeLockId)) { - powerSaveBlocker.stop(wakeLockId) - } - } finally { - wakeLockId = null - } + ipcMain.handle("media:requestMicrophoneAccess", async (event): Promise<{ granted: boolean }> => { + anyTrusted(event) + return { granted: await requestMicrophoneAccess() } + }) + ipcMain.handle("remote:openWindow", async (event, payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) => { + local(event) + if (!payload || typeof payload.id !== "string" || !payload.id.trim() || typeof payload.name !== "string" || typeof payload.baseUrl !== "string" + || (payload.entryUrl !== undefined && typeof payload.entryUrl !== "string") + || (payload.proxySessionId !== undefined && typeof payload.proxySessionId !== "string") + || typeof payload.skipTlsVerify !== "boolean") { + throw new Error("Invalid remote window request") } - return { enabled: false } + requireHttpUrl(payload.baseUrl, "baseUrl") + if (payload.entryUrl !== undefined) requireHttpUrl(payload.entryUrl, "entryUrl") + await dependencies.openRemoteWindow(payload) + return { ok: true } }) - - ipcMain.handle( - "media:requestMicrophoneAccess", - async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), - ) - - ipcMain.handle( - "remote:openWindow", - async ( - _event, - payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }, - ): Promise<{ ok: boolean }> => { - const opener = (mainWindow as BrowserWindow & { - __codenomadOpenRemoteWindow?: (payload: { - id: string - name: string - baseUrl: string - skipTlsVerify: boolean - }) => Promise - }).__codenomadOpenRemoteWindow - if (!opener) { - throw new Error("Remote window opening is not available") - } - await opener(payload) + ipcMain.handle("notifications:show", async (event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { + anyTrusted(event) + if (!Notification.isSupported()) return { ok: false, reason: "unsupported" } + try { + new Notification({ title: typeof payload?.title === "string" ? payload.title : "CodeNomad", body: typeof payload?.body === "string" ? payload.body : "" }).show() return { ok: true } - }, - ) - - ipcMain.handle( - "notifications:show", - async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { - if (!Notification.isSupported()) { - return { ok: false, reason: "unsupported" } - } - - const title = typeof payload?.title === "string" ? payload.title : "CodeNomad" - const body = typeof payload?.body === "string" ? payload.body : "" - try { - const notification = new Notification({ title, body }) - notification.show() - return { ok: true } - } catch (error) { - return { ok: false, reason: error instanceof Error ? error.message : String(error) } - } - }, - ) + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) } + } + }) } diff --git a/packages/electron-app/electron/main/local-window-registry.test.ts b/packages/electron-app/electron/main/local-window-registry.test.ts new file mode 100644 index 000000000..724859e1c --- /dev/null +++ b/packages/electron-app/electron/main/local-window-registry.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { LocalWindowRegistry } from "./local-window-registry" + +const id1 = "11111111-1111-4111-8111-111111111111" +const id2 = "22222222-2222-4222-8222-222222222222" + +function fakeWindow(contentsId: number) { + const calls: string[] = [] + const webContents = { id: contentsId, isDestroyed: () => false, send: (channel: string) => calls.push(channel) } + return { calls, webContents, isDestroyed: () => false, isMinimized: () => false, show: () => calls.push("show"), focus: () => calls.push("focus"), restore: () => calls.push("restore") } +} + +test("registry resolves independent webContents, tracks MRU, queues folders, and fans out backend events", () => { + const active: string[] = [] + const registry = new LocalWindowRegistry((id) => { active.push(id) }) + const first = fakeWindow(1) + const second = fakeWindow(2) + registry.add({ id: id1.toUpperCase(), persisted: true, window: first as never, navigation: {} as never, tracker: null, loading: true, backendUrl: null, pendingFolders: [] }) + registry.add({ id: id2, persisted: true, window: second as never, navigation: {} as never, tracker: null, loading: true, backendUrl: null, pendingFolders: [] }) + assert.equal(registry.resolve(first.webContents as never)?.id, id1) + assert.equal(registry.focusMru()?.id, id2) + registry.focus(id1) + assert.equal(registry.focusMru()?.id, id1) + assert.deepEqual(active, [id2, id1, id1]) + registry.queueFolder(id2, "/two") + registry.queueFolder(id2, "/three") + assert.equal(registry.nextFolder(id2), "/two") + registry.acknowledgeFolder(id2, "/two", false) + assert.equal(registry.nextFolder(id2), "/three") + assert.throws(() => registry.acknowledgeFolder(id2, "/wrong", true), /out of order/) + registry.acknowledgeFolder(id2, "/three", true) + assert.equal(registry.nextFolder(id2), "/two") + registry.acknowledgeFolder(id2, "/two", false) + registry.acknowledgeFolder(id2, "/two", false) + assert.equal(registry.nextFolder(id2), null) + registry.fanout("cli:ready", {}) + assert.equal(first.calls.includes("cli:ready"), true) + assert.equal(second.calls.includes("cli:ready"), true) + registry.remove(id1) + assert.equal(registry.resolve(first.webContents as never), undefined) +}) diff --git a/packages/electron-app/electron/main/local-window-registry.ts b/packages/electron-app/electron/main/local-window-registry.ts new file mode 100644 index 000000000..d2b08ec39 --- /dev/null +++ b/packages/electron-app/electron/main/local-window-registry.ts @@ -0,0 +1,130 @@ +import type { BrowserWindow, WebContents } from "electron" +import type { ClientStateNavigationController } from "./client-state-navigation" +import type { WindowStateTracker } from "./window-state" + +export interface LocalWindowRecord { + id: string + persisted: boolean + window: BrowserWindow + navigation: ClientStateNavigationController + tracker: WindowStateTracker | null + loading: boolean + backendUrl: string | null + pendingFolders: PendingFolder[] +} + +interface PendingFolder { path: string; attempts: number } + +const MAX_FOLDER_ATTEMPTS = 3 + +export class LocalWindowRegistry { + private readonly records = new Map() + private readonly webContentsIds = new Map() + private mru: string[] = [] + + constructor(private readonly setActiveWindow: (id: string) => void | Promise) {} + + add(record: LocalWindowRecord): void { + const id = record.id.toLowerCase() + if (this.records.has(id)) throw new Error("Local window is already registered") + if (this.records.size >= 16) throw new RangeError("Too many local windows") + record.id = id + this.records.set(id, record) + this.webContentsIds.set(record.window.webContents.id, id) + this.mru = [id, ...this.mru.filter((candidate) => candidate !== id)] + } + + get(id: string): LocalWindowRecord | undefined { + return this.records.get(id.toLowerCase()) + } + + all(): LocalWindowRecord[] { + return [...this.records.values()] + } + + resolve(sender: WebContents): LocalWindowRecord | undefined { + const id = this.webContentsIds.get(sender.id) + return id ? this.records.get(id) : undefined + } + + focus(id: string): LocalWindowRecord | undefined { + const record = this.get(id) + if (!record || record.window.isDestroyed()) return undefined + this.markFocused(record.id) + if (record.window.isMinimized()) record.window.restore() + record.window.show() + record.window.focus() + return record + } + + markFocused(id: string): void { + const record = this.get(id) + if (!record) return + this.mru = [record.id, ...this.mru.filter((candidate) => candidate !== record.id)] + if (!record.persisted) return + try { + void Promise.resolve(this.setActiveWindow(record.id)).catch((error) => console.warn("[client-state] failed to persist active window", error)) + } catch (error) { + console.warn("[client-state] failed to persist active window", error) + } + } + + mruRecord(): LocalWindowRecord | undefined { + for (const id of this.mru) { + const record = this.get(id) + if (record && !record.window.isDestroyed()) return record + } + return undefined + } + + focusMru(): LocalWindowRecord | undefined { + for (const id of this.mru) { + const focused = this.focus(id) + if (focused) return focused + } + return undefined + } + + queueFolder(id: string, folder: string): void { + const record = this.get(id) + if (!record) throw new Error("Unknown local window") + record.pendingFolders.push({ path: folder, attempts: 0 }) + if (!record.window.isDestroyed() && !record.window.webContents.isDestroyed()) { + record.window.webContents.send("window:folders-pending") + } + } + + nextFolder(id: string): string | null { + const record = this.get(id) + if (!record) throw new Error("Unknown local window") + return record.pendingFolders[0]?.path ?? null + } + + acknowledgeFolder(id: string, folder: string, opened: boolean): void { + const record = this.get(id) + if (!record) throw new Error("Unknown local window") + const pending = record.pendingFolders.shift() + if (pending?.path !== folder) { + if (pending) record.pendingFolders.unshift(pending) + throw new Error("Pending folder acknowledgement is out of order") + } + if (!opened && ++pending.attempts < MAX_FOLDER_ATTEMPTS) record.pendingFolders.push(pending) + } + + remove(id: string): LocalWindowRecord | undefined { + const record = this.get(id) + if (!record) return undefined + this.records.delete(record.id) + this.webContentsIds.delete(record.window.webContents.id) + this.mru = this.mru.filter((candidate) => candidate !== record.id) + return record + } + + fanout(channel: string, payload: unknown): void { + for (const record of this.records.values()) { + if (!record.window.isDestroyed() && !record.window.webContents.isDestroyed()) { + record.window.webContents.send(channel, payload) + } + } + } +} diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index f90fb3611..a2614791c 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,840 +1,372 @@ -import { app, BrowserView, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron" +import { app, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron" import http from "node:http" import https from "node:https" -import { existsSync, mkdirSync, rmSync } from "fs" -import { dirname, join } from "path" -import { fileURLToPath } from "url" -import { createApplicationMenu } from "./menu" +import { existsSync, mkdirSync, rmSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import { ClientStateManager } from "./client-state" import { setupClientStateIPC } from "./client-state-ipc" -import { ClientStateLifecycle } from "./client-state-lifecycle" import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" +import { LocalWindowRegistry, type LocalWindowRecord } from "./local-window-registry" +import { clearWorkspaceMenuWindow, createApplicationMenu, setWorkspaceMenuEnabled } from "./menu" +import { resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target" +import { MultiwindowLifecycle } from "./multiwindow-lifecycle" +import { decideNavigation, requireHttpUrl } from "./navigation-security" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" -import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { CliProcessManager } from "./process-manager" -import { - clampWindowBounds, - DEFAULT_WINDOW_HEIGHT, - DEFAULT_WINDOW_WIDTH, - installWindowZoomInput, - restoreWindowState, - WindowStateTracker, -} from "./window-state" - -const mainFilename = fileURLToPath(import.meta.url) -const mainDirname = dirname(mainFilename) +import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry" +import { resolveConfiguredRendererOrigins } from "./renderer-origin" +import { SerializedLifecycle } from "./serialized-lifecycle" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, startPrimaryInstance, type LaunchIntent } from "./startup" +import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, installWindowZoomInput, restoreWindowState, WindowStateTracker } from "./window-state" +const mainDirname = dirname(fileURLToPath(import.meta.url)) const isMac = process.platform === "darwin" -function configureDevStoragePaths() { - if (app.isPackaged) { - return - } - - const appName = "CodeNomad" - - try { - app.setName(appName) - - const userDataPath = join(app.getPath("appData"), appName) - const sessionDataPath = join(userDataPath, "session-data") - - mkdirSync(userDataPath, { recursive: true }) - mkdirSync(sessionDataPath, { recursive: true }) - - app.setPath("userData", userDataPath) - app.setPath("sessionData", sessionDataPath) - } catch (error) { - console.warn("[cli] failed to configure dev storage paths", error) - } -} - -configureDevStoragePaths() - -function configurePackagedStoragePaths() { - if (!app.isPackaged) { - return - } - - try { - const sessionDataPath = join(app.getPath("userData"), "session-data-v2") - mkdirSync(sessionDataPath, { recursive: true }) - app.setPath("sessionData", sessionDataPath) - } catch (error) { - console.warn("[electron-startup] failed to configure packaged session data path", error) - } +function configureStoragePaths() { + const baseUserDataPath = app.isPackaged ? app.getPath("userData") : join(app.getPath("appData"), "CodeNomad") + if (!app.isPackaged) app.setName("CodeNomad") + const scope = resolveStorageScope({ + appVersion: app.getVersion(), environmentChannel: process.env.CODENOMAD_UPDATE_CHANNEL, + cliConfig: process.env.CLI_CONFIG, cwd: process.cwd(), baseUserDataPath, packaged: app.isPackaged, + }) + mkdirSync(scope.userDataPath, { recursive: true }) + mkdirSync(scope.sessionDataPath, { recursive: true }) + app.setPath("userData", scope.userDataPath) + app.setPath("sessionData", scope.sessionDataPath) + return scope } -configurePackagedStoragePaths() - function cleanupPackagedChromiumStorage() { - if (!app.isPackaged) { - return - } - - const roots = [app.getPath("sessionData"), app.getPath("userData"), join(app.getPath("userData"), "session-data")] - const names = ["Service Worker", "QuotaManager", "QuotaManager-journal"] - - for (const root of roots) { - for (const name of names) { + if (!app.isPackaged) return + for (const root of [app.getPath("sessionData"), app.getPath("userData"), join(app.getPath("userData"), "session-data")]) { + for (const name of ["Service Worker", "QuotaManager", "QuotaManager-journal"]) { const candidate = join(root, name) - if (!existsSync(candidate)) { - continue - } - - try { - rmSync(candidate, { recursive: true, force: true }) - console.info("[electron-startup] removed stale Chromium storage", candidate) - } catch (error) { - console.warn("[electron-startup] failed to remove stale Chromium storage", candidate, error) - } - } - } -} - -cleanupPackagedChromiumStorage() - -const clientStateManager = new ClientStateManager(app.getPath("userData")) -const cliManager = new CliProcessManager() -let mainWindow: BrowserWindow | null = null -let currentCliUrl: string | null = null -let pendingCliUrl: string | null = null -let pendingBootstrapToken: string | null = null -let showingLoadingScreen = false -let preloadingView: BrowserView | null = null -let mainNavigationController: ClientStateNavigationController | null = null -const remoteWindowOrigins = new Map>() -const insecureWindowOrigins = new Map>() -const clientStateLifecycle = new ClientStateLifecycle({ - app, - clientStateManager, - cliManager, - getMainWindow: () => mainWindow, - getAllWindows: () => BrowserWindow.getAllWindows(), - getAllowedRendererOrigins, - isTrustedRendererOrigin: isAllowedRendererOrigin, -}) -const bindClientStateWindow = setupClientStateIPC( - ipcMain, - clientStateManager, - () => mainWindow, - getAllowedRendererOrigins, -) - -if (isMac) { - app.commandLine.appendSwitch("disable-spell-checking") -} - -function getIconPath() { - if (app.isPackaged) { - return join(process.resourcesPath, "icon.png") - } - - return join(mainDirname, "../resources/icon.png") -} - -type LoadingTarget = - | { type: "url"; source: string } - | { type: "file"; source: string } - -function resolveDevLoadingUrl(): string | null { - if (app.isPackaged) { - return null - } - const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL - if (!devBase) { - return null - } - - try { - const normalized = devBase.endsWith("/") ? devBase : `${devBase}/` - return new URL("loading.html", normalized).toString() - } catch (error) { - console.warn("[cli] failed to construct dev loading URL", devBase, error) - return null - } -} - -function resolveLoadingTarget(): LoadingTarget { - const devUrl = resolveDevLoadingUrl() - if (devUrl) { - return { type: "url", source: devUrl } - } - const filePath = resolveLoadingFilePath() - return { type: "file", source: filePath } -} - -function resolveLoadingFilePath() { - const candidates = [ - join(app.getAppPath(), "dist/renderer/loading.html"), - join(process.resourcesPath, "dist/renderer/loading.html"), - join(mainDirname, "../dist/renderer/loading.html"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - - return join(app.getAppPath(), "dist/renderer/loading.html") -} - -async function loadLoadingScreen(window: BrowserWindow): Promise { - const target = resolveLoadingTarget() - try { - await (target.type === "url" ? window.loadURL(target.source) : window.loadFile(target.source)) - return true - } catch (error) { - if (isIgnorableNavigationError(error)) { - return false - } - console.error("[cli] failed to load loading screen:", error) - return false - } -} - -function isIgnorableNavigationError(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false - } - - const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : "" - const message = "message" in error ? String((error as { message?: unknown }).message ?? "") : "" - return code === "ERR_ABORTED" || code === "ERR_FAILED" || message.includes("ERR_ABORTED") || message.includes("ERR_FAILED") -} - -function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { - const origins = new Set() - if (window) { - for (const origin of remoteWindowOrigins.get(window.id) ?? []) { - origins.add(origin) - } - } - for (const origin of resolveConfiguredRendererOrigins(currentCliUrl, app.isPackaged, [ - process.env.VITE_DEV_SERVER_URL, - process.env.ELECTRON_RENDERER_URL, - ])) { - origins.add(origin) - } - return Array.from(origins) -} - -function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean { - try { - const parsed = new URL(url) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return true + if (!existsSync(candidate)) continue + try { rmSync(candidate, { recursive: true, force: true }) } catch (error) { console.warn("[electron-startup] failed to remove stale Chromium storage", candidate, error) } } - const allowedOrigins = getAllowedRendererOrigins(window) - return !allowedOrigins.includes(parsed.origin) - } catch { - return false } } -function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) { - const handleExternal = (url: string) => { - shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) - } - - window.webContents.setWindowOpenHandler(({ url }) => { - if (shouldOpenExternally(url, window)) { - handleExternal(url) - return { action: "deny" } - } - return { action: "allow" } +function argvForLaunch(argv: string[]): string[] { + return argv.slice(app.isPackaged ? 1 : 2) +} + +const storageScope = configureStoragePaths() +const initialIntent = parseLaunchIntent(argvForLaunch(process.argv), process.cwd()) +startPrimaryInstance(() => app.requestSingleInstanceLock(), () => app.quit(), () => runPrimary(initialIntent)) + +function runPrimary(firstIntent: LaunchIntent) { + cleanupPackagedChromiumStorage() + const clientState = new ClientStateManager(app.getPath("userData"), undefined, storageScope.clientStateElectionDirectory + ? { crossHostElectionDirectory: storageScope.clientStateElectionDirectory } + : undefined) + const cli = new CliProcessManager() + const registry = new LocalWindowRegistry(async (id) => { await clientState.setActiveWindow(id) }) + const remoteOrigins = new Map>() + const insecureOrigins = new Map>() + const navigationLifecycle = new SerializedLifecycle() + let backendUrl: string | null = null + let backendTargetUrl: string | null = null + const remoteWindows = new RemoteWindowRegistry((sessionId) => { + if (!backendUrl) return + const target = new URL(`/api/remote-proxy/sessions/${encodeURIComponent(sessionId)}`, backendUrl) + const request = (target.protocol === "https:" ? https : http).request(target, { method: "DELETE" }, (response) => response.resume()) + request.on("error", (error) => console.warn("[electron] failed to clean up remote proxy session", sessionId, error)) + request.end() }) - window.webContents.on("will-navigate", (event, url) => { - if (shouldOpenExternally(url, window)) { - event.preventDefault() - handleExternal(url) - } else if (navigationController) { - event.preventDefault() - void navigationController.navigate((target) => target.loadURL(url)).catch((error) => { - if (!isIgnorableNavigationError(error)) { - console.error("[client-state] trusted renderer navigation failed", error) - } - }) - } + const getAllowedOrigins = (window?: BrowserWindow | null): string[] => { + const origins = new Set(remoteOrigins.get(window?.id ?? -1) ?? []) + for (const origin of resolveConfiguredRendererOrigins(backendUrl, app.isPackaged, [process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL])) origins.add(origin) + return [...origins] + } + const lifecycle = new MultiwindowLifecycle({ + app, clientStateManager: clientState, cliManager: cli, + getLocalWindows: () => registry.all(), getAllWindows: () => BrowserWindow.getAllWindows(), + removeWindowState: (id) => clientState.removeWindow(id), getAllowedRendererOrigins: getAllowedOrigins, + isTrustedRendererOrigin: isAllowedRendererOrigin, + navigationLifecycle, }) + const bindClientState = setupClientStateIPC(ipcMain, clientState, (sender) => registry.resolve(sender), getAllowedOrigins) - window.webContents.on("will-redirect", (event, url) => { - if (shouldOpenExternally(url, window)) { - event.preventDefault() - handleExternal(url) + const loadingTarget = (): { url?: string; file?: string } => { + if (!app.isPackaged) { + const base = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL + if (base) return { url: new URL("loading.html", base.endsWith("/") ? base : `${base}/`).toString() } } - }) -} - -function setWindowAllowedOrigin(window: BrowserWindow, url: string) { - try { - const origin = new URL(url).origin - remoteWindowOrigins.set(window.id, new Set([origin])) - } catch (error) { - console.warn("[cli] failed to store allowed origin", url, error) - } -} - -function stageWindowAllowedOrigin(window: BrowserWindow, url: string): () => void { - const previous = remoteWindowOrigins.get(window.id) - try { - const origins = new Set(previous) - origins.add(new URL(url).origin) - remoteWindowOrigins.set(window.id, origins) - } catch (error) { - console.warn("[cli] failed to stage allowed origin", url, error) - } - return () => { - if (previous) remoteWindowOrigins.set(window.id, previous) - else remoteWindowOrigins.delete(window.id) - } -} - -function clearWindowAllowedOrigin(window: BrowserWindow) { - remoteWindowOrigins.delete(window.id) -} - -function addWindowInsecureOrigin(window: BrowserWindow, url: string) { - try { - const origin = new URL(url).origin - insecureWindowOrigins.set(window.id, new Set([origin])) - } catch (error) { - console.warn("[cli] failed to store insecure origin", url, error) + const candidates = [join(app.getAppPath(), "dist/renderer/loading.html"), join(process.resourcesPath, "dist/renderer/loading.html"), join(mainDirname, "../dist/renderer/loading.html")] + return { file: candidates.find(existsSync) ?? candidates[0] } + } + const getLoadingUrl = () => { + const target = loadingTarget() + return target.url ?? pathToFileURL(target.file!).toString() + } + const loadLoading = async (record: LocalWindowRecord, force = false) => { + if (record.window.isDestroyed() || (record.loading && !force)) return + record.loading = true + const target = loadingTarget() + await record.navigation.navigate(async (window, generation) => { + if (!record.navigation.isCurrent(generation)) return + await (target.url ? window.loadURL(target.url) : window.loadFile(target.file!)) + if (!record.navigation.isCurrent(generation)) return + record.backendUrl = null + remoteOrigins.delete(record.window.id) + }).catch((error) => { + if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load loading screen", error) + }) } -} - -function clearWindowInsecureOrigin(window: BrowserWindow) { - insecureWindowOrigins.delete(window.id) -} - -function isInsecureOriginAllowed(url: string) { - try { - const targetOrigin = new URL(url).origin - for (const origins of insecureWindowOrigins.values()) { - if (origins.has(targetOrigin)) { - return true + const navigateBackend = async (record: LocalWindowRecord, url: string) => { + if (record.window.isDestroyed() || (!record.loading && record.backendUrl === url)) return + let origin: string + try { origin = new URL(url).origin } catch { return } + await record.navigation.navigate(async (window, generation) => { + if (!record.navigation.isCurrent(generation)) return + const previous = remoteOrigins.get(record.window.id) + remoteOrigins.set(record.window.id, new Set([...(previous ?? []), origin])) + try { await window.loadURL(url) } catch (error) { + if (record.navigation.isCurrent(generation)) { + if (previous) remoteOrigins.set(record.window.id, previous); else remoteOrigins.delete(record.window.id) + } + throw error } - } - } catch { - return false - } - - return false -} - -let cachedPreloadPath: string | null = null -function getPreloadPath() { - if (cachedPreloadPath && existsSync(cachedPreloadPath)) { - return cachedPreloadPath - } - - const candidates = [ - join(process.resourcesPath, "preload/index.js"), - join(mainDirname, "../preload/index.js"), - join(mainDirname, "../preload/index.cjs"), - join(mainDirname, "../../preload/index.cjs"), - join(mainDirname, "../../electron/preload/index.cjs"), - join(app.getAppPath(), "preload/index.cjs"), - join(app.getAppPath(), "electron/preload/index.cjs"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - cachedPreloadPath = candidate - return candidate - } - } - - return join(mainDirname, "../preload/index.js") -} - -function destroyPreloadingView(target?: BrowserView | null) { - const view = target ?? preloadingView - if (!view) { - return - } - - try { - const contents = view.webContents as any - contents?.destroy?.() - } catch (error) { - console.warn("[cli] failed to destroy preloading view", error) - } - - if (!target || view === preloadingView) { - preloadingView = null + if (!record.navigation.isCurrent(generation)) return + record.loading = false + record.backendUrl = url + remoteOrigins.set(record.window.id, new Set([origin])) + }).catch((error) => { + if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load backend", error) + }) } -} - -function createWindow() { - const prefersDark = true - const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" - const iconPath = getIconPath() - const savedWindowState = clientStateManager.getWindowState() - const restoredBounds = savedWindowState - ? clampWindowBounds( - savedWindowState.bounds, - screen.getAllDisplays().map((display) => display.workArea), - ) - : undefined - - mainWindow = new BrowserWindow({ - width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH, - height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT, - useContentSize: true, - ...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}), - minWidth: 800, - minHeight: 600, - backgroundColor, - icon: iconPath, - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - ...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}), - spellcheck: !isMac, - additionalArguments: ["--codenomad-window-context=local"], - }, - }) - - const window = mainWindow - const navigationController = new ClientStateNavigationController( - window, - { - clientStateManager, - isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)), - reportFlushError: (error) => { - console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error) - }, + const bootstrap = new BackendBootstrapCoordinator( + (url, token) => exchangeBootstrapToken(url, token, cli), + (url) => { + backendTargetUrl = url + for (const record of registry.all()) void navigateBackend(record, url) }, + (error) => console.error("[cli] bootstrap token exchange failed", error), ) - mainNavigationController = navigationController - let windowStateTracker: WindowStateTracker | null = null - if (clientStateManager.isPrimary) { - restoreWindowState(window, savedWindowState, restoredBounds) - windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState) - } - installWindowZoomInput(window, (level) => { - if (windowStateTracker) windowStateTracker.setZoomLevel(level) - else window.webContents.setZoomLevel(level) - }) - - setupNavigationGuards(window, navigationController) - - if (isMac) { - window.webContents.session.setSpellCheckerEnabled(false) + const createWindow = (windowId: string, persisted = true): LocalWindowRecord => { + const saved = persisted ? clientState.getWindowState(windowId) : undefined + const bounds = saved ? clampWindowBounds(saved.bounds, screen.getAllDisplays().map((display) => ({ ...display.workArea, scaleFactor: display.scaleFactor }))) : undefined + const window = new BrowserWindow({ + width: bounds?.width ?? DEFAULT_WINDOW_WIDTH, height: bounds?.height ?? DEFAULT_WINDOW_HEIGHT, + ...(bounds ? { x: bounds.x, y: bounds.y } : {}), useContentSize: true, minWidth: 800, minHeight: 600, + backgroundColor: "#1a1a1a", icon: getIconPath(), + webPreferences: { + preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, + ...(saved ? { zoomFactor: saved.zoomFactor } : {}), + additionalArguments: ["--codenomad-window-context=local", `--codenomad-window-id=${windowId}`], + }, + }) + const navigation = new ClientStateNavigationController(window, { + clientStateManager: persisted ? clientState : { isPrimary: false }, + isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedOrigins(window)), + reportFlushError: (error) => console.warn("[client-state] renderer pre-navigation flush failed", error), + lifecycle: navigationLifecycle, + }) + const tracker = persisted && clientState.isPrimary ? new WindowStateTracker(window, clientState, saved, windowId) : null + if (persisted && clientState.isPrimary) restoreWindowState(window, saved, bounds) + const record: LocalWindowRecord = { id: windowId, persisted, window, navigation, tracker, loading: false, backendUrl: null, pendingFolders: [] } + registry.add(record) + bindClientState(window) + lifecycle.attach(record) + installWindowZoomInput(window, (level) => tracker ? tracker.setZoomLevel(level) : window.webContents.setZoomLevel(level)) + setupNavigationGuards(window, navigation, getAllowedOrigins, getLoadingUrl) + window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { + if (isMainFrame) setWorkspaceMenuEnabled(window, false) + }) + window.on("focus", () => registry.markFocused(windowId)) + window.on("closed", () => { + registry.remove(windowId) + clearWorkspaceMenuWindow(window) + remoteOrigins.delete(window.id) + insecureOrigins.delete(window.webContents.id) + }) + if (isMac) window.webContents.session.setSpellCheckerEnabled(false) + if (process.env.NODE_ENV === "development") window.webContents.openDevTools({ mode: "detach" }) + void (backendTargetUrl ? navigateBackend(record, backendTargetUrl) : loadLoading(record)) + return record + } + + const ensureLocalWindow = () => { + const existing = registry.mruRecord() + if (existing) return existing + const persistedId = clientState.windowIds.find((id) => !registry.get(id)) + return persistedId ? createWindow(persistedId) : undefined + } + const createNewWindow = async () => { + const identity = await allocateLocalWindowIdentity( + clientState.windowIds, + (id) => Boolean(registry.get(id)), + () => clientState.addWindow(), + (error) => console.warn("[client-state] failed to persist a new window; using an ephemeral window", error), + ) + return createWindow(identity.id, identity.persisted) } - - showingLoadingScreen = true - currentCliUrl = null - clearWindowAllowedOrigin(window) - void loadLoadingScreen(window) - - if (process.env.NODE_ENV === "development") { - window.webContents.openDevTools({ mode: "detach" }) + const handleIntent = async (intent: LaunchIntent) => { + const record = intent.newWindow ? await createNewWindow() : registry.focusMru() ?? ensureLocalWindow() ?? await createNewWindow() + for (const folder of intent.folders) registry.queueFolder(record.id, folder) + registry.focus(record.id) } + const intentQueue = createLaunchIntentQueue(handleIntent, (error) => console.error("[electron-startup] launch intent failed", error)) + const firstLaunch = intentQueue.enqueue(firstIntent) + void firstLaunch.catch(() => {}) - createApplicationMenu(window, { - reload: () => { - void navigationController.navigate((target) => target.webContents.reload()) - }, - forceReload: () => { - void navigationController.navigate((target) => target.webContents.reloadIgnoringCache()) - }, + setupCliIPC(cli, { + resolveLocal: (sender) => registry.resolve(sender), getAllowedOrigins, + openRemoteWindow, newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }), + nextFolder: (id) => registry.nextFolder(id), acknowledgeFolder: (id, folder, opened) => registry.acknowledgeFolder(id, folder, opened), }) - setupCliIPC(window, cliManager) - bindClientStateWindow(window) - clientStateLifecycle.attachMainWindow(window, windowStateTracker) - - window.on("closed", () => { - destroyPreloadingView() - clearWindowAllowedOrigin(window) - clearWindowInsecureOrigin(window) - mainWindow = null - if (mainNavigationController === navigationController) mainNavigationController = null - currentCliUrl = null - pendingCliUrl = null - showingLoadingScreen = false - clientStateLifecycle.detachMainWindow(window) + lifecycle.registerAppEvents() + app.on("second-instance", (_event, argv, workingDirectory) => { + void intentQueue.enqueue(parseLaunchIntent(argvForLaunch(argv), workingDirectory || process.cwd())).catch(() => {}) }) - - if (pendingCliUrl) { - const url = pendingCliUrl - pendingCliUrl = null - startCliPreload(url) - } -} - -function showLoadingScreen(force = false) { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - - if (showingLoadingScreen && !force) { - return - } - - const window = mainWindow - const wasShowingLoadingScreen = showingLoadingScreen - showingLoadingScreen = true - destroyPreloadingView() - pendingCliUrl = null - void mainNavigationController?.navigate(async (target) => { - if (!(await loadLoadingScreen(target))) { - showingLoadingScreen = wasShowingLoadingScreen - return - } - currentCliUrl = null - clearWindowAllowedOrigin(target) + app.on("open-file", (event, path) => { + event.preventDefault() + const intent = parseLaunchIntent([path], process.cwd()) + if (intent.folders.length) void intentQueue.enqueue(intent).catch(() => {}) }) -} - -function isBootstrapTokenUrl(url: string): boolean { - try { - const parsed = new URL(url) - return parsed.pathname === "/auth/token" && parsed.hash.length > 1 - } catch { - return false - } -} - -function startCliPreload(url: string) { - if (!mainWindow || mainWindow.isDestroyed()) { - pendingCliUrl = url - return - } - - if (currentCliUrl === url && !showingLoadingScreen) { - return - } - - pendingCliUrl = url - destroyPreloadingView() - - if (!showingLoadingScreen) { - showLoadingScreen(true) - } - - // Important: /auth/token#... is one-time. Preloading + swapping would load it twice, - // consuming the token in the hidden view and then failing in the main window. - if (isBootstrapTokenUrl(url)) { - finalizeCliSwap(url) - return - } - - const view = new BrowserView({ - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - spellcheck: !isMac, - }, + app.on("certificate-error", (event, contents, url, error, _certificate, callback) => { + if (contents && isRemoteCertificateAllowed(contents.id, url, insecureOrigins)) { + event.preventDefault(); console.warn("[cli] allowing insecure remote certificate", url, error); callback(true) + } else callback(false) }) - preloadingView = view - - view.webContents.once("did-finish-load", () => { - if (preloadingView !== view) { - destroyPreloadingView(view) - return - } - finalizeCliSwap(url) + cli.on("bootstrapToken", (token) => bootstrap.setToken(token)) + cli.on("ready", (status) => { + if (!status.url) return + backendUrl = status.url + bootstrap.setReady(status.url) + registry.fanout("cli:ready", status) }) - - view.webContents.loadURL(url).catch((error) => { - if (isIgnorableNavigationError(error)) { - return - } - console.error("[cli] failed to preload CLI view:", error) - if (preloadingView === view) { - destroyPreloadingView(view) + cli.on("status", (status) => { + registry.fanout("cli:status", status) + if (status.state !== "ready") { + bootstrap.reset() + backendUrl = null + backendTargetUrl = null + for (const record of registry.all()) void loadLoading(record, true) } }) -} - -function finalizeCliSwap(url: string) { - destroyPreloadingView() - - if (!mainWindow || mainWindow.isDestroyed()) { - pendingCliUrl = url - return - } - - const navigate = async (target: BrowserWindow) => { - const rollbackOrigin = stageWindowAllowedOrigin(target, url) - try { - await target.loadURL(url) - } catch (error) { - rollbackOrigin() - throw error + cli.on("error", (error) => registry.fanout("cli:error", { message: error.message })) + + app.whenReady().then(async () => { + try { app.setAppUserModelId("ai.neuralnomads.codenomad.client") } catch {} + if (isMac) { + session.defaultSession.setSpellCheckerEnabled(false) + configureMediaPermissionHandlers(getAllowedOrigins) + if (app.dock) { const icon = nativeImage.createFromPath(getIconPath()); if (!icon.isEmpty()) app.dock.setIcon(icon) } } - showingLoadingScreen = false - currentCliUrl = url - setWindowAllowedOrigin(target, url) - pendingCliUrl = null - } - void mainNavigationController?.navigate(navigate).then(() => { - if (cliManager.getStatus().state !== "ready") showLoadingScreen() - }).catch((error) => { - if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load CLI view:", error) + createApplicationMenu({ + getLocalTarget: () => { + const focused = BrowserWindow.getFocusedWindow() + const mru = registry.mruRecord()?.window ?? null + return resolveFocusedLocalTarget(focused, mru, (window) => Boolean(registry.resolve(window.webContents))) + }, + getWindowTarget: () => resolveWindowTarget(BrowserWindow.getFocusedWindow(), registry.mruRecord()?.window ?? null), + newWindow: () => { void intentQueue.enqueue({ newWindow: true, folders: [] }).catch(() => {}) }, + reload: (window) => { const record = registry.resolve(window.webContents); if (record) void record.navigation.navigate((target) => target.webContents.reload()); else window.webContents.reload() }, + forceReload: (window) => { const record = registry.resolve(window.webContents); if (record) void record.navigation.navigate((target) => target.webContents.reloadIgnoringCache()); else window.webContents.reloadIgnoringCache() }, + }) + for (const id of clientState.windowIds) createWindow(id) + registry.focus(clientState.activeWindowId) + intentQueue.start() + await firstLaunch.catch(() => {}) + void cli.start({ dev: !app.isPackaged }).catch((error) => registry.fanout("cli:error", { message: error instanceof Error ? error.message : String(error) })) + app.on("activate", () => { if (registry.all().length === 0) void intentQueue.enqueue({ newWindow: false, folders: [] }).catch(() => {}) }) }) -} -function buildRemoteWindowTitle(name: string, baseUrl: string) { - return `${name} - ${baseUrl}` + function getIconPath() { + return app.isPackaged ? join(process.resourcesPath, "icon.png") : join(mainDirname, "../resources/icon.png") + } + function getPreloadPath() { + const candidates = [join(process.resourcesPath, "preload/index.js"), join(mainDirname, "../preload/index.js"), join(mainDirname, "../preload/index.cjs"), join(mainDirname, "../../electron/preload/index.cjs"), join(app.getAppPath(), "electron/preload/index.cjs")] + return candidates.find(existsSync) ?? candidates[0] + } + async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) { + return remoteWindows.serialize(payload.id, async () => { + const base = requireHttpUrl(payload.baseUrl, "baseUrl") + const target = requireHttpUrl(payload.entryUrl ?? payload.baseUrl, "entryUrl") + const title = `${payload.name} - ${payload.baseUrl}` + const existing = remoteWindows.reuse(payload.id, payload.proxySessionId) + if (existing) { + const allowedOrigins = new Set([base.origin, target.origin]) + existing.setTitle(title) + await navigateRemoteWindow(existing, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) + return + } + const remoteSession = session.fromPartition(resolveRemoteSessionPartition(payload.id, payload.proxySessionId)) + const window = new BrowserWindow({ + width: 1400, height: 900, minWidth: 800, minHeight: 600, backgroundColor: "#1a1a1a", icon: getIconPath(), title, + webPreferences: { session: remoteSession, preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=remote"] }, + }) + const allowedOrigins = new Set([base.origin, target.origin]) + remoteWindows.register(payload.id, window, payload.proxySessionId) + if (isMac) configureMediaPermissionHandlers(() => BrowserWindow.getAllWindows() + .filter((candidate) => candidate.webContents.session === remoteSession) + .flatMap((candidate) => [...(remoteOrigins.get(candidate.id) ?? [])]), remoteSession) + window.setTitle(title) + window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(title) }) + setupNavigationGuards(window, undefined, getAllowedOrigins, getLoadingUrl) + lifecycle.attachRemote(window) + window.on("closed", () => { remoteOrigins.delete(window.id); insecureOrigins.delete(window.webContents.id) }) + try { await navigateRemoteWindow(window, target, allowedOrigins, remoteOrigins, insecureOrigins, payload.skipTlsVerify) } catch (error) { + console.warn("[electron] failed to load remote window; showing loading screen", error) + const loading = loadingTarget() + await (loading.url ? window.loadURL(loading.url) : window.loadFile(loading.file!)) + } + }) + } } -function lockWindowTitle(window: BrowserWindow, title: string) { - window.setTitle(title) - window.webContents.on("page-title-updated", (event) => { - event.preventDefault() - window.setTitle(title) +function setupNavigationGuards( + window: BrowserWindow, + navigation: ClientStateNavigationController | undefined, + allowedOrigins: (window: BrowserWindow) => string[], + loadingUrl: () => string, +) { + const external = (url: string) => shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) + const decide = (url: string) => decideNavigation(url, allowedOrigins(window), loadingUrl()) + window.webContents.setWindowOpenHandler(({ url }) => { + if (decide(url) === "external") void external(url) + return { action: "deny" } }) -} - -function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { - const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) - const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) - const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) - return `${escapedName}

${escapedName}

Could not connect to the remote server.

${escapedMessage}

${escapedUrl}
` -} - -async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) { - const targetUrl = new URL(payload.baseUrl) - const title = buildRemoteWindowTitle(payload.name, payload.baseUrl) - const window = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 800, - minHeight: 600, - backgroundColor: "#1a1a1a", - icon: getIconPath(), - title, - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - spellcheck: !isMac, - additionalArguments: ["--codenomad-window-context=remote"], - }, + window.webContents.on("will-navigate", (event, url) => { + const decision = decide(url) + if (decision !== "allow") { event.preventDefault(); if (decision === "external") void external(url) } + else if (navigation) { event.preventDefault(); void navigation.navigate((target) => target.loadURL(url)) } }) - lockWindowTitle(window, title) - - setWindowAllowedOrigin(window, targetUrl.toString()) - if (payload.skipTlsVerify) { - addWindowInsecureOrigin(window, targetUrl.toString()) - } - - setupNavigationGuards(window) - window.on("closed", () => { - clearWindowAllowedOrigin(window) - clearWindowInsecureOrigin(window) + window.webContents.on("will-redirect", (event, url) => { + const decision = decide(url) + if (decision !== "allow") { event.preventDefault(); if (decision === "external") void external(url) } }) - - try { - await window.loadURL(targetUrl.toString()) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`) - } } -let bootstrapExchangeInFlight = false - -function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null { - const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader - if (!raw) return null - - const first = raw.split(";")[0] ?? "" - const index = first.indexOf("=") - if (index < 0) return null - - const key = first.slice(0, index).trim() - const value = first.slice(index + 1).trim() - if (key !== name || !value) return null - - try { - return decodeURIComponent(value) - } catch { - return value - } +function isIgnorableNavigationError(error: unknown): boolean { + const text = error instanceof Error ? `${(error as Error & { code?: string }).code ?? ""} ${error.message}` : String(error) + return text.includes("ERR_ABORTED") || text.includes("ERR_FAILED") } -async function exchangeBootstrapToken(baseUrl: string, token: string): Promise { - const sessionCookieName = cliManager.getAuthCookieName() +async function exchangeBootstrapToken(baseUrl: string, token: string, cli: CliProcessManager): Promise { const target = new URL("/api/auth/token", baseUrl) const body = JSON.stringify({ token }) - const transport = target.protocol === "https:" ? https : http - - const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => { - const req = transport.request( - target, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }, - }, - (res) => { - res.resume() - resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] }) - }, - ) - - req.on("error", reject) - req.write(body) - req.end() - }) - - if (result.statusCode !== 200) { - return false - } - - const sessionId = extractCookieValue(result.setCookie, sessionCookieName) - if (!sessionId) { - return false - } - - await session.defaultSession.cookies.set({ - url: baseUrl, - name: sessionCookieName, - value: sessionId, - httpOnly: true, - path: "/", - sameSite: "lax", + const result = await new Promise<{ status: number; cookie?: string }>((resolve, reject) => { + const request = transport.request(target, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } }, (response) => { + response.resume() + resolve({ status: response.statusCode ?? 0, cookie: response.headers["set-cookie"]?.[0] }) + }) + request.on("error", reject); request.end(body) }) - + if (result.status !== 200 || !result.cookie) return false + const first = result.cookie.split(";", 1)[0] ?? "" + const separator = first.indexOf("=") + if (first.slice(0, separator).trim() !== cli.getAuthCookieName()) return false + await session.defaultSession.cookies.set({ url: baseUrl, name: cli.getAuthCookieName(), value: decodeURIComponent(first.slice(separator + 1).trim()), httpOnly: true, path: "/", sameSite: "lax" }) return true } -async function startCli() { - try { - // In desktop dev workflows we always want the CLI to run in dev mode so it: - // - uses plain HTTP - // - proxies UI requests to the renderer dev server - // Monaco's AMD assets are served from that dev server. - const devMode = !app.isPackaged - console.info("[cli] start requested (dev mode:", devMode, ")") - await cliManager.start({ dev: devMode }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error("[cli] start failed:", message) - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("cli:error", { message }) - } - } -} - -async function maybeExchangeAndNavigate(baseUrl: string) { - if (bootstrapExchangeInFlight) { - return - } - - const token = pendingBootstrapToken - if (!token) { - startCliPreload(baseUrl) - return - } - - bootstrapExchangeInFlight = true - - try { - const ok = await exchangeBootstrapToken(baseUrl, token) - pendingBootstrapToken = null - - if (!ok) { - startCliPreload(`${baseUrl}/login`) - return - } - - startCliPreload(baseUrl) - } catch (error) { - console.error("[cli] bootstrap token exchange failed:", error) - pendingBootstrapToken = null - startCliPreload(`${baseUrl}/login`) - } finally { - bootstrapExchangeInFlight = false - } -} - -cliManager.on("bootstrapToken", (token) => { - pendingBootstrapToken = token - - const status = cliManager.getStatus() - if (status.url) { - void maybeExchangeAndNavigate(status.url) - } -}) - -cliManager.on("ready", (status) => { - if (!status.url) { - return - } - - void maybeExchangeAndNavigate(status.url) -}) - -cliManager.on("status", (status) => { - if (status.state !== "ready") { - showLoadingScreen() - } -}) - -if (isMac) { - app.on("web-contents-created", (_, contents) => { - contents.session.setSpellCheckerEnabled(false) - }) -} - -app.whenReady().then(() => { - // Required for Windows notifications / taskbar grouping. - // Keep in sync with desktop app identifier. - try { - app.setAppUserModelId("ai.neuralnomads.codenomad.client") - } catch { - // ignore - } - - startCli() - - if (isMac) { - session.defaultSession.setSpellCheckerEnabled(false) - configureMediaPermissionHandlers(getAllowedRendererOrigins) - app.on("browser-window-created", (_, window) => { - window.webContents.session.setSpellCheckerEnabled(false) - }) - - if (app.dock) { - const dockIcon = nativeImage.createFromPath(getIconPath()) - if (!dockIcon.isEmpty()) { - app.dock.setIcon(dockIcon) - } - } - } - - createWindow() - ;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow - - app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => { - if (isInsecureOriginAllowed(url)) { - event.preventDefault() - console.warn("[cli] allowing insecure remote certificate for", url, error) - callback(true) - return - } - callback(false) - }) - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } - }) -}) - -clientStateLifecycle.registerAppEvents() +if (isMac) app.commandLine.appendSwitch("disable-spell-checking") diff --git a/packages/electron-app/electron/main/menu-target.test.ts b/packages/electron-app/electron/main/menu-target.test.ts new file mode 100644 index 000000000..6e0e655ce --- /dev/null +++ b/packages/electron-app/electron/main/menu-target.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { NEW_WINDOW_ACCELERATOR, resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target" + +test("New Window has its dedicated shortcut and menu actions target focused then MRU local windows", () => { + const local = { kind: "local" } + const other = { kind: "local" } + const remote = { kind: "remote" } + const isLocal = (window: { kind: string }) => window.kind === "local" + assert.equal(NEW_WINDOW_ACCELERATOR, "CmdOrCtrl+Shift+N") + assert.equal(resolveFocusedLocalTarget(local, other, isLocal), local) + assert.equal(resolveFocusedLocalTarget(remote, other, isLocal), null) + assert.equal(resolveFocusedLocalTarget(remote, remote, isLocal), null) + assert.equal(resolveFocusedLocalTarget(null, other, isLocal), other) + assert.equal(resolveWindowTarget(remote, other), remote) + assert.equal(resolveWindowTarget(null, other), other) +}) diff --git a/packages/electron-app/electron/main/menu-target.ts b/packages/electron-app/electron/main/menu-target.ts new file mode 100644 index 000000000..e28c212c8 --- /dev/null +++ b/packages/electron-app/electron/main/menu-target.ts @@ -0,0 +1,10 @@ +export const NEW_WINDOW_ACCELERATOR = "CmdOrCtrl+Shift+N" + +export function resolveFocusedLocalTarget(focused: T | null, mru: T | null, isLocal: (value: T) => boolean): T | null { + if (focused) return isLocal(focused) ? focused : null + return mru && isLocal(mru) ? mru : null +} + +export function resolveWindowTarget(focused: T | null, mru: T | null): T | null { + return focused ?? mru +} diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index ae345ba44..fb13afce1 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,131 +1,101 @@ -import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" +import { app, BrowserWindow, Menu, type MenuItemConstructorOptions } from "electron" +import { NEW_WINDOW_ACCELERATOR } from "./menu-target" interface ApplicationMenuActions { - reload(): void - forceReload(): void + getLocalTarget(): BrowserWindow | null + getWindowTarget(): BrowserWindow | null + newWindow(): void + reload(window: BrowserWindow): void + forceReload(window: BrowserWindow): void } -let workspaceActionsRequested = false +const workspaceEnabled = new Map() let applicationMenu: Menu | null = null -let localMainWindow: BrowserWindow | null = null +let menuInstalled = false +let actions: ApplicationMenuActions | null = null + +function target(local: boolean): BrowserWindow | null { + const window = (local ? actions?.getLocalTarget() : actions?.getWindowTarget()) ?? null + return window && !window.isDestroyed() ? window : null +} function updateWorkspaceMenuState() { - const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow + const window = target(true) + const enabled = Boolean(window && workspaceEnabled.get(window.webContents.id)) 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 +export function setWorkspaceMenuEnabled(window: BrowserWindow, enabled: boolean) { + workspaceEnabled.set(window.webContents.id, enabled) + updateWorkspaceMenuState() +} + +export function clearWorkspaceMenuWindow(window: BrowserWindow) { + workspaceEnabled.delete(window.webContents.id) updateWorkspaceMenuState() } -export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) { - localMainWindow = mainWindow +export function createApplicationMenu(menuActions: ApplicationMenuActions) { + actions = menuActions + if (menuInstalled) return + menuInstalled = true 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 sendCommand = (id: string) => () => target(true)?.webContents.send("menu:action", id) + const withTarget = (operation: (window: BrowserWindow) => void) => () => { + const window = target(false) + if (window) operation(window) } const template: MenuItemConstructorOptions[] = [ - ...(isMac - ? [ - { - label: "CodeNomad", - submenu: [ - { role: "about" as const }, - { type: "separator" as const }, - { role: "hide" as const }, - { role: "hideOthers" as const }, - { role: "unhide" as const }, - { type: "separator" as const }, - { role: "quit" as const }, - ], - }, - ] - : []), + ...(isMac ? [{ label: "CodeNomad", submenu: [ + { role: "about" as const }, { type: "separator" as const }, { role: "hide" as const }, + { role: "hideOthers" as const }, { role: "unhide" as const }, { type: "separator" as const }, { role: "quit" as const }, + ] }] : []), { label: "File", submenu: [ - { - label: "New Instance", - accelerator: "CmdOrCtrl+N", - 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 }, - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" as const }, - { role: "redo" as const }, - { type: "separator" as const }, - { role: "cut" as const }, - { role: "copy" as const }, - { role: "paste" as const }, - ...(isMac - ? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }] - : [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]), - ], - }, - { - label: "View", - submenu: [ - { label: "Reload", accelerator: "CmdOrCtrl+R", click: actions.reload }, - { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: actions.forceReload }, - { role: "toggleDevTools" as const }, - { type: "separator" as const }, - { role: "resetZoom" as const }, - { role: "zoomIn" as const }, - { role: "zoomOut" as const }, - { type: "separator" as const }, - { role: "togglefullscreen" as const }, - ], - }, - { - label: "Window", - submenu: [ - { role: "minimize" as const }, - { role: "zoom" as const }, - ...(isMac - ? [ - { type: "separator" as const }, - { role: "front" as const }, - { type: "separator" as const }, - { role: "window" as const }, - ] - : [{ role: "close" as const }]), + { 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" }, isMac ? { role: "close" } : { role: "quit" }, ], }, + { label: "Edit", submenu: [ + { role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, + ...(isMac ? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }] + : [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]), + ] }, + { label: "View", submenu: [ + { label: "Reload", accelerator: "CmdOrCtrl+R", click: withTarget((window) => actions?.reload(window)) }, + { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: withTarget((window) => actions?.forceReload(window)) }, + { label: "Toggle Developer Tools", accelerator: isMac ? "Alt+Command+I" : "Ctrl+Shift+I", click: withTarget((window) => window.webContents.toggleDevTools()) }, + { type: "separator" }, + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: withTarget((window) => window.webContents.setZoomLevel(0)) }, + { label: "Zoom In", accelerator: "CmdOrCtrl+Plus", click: withTarget((window) => window.webContents.setZoomLevel(window.webContents.getZoomLevel() + 0.5)) }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: withTarget((window) => window.webContents.setZoomLevel(window.webContents.getZoomLevel() - 0.5)) }, + { type: "separator" }, + { label: "Toggle Full Screen", accelerator: isMac ? "Ctrl+Command+F" : "F11", click: withTarget((window) => window.setFullScreen(!window.isFullScreen())) }, + ] }, + { label: "Window", submenu: [ + { label: "New Window", accelerator: NEW_WINDOW_ACCELERATOR, click: () => actions?.newWindow() }, + { label: "New Instance", accelerator: "CmdOrCtrl+N", click: sendCommand("new-instance") }, + { type: "separator" }, + { label: "Minimize", accelerator: "CmdOrCtrl+M", click: withTarget((window) => window.minimize()) }, + ...(isMac ? [{ role: "front" as const }] : [{ label: "Close", accelerator: "CmdOrCtrl+W", click: withTarget((window) => window.close()) }]), + ] }, ] - const menu = Menu.buildFromTemplate(template) - applicationMenu = menu - Menu.setApplicationMenu(menu) + applicationMenu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(applicationMenu) 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) } diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts new file mode 100644 index 000000000..6ebd08872 --- /dev/null +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.test.ts @@ -0,0 +1,347 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { MultiwindowLifecycle, type LifecycleWindow } from "./multiwindow-lifecycle" + +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function windowRecord(id: string, calls: string[]): LifecycleWindow & { events: Map } { + const events = new Map() + const window = { + on: (name: string, handler: Function) => events.set(name, handler), isDestroyed: () => false, + isVisible: () => !calls.includes(`hide:${id}`) || calls.lastIndexOf(`show:${id}`) > calls.lastIndexOf(`hide:${id}`), + hide: () => calls.push(`hide:${id}`), show: () => calls.push(`show:${id}`), close: () => { calls.push(`close:${id}`); events.get("close")?.({ preventDefault: () => assert.fail() }) }, + webContents: { isDestroyed: () => false, getURL: () => "http://localhost/app", executeJavaScript: async () => calls.push(`renderer:${id}`) }, + } + return { id, window: window as never, tracker: { flush: async () => calls.push(`native:${id}`) } as never, events } +} + +test("closing one local window removes only its V3 record and leaves backend running", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + const local = [first, second] + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => local, getAllWindows: () => local.map((record) => record.window), + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.deepEqual(calls, ["prevent", "renderer:one", "native:one", "remove:one", "close:one"]) +}) + +test("closing the sole local window while a remote remains removes its V3 record", async () => { + const calls: string[] = [] + const local = windowRecord("local", calls) + const remote = { isDestroyed: () => false } + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [local], getAllWindows: () => [local.window, remote as never], + removeWindowState: async (id) => { calls.push(`remove:${id}`); return true }, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(local) + local.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.deepEqual(calls, ["prevent", "renderer:local", "native:local", "remove:local", "close:local"]) +}) + +test("persisted local close waits for confirmed removal and remains retryable", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + let removals = 0 + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => calls.push("quit"), exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true } as never, cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [first, second], getAllWindows: () => [first.window, second.window], + removeWindowState: async () => { + removals += 1 + calls.push(`remove:${removals}`) + if (removals === 2) throw new Error("remove failed") + return removals === 3 + }, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.equal(calls.includes("close:one"), false) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.equal(calls.includes("close:one"), false) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + + assert.equal(removals, 3) + assert.equal(calls.filter((call) => call === "close:one").length, 1) +}) + +test("renderer persistence failure blocks destructive local close and remains retryable", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + let attempts = 0 + first.window.webContents.executeJavaScript = async () => { + attempts += 1 + calls.push(`renderer:${attempts}`) + if (attempts === 1) throw new Error("snapshot too large") + } + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => {}, exit: () => {} } as never, + clientStateManager: { isPrimary: true } as never, cliManager: { shutdown: async () => {} } as never, + getLocalWindows: () => [first, second], getAllWindows: () => [first.window, second.window], + removeWindowState: async () => { calls.push("remove"); return true }, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.deepEqual(calls, ["prevent", "renderer:1"]) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + await tick() + assert.deepEqual(calls, ["prevent", "renderer:1", "prevent", "renderer:2", "native:one", "remove", "close:one"]) +}) + +test("global shutdown asks all renderers concurrently before aggregate persistence", async () => { + const calls: string[] = [] + const events = new Map() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const first = windowRecord("one", calls) + const second = windowRecord("two", calls) + first.window.webContents.executeJavaScript = async () => { calls.push("renderer:one"); await gate } + second.window.webContents.executeJavaScript = async () => { calls.push("renderer:two") } + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, getLocalWindows: () => [first, second], getAllWindows: () => [first.window, second.window], + removeWindowState: async () => true, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.registerAppEvents() + events.get("before-quit")?.({ preventDefault: () => {} }) + await tick() + assert.deepEqual(calls.filter((call) => call.startsWith("renderer:")), ["renderer:one", "renderer:two"]) + assert.equal(calls.includes("aggregate"), false) + release() + await tick(); await tick() + assert.ok(calls.indexOf("aggregate") > calls.indexOf("native:two")) +}) + +test("final close retains its record and shutdown stops/releases once", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + const app = { on: (name: string, handler: Function) => events.set(name, handler), quit: () => calls.push("quit"), exit: () => calls.push("exit") } + const lifecycle = new MultiwindowLifecycle({ + app: app as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, getLocalWindows: () => [first], getAllWindows: () => [first.window], + removeWindowState: async () => { calls.push("remove"); return true }, getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first); lifecycle.registerAppEvents() + first.events.get("close")?.({ preventDefault: () => calls.push("prevent") }) + assert.deepEqual(calls, ["prevent", "quit"]) + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) + await tick(); await tick() + assert.equal(calls.includes("remove"), false) + assert.equal(calls.filter((call) => call === "stop").length, 1) + assert.equal(calls.filter((call) => call === "release").length, 1) +}) + +test("Windows query preflush leaves the app alive until session end is confirmed", async () => { + const calls: string[] = [] + const first = windowRecord("one", calls) + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => true, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: 20, + }) + lifecycle.attach(first) + let vetoed = false + first.events.get("query-session-end")?.({ preventDefault: () => { vetoed = true } }) + await (lifecycle as any).sessionEndPreparation + assert.equal(vetoed, false) + assert.deepEqual(calls, ["renderer:one", "native:one"]) + + first.events.get("session-end")?.() + await (lifecycle as any).sessionEnd + assert.deepEqual(calls, ["renderer:one", "native:one", "aggregate", "stop", "release", "exit"]) +}) + +test("remote windows receive session-end cleanup without local close semantics", async () => { + const calls: string[] = [] + const events = new Map() + const remote = { on: (name: string, handler: Function) => events.set(name, handler), isDestroyed: () => false } + const lifecycle = new MultiwindowLifecycle({ + app: { on: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [], getAllWindows: () => [remote as never], removeWindowState: async () => { calls.push("remove"); return true }, + getAllowedRendererOrigins: () => [], isTrustedRendererOrigin: () => false, isWindows: true, + }) + + lifecycle.attachSessionEnd(remote as never) + let vetoed = false + events.get("query-session-end")?.({ preventDefault: () => { vetoed = true } }) + await (lifecycle as any).sessionEndPreparation + assert.equal(vetoed, false) + assert.deepEqual(calls, []) + events.get("session-end")?.() + await (lifecycle as any).sessionEnd + assert.deepEqual(calls, ["aggregate", "stop", "release", "exit"]) +}) + +test("normal quit reports a failed CLI shutdown without allowing exit", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => { throw new Error("CLI failed") } } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => true, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.registerAppEvents() + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent") }) + await tick(); await tick() + assert.equal(calls.includes("prevent"), true) + assert.equal(calls.includes("exit"), false) + assert.equal(calls.includes("release"), false) + assert.ok(calls.indexOf("show:one") > calls.indexOf("hide:one")) +}) + +test("normal quit restores windows when renderer persistence fails", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + first.window.webContents.executeJavaScript = async () => { calls.push("renderer"); throw new Error("snapshot too large") } + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => calls.push("stop") } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => true, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.registerAppEvents() + + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent") }) + await tick(); await tick() + assert.deepEqual(calls, ["prevent", "hide:one", "renderer", "show:one"]) +}) + +test("final close restores its window after failed shutdown and allows one deduped retry", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + let attempts = 0 + let rejectFirst!: (error: Error) => void + let resolveSecond!: () => void + const firstAttempt = new Promise((_resolve, reject) => { rejectFirst = reject }) + const secondAttempt = new Promise((resolve) => { resolveSecond = resolve }) + const app = { + on: (name: string, handler: Function) => events.set(name, handler), + quit: () => { + calls.push(`quit-visible:${first.window.isVisible()}`) + events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) + }, + exit: () => calls.push("exit"), + } + const lifecycle = new MultiwindowLifecycle({ + app: app as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: () => { attempts += 1; calls.push(`stop:${attempts}`); return attempts === 1 ? firstAttempt : secondAttempt } } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => true, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.attach(first); lifecycle.registerAppEvents() + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) + await tick() + assert.equal(attempts, 1) + assert.equal(calls.filter((call) => call === "quit-visible:true").length, 1) + rejectFirst(new Error("CLI failed")) + await tick() + assert.ok(calls.indexOf("show:one") > calls.indexOf("hide:one")) + + first.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) + first.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) + await tick() + assert.equal(attempts, 2) + assert.equal(calls.filter((call) => call === "hide:one").length, 2) + resolveSecond() + await tick(); await tick() + assert.equal(calls.filter((call) => call === "release").length, 1) + assert.equal(calls.filter((call) => call === "exit").length, 1) +}) + +test("final remote close stays restorable by routing through app shutdown", async () => { + const calls: string[] = [] + const events = new Map() + const remote = windowRecord("remote", calls) + const app = { + on: (name: string, handler: Function) => events.set(name, handler), + quit: () => { calls.push("quit"); events.get("before-quit")?.({ preventDefault: () => calls.push("prevent-quit") }) }, + exit: () => calls.push("exit"), + } + const lifecycle = new MultiwindowLifecycle({ + app: app as never, + clientStateManager: { isPrimary: true, flush: async () => calls.push("aggregate"), drainAndReleasePrimary: async () => calls.push("release") } as never, + cliManager: { shutdown: async () => { calls.push("stop"); throw new Error("CLI failed") } } as never, + getLocalWindows: () => [], getAllWindows: () => [remote.window], removeWindowState: async () => true, + getAllowedRendererOrigins: () => [], isTrustedRendererOrigin: () => false, + }) + lifecycle.attachRemote(remote.window); lifecycle.registerAppEvents() + + remote.events.get("close")?.({ preventDefault: () => calls.push("prevent-close") }) + await tick(); await tick() + assert.deepEqual(calls, ["prevent-close", "quit", "prevent-quit", "hide:remote", "aggregate", "stop", "show:remote"]) +}) + +test("failed primary release is retried by the next shutdown attempt", async () => { + const calls: string[] = [] + const events = new Map() + const first = windowRecord("one", calls) + let releases = 0 + const lifecycle = new MultiwindowLifecycle({ + app: { on: (name: string, handler: Function) => events.set(name, handler), quit: () => {}, exit: () => calls.push("exit") } as never, + clientStateManager: { + isPrimary: true, + flush: async () => {}, + drainAndReleasePrimary: async () => { + releases += 1 + if (releases === 1) throw new Error("release failed") + }, + } as never, + cliManager: { + shutdown: async () => { calls.push("stop") }, + recoverAfterFailedShutdown: async () => { calls.push("recover") }, + } as never, + getLocalWindows: () => [first], getAllWindows: () => [first.window], removeWindowState: async () => true, + getAllowedRendererOrigins: () => ["http://localhost"], isTrustedRendererOrigin: () => true, + }) + lifecycle.registerAppEvents() + + events.get("before-quit")?.({ preventDefault: () => {} }) + await tick(); await tick() + assert.equal(releases, 1) + assert.equal(calls.includes("exit"), false) + assert.deepEqual(calls, ["hide:one", "renderer:one", "native:one", "stop", "recover", "show:one"]) + + events.get("before-quit")?.({ preventDefault: () => {} }) + await tick(); await tick() + assert.equal(releases, 2) + assert.equal(calls.filter((call) => call === "exit").length, 1) + assert.deepEqual(calls, [ + "hide:one", "renderer:one", "native:one", "stop", "recover", "show:one", + "hide:one", "renderer:one", "native:one", "stop", "exit", + ]) +}) diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.ts new file mode 100644 index 000000000..7b3fccf47 --- /dev/null +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.ts @@ -0,0 +1,179 @@ +import type { App, BrowserWindow } from "electron" +import type { ClientStateManager } from "./client-state" +import type { CliProcessManager } from "./process-manager" +import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" +import type { SerializedLifecycle } from "./serialized-lifecycle" +import type { WindowStateTracker } from "./window-state" + +export interface LifecycleWindow { + id: string + persisted?: boolean + window: BrowserWindow + tracker: WindowStateTracker | null +} + +interface Dependencies { + app: App + clientStateManager: ClientStateManager + cliManager: CliProcessManager + getLocalWindows(): LifecycleWindow[] + getAllWindows(): BrowserWindow[] + removeWindowState(id: string): Promise + getAllowedRendererOrigins(window: BrowserWindow): string[] + isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean + rendererFlushTimeoutMs?: number + sessionEndCleanupTimeoutMs?: number + isWindows?: boolean + navigationLifecycle?: SerializedLifecycle +} + +export class MultiwindowLifecycle { + private shutdown: Promise | null = null + private sessionEnd: Promise | null = null + private sessionEndPreparation: Promise | null = null + private sessionEndPreparationPending = false + private release: Promise | null = null + private exitAllowed = false + private readonly sessionEndWindows = new WeakSet() + + constructor(private readonly dependencies: Dependencies) {} + + attach(record: LifecycleWindow): void { + let approved = false + let closing = false + record.window.on("close", (event) => { + if (approved || this.exitAllowed) return + event.preventDefault() + if (closing || this.shutdown) return + const otherLocal = this.dependencies.getLocalWindows().some((candidate) => candidate.id !== record.id && !candidate.window.isDestroyed()) + const otherWindow = this.dependencies.getAllWindows().some((candidate) => candidate !== record.window && !candidate.isDestroyed()) + if (!otherLocal && !otherWindow) { + this.dependencies.app.quit() + return + } + closing = true + void this.flushWindow(record).then(async () => { + if (record.persisted !== false && !await this.dependencies.removeWindowState(record.id)) { + closing = false + return + } + approved = true + record.window.close() + }).catch((error) => { + closing = false + console.warn("[client-state] local window close failed", error) + }) + }) + + this.attachSessionEnd(record.window) + } + + attachRemote(window: BrowserWindow): void { + window.on("close", (event) => { + if (this.exitAllowed || this.dependencies.getAllWindows().some((candidate) => candidate !== window && !candidate.isDestroyed())) return + event.preventDefault() + if (!this.shutdown) this.dependencies.app.quit() + }) + this.attachSessionEnd(window) + } + + attachSessionEnd(window: BrowserWindow): void { + if (!(this.dependencies.isWindows ?? process.platform === "win32") || this.sessionEndWindows.has(window)) return + this.sessionEndWindows.add(window) + window.on("query-session-end", () => this.prepareSessionEnd()) + window.on("session-end", () => this.startSessionEnd()) + } + + registerAppEvents(): void { + this.dependencies.app.on("before-quit", (event) => { + if (this.exitAllowed) return + event.preventDefault() + const visibleWindows = this.dependencies.getAllWindows().filter((window) => !window.isDestroyed() && window.isVisible()) + for (const window of visibleWindows) window.hide() + void this.startShutdown().then(() => this.exit(), (error) => { + for (const window of visibleWindows) if (!window.isDestroyed()) window.show() + console.warn("[client-state] shutdown remains pending", error) + }) + }) + this.dependencies.app.on("window-all-closed", () => this.dependencies.app.quit()) + } + + private startShutdown(preparedFlush?: Promise): Promise { + if (this.shutdown) return this.shutdown + const cleanup = async () => { + await (preparedFlush ?? this.flushLocalWindows()) + await this.run("aggregate state flush", () => this.dependencies.clientStateManager.flush()) + await this.dependencies.cliManager.shutdown() + try { + await this.releasePrimary() + } catch (error) { + await this.run("CLI recovery", () => this.dependencies.cliManager.recoverAfterFailedShutdown({ + dev: process.env.NODE_ENV === "development", + })) + throw error + } + } + const shutdown = this.dependencies.navigationLifecycle?.stop(cleanup) ?? cleanup() + this.shutdown = shutdown + void shutdown.catch(() => { + if (this.shutdown === shutdown) this.shutdown = null + }) + return shutdown + } + + private flushLocalWindows(): Promise { + return Promise.all(this.dependencies.getLocalWindows().map((record) => this.flushWindow(record))).then(() => undefined) + } + + private prepareSessionEnd(): void { + if (this.exitAllowed || this.sessionEnd || this.shutdown || this.sessionEndPreparationPending) return + this.sessionEndPreparationPending = true + const preparation = this.flushLocalWindows() + this.sessionEndPreparation = preparation + void preparation.finally(() => { + if (this.sessionEndPreparation === preparation) this.sessionEndPreparationPending = false + }) + } + + private startSessionEnd(): void { + if (this.exitAllowed || this.sessionEnd) return + const timeoutMs = this.dependencies.sessionEndCleanupTimeoutMs ?? 5_000 + const cleanup = this.shutdown ?? this.startShutdown(this.sessionEndPreparation ?? this.flushLocalWindows()) + this.sessionEnd = Promise.race([ + cleanup, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]).catch((error) => { + console.warn("[client-state] OS session-end shutdown failed; exiting at the fail-open boundary", error) + }).then(() => this.exit()) + } + + private async flushWindow(record: LifecycleWindow): Promise { + await flushRendererClientStateBeforeShutdown( + record.window, + this.dependencies.clientStateManager.isPrimary, + (url) => this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(record.window)), + this.dependencies.rendererFlushTimeoutMs, + ) + if (record.tracker) await this.run("native window flush", () => record.tracker!.flush()) + } + + private releasePrimary(): Promise { + if (this.release) return this.release + const release = this.dependencies.clientStateManager.drainAndReleasePrimary() + this.release = release + void release.catch(() => { + if (this.release === release) this.release = null + }) + return release + } + + private async run(name: string, operation: () => Promise): Promise { + try { await operation() } catch (error) { console.warn(`[client-state] ${name} failed; continuing`, error) } + } + + private exit(): void { + if (this.exitAllowed) return + this.exitAllowed = true + this.dependencies.app.exit(0) + } +} diff --git a/packages/electron-app/electron/main/navigation-security.test.ts b/packages/electron-app/electron/main/navigation-security.test.ts new file mode 100644 index 000000000..d06964ab4 --- /dev/null +++ b/packages/electron-app/electron/main/navigation-security.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { decideNavigation, requireHttpUrl } from "./navigation-security" + +test("remote window URLs require HTTP or HTTPS", () => { + assert.equal(requireHttpUrl("http://localhost:3000/app", "baseUrl").protocol, "http:") + assert.equal(requireHttpUrl("https://example.com/app", "entryUrl").protocol, "https:") + for (const url of ["file:///tmp/index.html", "data:text/html,hello", "javascript:alert(1)"]) { + assert.throws(() => requireHttpUrl(url, "baseUrl"), /must use HTTP or HTTPS/) + } +}) + +test("navigation allows registered origins and only the exact loading file", () => { + const loading = "file:///opt/codenomad/loading.html" + const origins = ["https://renderer.example"] + assert.equal(decideNavigation(loading, origins, loading), "allow") + assert.equal(decideNavigation("file:///opt/codenomad/index.html", origins, loading), "deny") + assert.equal(decideNavigation("https://renderer.example/workspace", origins, loading), "allow") + assert.equal(decideNavigation("https://outside.example/", origins, loading), "external") + assert.equal(decideNavigation("mailto:hello@example.com", origins, loading), "external") + assert.equal(decideNavigation("vscode://file/C:/workspace", origins, loading), "deny") + assert.equal(decideNavigation("not a URL", origins, loading), "deny") + assert.equal(decideNavigation("http://localhost:5173/loading.html", [], "http://localhost:5173/loading.html"), "allow") +}) diff --git a/packages/electron-app/electron/main/navigation-security.ts b/packages/electron-app/electron/main/navigation-security.ts new file mode 100644 index 000000000..175c9d8c5 --- /dev/null +++ b/packages/electron-app/electron/main/navigation-security.ts @@ -0,0 +1,25 @@ +export type NavigationDecision = "allow" | "external" | "deny" + +export function requireHttpUrl(value: string, name: string): URL { + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must use HTTP or HTTPS`) + return url +} + +export function decideNavigation( + value: string, + allowedOrigins: readonly string[], + loadingUrl: string, +): NavigationDecision { + let url: URL + try { url = new URL(value) } catch { return "deny" } + + try { + if (url.toString() === new URL(loadingUrl).toString()) return "allow" + } catch {} + + if (url.protocol === "http:" || url.protocol === "https:") { + return allowedOrigins.includes(url.origin) ? "allow" : "external" + } + return url.protocol === "mailto:" ? "external" : "deny" +} diff --git a/packages/electron-app/electron/main/permissions.ts b/packages/electron-app/electron/main/permissions.ts index 5e1bf4f88..4a785621c 100644 --- a/packages/electron-app/electron/main/permissions.ts +++ b/packages/electron-app/electron/main/permissions.ts @@ -1,11 +1,11 @@ -import { session, systemPreferences } from "electron" +import { session, systemPreferences, type Session } from "electron" import { isAllowedRendererOrigin } from "./renderer-origin" export { isAllowedRendererOrigin } from "./renderer-origin" const isMac = process.platform === "darwin" -export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { +export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[], targetSession: Session = session.defaultSession) { const isAudioMediaRequest = (permission: string, details?: unknown) => { if (permission !== "media") { return false @@ -15,7 +15,7 @@ export function configureMediaPermissionHandlers(getAllowedOrigins: () => string return mediaTypes.length === 0 || mediaTypes.includes("audio") } - session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + targetSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { if (!isAudioMediaRequest(permission, details)) { return false } @@ -23,7 +23,7 @@ export function configureMediaPermissionHandlers(getAllowedOrigins: () => string return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()) }) - session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => { if (!isAudioMediaRequest(permission, details)) { callback(false) return diff --git a/packages/electron-app/electron/main/process-exit.test.ts b/packages/electron-app/electron/main/process-exit.test.ts new file mode 100644 index 000000000..3adffe157 --- /dev/null +++ b/packages/electron-app/electron/main/process-exit.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { resolveManagedProcessExit, shouldReportManagedProcessError } from "./process-exit" + +test("requested and invalidated process exits do not become failures", () => { + assert.deepEqual(resolveManagedProcessExit(undefined, 0, null, true, true), { state: "stopped" }) + assert.equal(resolveManagedProcessExit(undefined, 1, null, false, false), null) +}) + +test("unexpected exits report their code or signal", () => { + assert.deepEqual(resolveManagedProcessExit(undefined, 0, null, false, true), { + state: "error", + error: "CLI exited unexpectedly (code 0)", + }) + assert.match(resolveManagedProcessExit(undefined, null, "SIGTERM", false, true)?.error ?? "", /signal SIGTERM/) + assert.equal(resolveManagedProcessExit("startup failed", 1, null, false, true)?.error, "startup failed") +}) + +test("child errors are reported only for the current process outside a requested stop", () => { + assert.equal(shouldReportManagedProcessError(false, true), true) + assert.equal(shouldReportManagedProcessError(true, true), false) + assert.equal(shouldReportManagedProcessError(false, false), false) +}) diff --git a/packages/electron-app/electron/main/process-exit.ts b/packages/electron-app/electron/main/process-exit.ts new file mode 100644 index 000000000..6b3f4db1e --- /dev/null +++ b/packages/electron-app/electron/main/process-exit.ts @@ -0,0 +1,21 @@ +export interface ManagedProcessExit { + state: "error" | "stopped" + error?: string +} + +export function shouldReportManagedProcessError(requestedStop: boolean, currentGeneration: boolean): boolean { + return currentGeneration && !requestedStop +} + +export function resolveManagedProcessExit( + currentError: string | undefined, + code: number | null, + signal: NodeJS.Signals | null, + requestedStop: boolean, + currentGeneration: boolean, +): ManagedProcessExit | null { + if (!currentGeneration) return null + if (requestedStop) return { state: "stopped" } + const details = [code === null ? null : `code ${code}`, signal ? `signal ${signal}` : null].filter(Boolean).join(", ") + return { state: "error", error: currentError ?? `CLI exited unexpectedly (${details || "unknown status"})` } +} diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 090de0d11..85103919c 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -18,6 +18,7 @@ import { stopManagedChild, } from "./process-stop" import { SerializedLifecycle } from "./serialized-lifecycle" +import { resolveManagedProcessExit, shouldReportManagedProcessError } from "./process-exit" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) @@ -143,6 +144,7 @@ export class CliProcessManager extends EventEmitter { private bootstrapToken: string | null = null private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` private requestedStop = false + private cancelPendingStart?: (error: Error) => void private shutdownStatus: "complete" | "incomplete" | null = null private lifecycle = new SerializedLifecycle() @@ -163,7 +165,14 @@ export class CliProcessManager extends EventEmitter { } shutdown(): Promise { - return this.lifecycle.stop(() => this.stopNow()) + return this.lifecycle.stop(() => this.stopNow(), () => { + this.requestedStop = true + this.cancelPendingStart?.(new Error("CLI startup interrupted by shutdown")) + }) + } + + recoverAfterFailedShutdown(options: StartOptions): Promise { + return this.lifecycle.resume(() => this.startNow(options)) } private async startNow(options: StartOptions): Promise { @@ -171,6 +180,7 @@ export class CliProcessManager extends EventEmitter { if (this.child) { await this.stopNow() if (this.child) throw new Error("CLI process did not exit before restart") + if (this.lifecycle.stopped) throw new Error("CLI startup interrupted by shutdown") } this.stdoutBuffer = "" @@ -185,7 +195,8 @@ export class CliProcessManager extends EventEmitter { const listeningMode = this.resolveListeningMode() const host = resolveHostForMode(listeningMode) const args = this.buildCliArgs(options, host) - const cliEntry = await this.resolveCliEntry(options) + const cliEntry = await this.awaitStartupStep(this.resolveCliEntry(options)) + if (this.lifecycle.stopped) throw new Error("CLI startup interrupted by shutdown") console.info( `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, @@ -219,25 +230,29 @@ export class CliProcessManager extends EventEmitter { const stderr = child.stderr as NodeJS.ReadableStream | undefined stdout?.on("data", (data: Buffer) => { + if (this.child !== child) return this.handleStream(data.toString(), "stdout") }) stderr?.on("data", (data: Buffer) => { + if (this.child !== child) return this.handleStream(data.toString(), "stderr") }) child.on("error", (error) => { + if (!shouldReportManagedProcessError(this.requestedStop, this.child === child)) return console.error("[cli] failed to start CLI:", error) this.updateStatus({ state: "error", error: error.message }) this.emit("error", error) }) child.on("exit", (code, signal) => { - if (this.child !== child) return - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined + const exit = resolveManagedProcessExit(this.status.error, code, signal, this.requestedStop, this.child === child) + if (!exit) return + const failed = exit.state === "error" + const error = exit.error console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) + this.updateStatus({ state: exit.state, error }) if (failed && error) { this.emit("error", new Error(error)) } @@ -249,18 +264,22 @@ export class CliProcessManager extends EventEmitter { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.handleTimeout() - reject(new Error("CLI startup timeout")) + finish(reject, new Error("CLI startup timeout")) }, 60000) - this.once("ready", (status) => { + const finish = (settle: (value: T) => void, value: T) => { clearTimeout(timeout) - resolve(status) - }) - - this.once("error", (error) => { - clearTimeout(timeout) - reject(error) - }) + this.off("ready", onReady) + this.off("error", onError) + if (this.cancelPendingStart === cancel) this.cancelPendingStart = undefined + settle(value) + } + const onReady = (status: CliStatus) => finish(resolve, status) + const onError = (error: Error) => finish(reject, error) + const cancel = (error: Error) => finish(reject, error) + this.cancelPendingStart = cancel + this.once("ready", onReady) + this.once("error", onError) }) } @@ -352,6 +371,22 @@ export class CliProcessManager extends EventEmitter { return this.authCookieName } + private awaitStartupStep(operation: Promise): Promise { + return new Promise((resolve, reject) => { + const finish = () => { + if (this.cancelPendingStart !== cancel) return false + this.cancelPendingStart = undefined + return true + } + const cancel = (error: Error) => { if (finish()) reject(error) } + this.cancelPendingStart = cancel + operation.then( + (value) => { if (finish()) resolve(value) }, + (error) => { if (finish()) reject(error) }, + ) + }) + } + private resolveListeningMode(): ListeningMode { return readListeningModeFromConfig() } diff --git a/packages/electron-app/electron/main/process-stop.test.ts b/packages/electron-app/electron/main/process-stop.test.ts index 898a79c38..500fc9eb6 100644 --- a/packages/electron-app/electron/main/process-stop.test.ts +++ b/packages/electron-app/electron/main/process-stop.test.ts @@ -514,7 +514,7 @@ test("signal dispatch is not confirmation while the captured identity remains", assert.equal(await forceCapturedProcessTree(tree, () => "owned", undefined, kill), false) }) -test("incomplete shutdown status remains terminal", async () => { +test("process manager keeps incomplete shutdown terminal and interrupts a pending startup", async () => { const hooks = registerHooks({ resolve(specifier, context, nextResolve) { if (specifier === "electron") { @@ -536,6 +536,14 @@ test("incomplete shutdown status remains terminal", async () => { assert.equal((manager as any).shutdownStatus, "incomplete") assert.equal(enforcements, 1) + + const pending = new CliProcessManager() + ;(pending as any).resolveCliEntry = () => new Promise(() => {}) + const startup = pending.start({ dev: false }) + await new Promise((resolve) => setImmediate(resolve)) + const shutdown = pending.shutdown() + await assert.rejects(startup, /startup interrupted by shutdown/) + await shutdown } finally { hooks.deregister() } diff --git a/packages/electron-app/electron/main/remote-window-registry.test.ts b/packages/electron-app/electron/main/remote-window-registry.test.ts new file mode 100644 index 000000000..dc1488c6d --- /dev/null +++ b/packages/electron-app/electron/main/remote-window-registry.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { BrowserWindow } from "electron" +import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry" + +function window() { + const events = new Map void>() + const calls: string[] = [] + return { + calls, + events, + value: { + isDestroyed: () => false, + isMinimized: () => false, + restore: () => calls.push("restore"), + show: () => calls.push("show"), + focus: () => calls.push("focus"), + close: () => { calls.push("close"); events.get("close")?.() }, + destroy: () => calls.push("destroy"), + on: (name: string, callback: () => void) => events.set(name, callback), + } as unknown as BrowserWindow, + } +} + +test("remote profiles reuse one window and preserve direct profile sessions", () => { + const cleaned: string[] = [] + const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) + const direct = window() + registry.register("profile", direct.value) + assert.equal(registry.reuse("profile"), direct.value) + assert.deepEqual(direct.calls, ["show", "focus"]) + direct.events.get("closed")?.() + assert.deepEqual(cleaned, []) +}) + +test("proxy replacement destroys the old window without triggering close interception", () => { + const cleaned: string[] = [] + const registry = new RemoteWindowRegistry((id) => cleaned.push(id)) + const first = window() + first.events.set("close", () => first.calls.push("quit")) + registry.register("profile", first.value, "proxy-one") + assert.equal(registry.reuse("profile", "proxy-two"), undefined) + assert.deepEqual(first.calls, ["destroy"]) + assert.deepEqual(cleaned, ["proxy-one"]) + const second = window() + registry.register("profile", second.value, "proxy-two") + first.events.get("closed")?.() + second.events.get("closed")?.() + assert.deepEqual(cleaned, ["proxy-one", "proxy-two"]) +}) + +test("reused remote navigation trusts old and next redirect origins until success", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = async () => { + assert.deepEqual([...trusted.get(1)!], ["https://old.example", "https://new.example", "https://redirect.example"]) + } + + const next = new Set(["https://new.example", "https://redirect.example"]) + await navigateRemoteWindow(remote.value, new URL("https://new.example/app"), next, trusted, insecure, false) + assert.deepEqual([...trusted.get(1)!], [...next]) + assert.equal(insecure.has(2), false) +}) + +test("failed reused remote navigation restores trusted and insecure origins", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = async () => { + assert.deepEqual([...trusted.get(1)!], ["https://old.example", "https://new.example"]) + assert.deepEqual([...insecure.get(2)!], ["https://old.example", "https://new.example"]) + throw new Error("failed") + } + + const next = new Set(["https://new.example"]) + await assert.rejects(navigateRemoteWindow(remote.value, new URL("https://new.example/app"), next, trusted, insecure, true), /failed/) + assert.deepEqual([...trusted.get(1)!], ["https://old.example"]) + assert.deepEqual([...insecure.get(2)!], ["https://old.example"]) +}) + +test("stale remote navigation failure cannot replace newer committed authority", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + const loads: Array<{ resolve: () => void; reject: (error: Error) => void }> = [] + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = () => new Promise((resolve, reject) => loads.push({ resolve, reject })) + + const stale = navigateRemoteWindow(remote.value, new URL("https://stale.example"), new Set(["https://stale.example"]), trusted, insecure, true) + const current = navigateRemoteWindow(remote.value, new URL("https://current.example"), new Set(["https://current.example"]), trusted, insecure, false) + loads[1]!.resolve() + await current + loads[0]!.reject(new Error("stale failed")) + await stale + + assert.deepEqual([...trusted.get(1)!], ["https://current.example"]) + assert.equal(insecure.has(2), false) +}) + +test("stale remote navigation success cannot replace authority restored by a newer failure", async () => { + const remote = window() + const trusted = new Map([[1, new Set(["https://old.example"])]]) + const insecure = new Map([[2, new Set(["https://old.example"])]]) + const loads: Array<{ resolve: () => void; reject: (error: Error) => void }> = [] + Object.assign(remote.value, { id: 1, webContents: { id: 2 } }) + remote.value.loadURL = () => new Promise((resolve, reject) => loads.push({ resolve, reject })) + + const stale = navigateRemoteWindow(remote.value, new URL("https://stale.example"), new Set(["https://stale.example"]), trusted, insecure, false) + const current = navigateRemoteWindow(remote.value, new URL("https://current.example"), new Set(["https://current.example"]), trusted, insecure, true) + loads[1]!.reject(new Error("current failed")) + await assert.rejects(current, /current failed/) + loads[0]!.resolve() + await stale + + assert.deepEqual([...trusted.get(1)!], ["https://old.example"]) + assert.deepEqual([...insecure.get(2)!], ["https://old.example"]) +}) + +test("overlapping remote opens wait for the prior loadURL fallback for the same profile", async () => { + const registry = new RemoteWindowRegistry(() => {}) + const calls: string[] = [] + let releaseFallback!: () => void + const fallback = new Promise((resolve) => { releaseFallback = resolve }) + const first = registry.serialize("profile", async () => { + calls.push("first-loadURL") + try { throw new Error("load failed") } catch { calls.push("first-fallback"); await fallback } + calls.push("first-done") + }) + const second = registry.serialize("profile", async () => { calls.push("second-loadURL") }) + + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["first-loadURL", "first-fallback"]) + releaseFallback() + await Promise.all([first, second]) + assert.deepEqual(calls, ["first-loadURL", "first-fallback", "first-done", "second-loadURL"]) +}) diff --git a/packages/electron-app/electron/main/remote-window-registry.ts b/packages/electron-app/electron/main/remote-window-registry.ts new file mode 100644 index 000000000..1b129d292 --- /dev/null +++ b/packages/electron-app/electron/main/remote-window-registry.ts @@ -0,0 +1,99 @@ +import type { BrowserWindow } from "electron" + +interface RemoteWindowRecord { + window: BrowserWindow + proxySessionId?: string +} + +export class RemoteWindowRegistry { + private readonly records = new Map() + private readonly operations = new Map>() + + constructor(private readonly cleanupProxySession: (sessionId: string) => void) {} + + serialize(profileId: string, operation: () => Promise): Promise { + const previous = this.operations.get(profileId) ?? Promise.resolve() + const result = previous.catch(() => {}).then(operation) + const tail = result.then(() => {}, () => {}) + this.operations.set(profileId, tail) + void tail.then(() => { + if (this.operations.get(profileId) === tail) this.operations.delete(profileId) + }) + return result + } + + reuse(profileId: string, proxySessionId?: string): BrowserWindow | undefined { + const record = this.records.get(profileId) + if (!record || record.window.isDestroyed()) return undefined + if (record.proxySessionId !== proxySessionId) { + this.records.delete(profileId) + record.window.destroy() + if (record.proxySessionId) this.cleanupProxySession(record.proxySessionId) + return undefined + } + if (record.window.isMinimized()) record.window.restore() + record.window.show() + record.window.focus() + return record.window + } + + register(profileId: string, window: BrowserWindow, proxySessionId?: string): void { + const record = { window, proxySessionId } + this.records.set(profileId, record) + window.on("closed", () => { + if (this.records.get(profileId) !== record) return + this.records.delete(profileId) + if (proxySessionId) this.cleanupProxySession(proxySessionId) + }) + } +} + +interface RemoteNavigationAuthority { + generation: number + trustedOrigins: Set + insecureOrigins: Set +} + +const navigationAuthorities = new WeakMap() + +export async function navigateRemoteWindow( + window: BrowserWindow, + target: URL, + nextOrigins: ReadonlySet, + trustedOrigins: Map>, + insecureOrigins: Map>, + skipTlsVerify: boolean, +): Promise { + let authority = navigationAuthorities.get(window) + if (!authority) { + authority = { + generation: 0, + trustedOrigins: new Set(trustedOrigins.get(window.id)), + insecureOrigins: new Set(insecureOrigins.get(window.webContents.id)), + } + navigationAuthorities.set(window, authority) + } + const generation = ++authority.generation + const committedOrigins = new Set(nextOrigins) + trustedOrigins.set(window.id, new Set([...authority.trustedOrigins, ...committedOrigins])) + const provisionalInsecure = new Set(authority.insecureOrigins) + if (skipTlsVerify) for (const origin of committedOrigins) provisionalInsecure.add(origin) + if (provisionalInsecure.size) insecureOrigins.set(window.webContents.id, provisionalInsecure) + else insecureOrigins.delete(window.webContents.id) + + try { await window.loadURL(target.toString()) } catch (error) { + if (authority.generation !== generation) return + if (authority.trustedOrigins.size) trustedOrigins.set(window.id, new Set(authority.trustedOrigins)) + else trustedOrigins.delete(window.id) + if (authority.insecureOrigins.size) insecureOrigins.set(window.webContents.id, new Set(authority.insecureOrigins)) + else insecureOrigins.delete(window.webContents.id) + throw error + } + + if (authority.generation !== generation) return + authority.trustedOrigins = committedOrigins + authority.insecureOrigins = skipTlsVerify ? new Set(committedOrigins) : new Set() + trustedOrigins.set(window.id, committedOrigins) + if (authority.insecureOrigins.size) insecureOrigins.set(window.webContents.id, new Set(authority.insecureOrigins)) + else insecureOrigins.delete(window.webContents.id) +} diff --git a/packages/electron-app/electron/main/serialized-lifecycle.test.ts b/packages/electron-app/electron/main/serialized-lifecycle.test.ts index 24e7422fe..aacd97447 100644 --- a/packages/electron-app/electron/main/serialized-lifecycle.test.ts +++ b/packages/electron-app/electron/main/serialized-lifecycle.test.ts @@ -36,3 +36,16 @@ test("failed shutdown reopens the lifecycle before queued retries run", async () assert.equal(await retry, "restarted") assert.equal(lifecycle.stopped, false) }) + +test("shutdown can interrupt pending work before entering the serialized stop", async () => { + const lifecycle = new SerializedLifecycle() + let interrupt!: () => void + const startup = lifecycle.enqueue(() => new Promise((_resolve, reject) => { + interrupt = () => reject(new Error("startup interrupted")) + })) + await new Promise((resolve) => setImmediate(resolve)) + const shutdown = lifecycle.stop(async () => {}, () => interrupt()) + + await assert.rejects(startup, /startup interrupted/) + await shutdown +}) diff --git a/packages/electron-app/electron/main/serialized-lifecycle.ts b/packages/electron-app/electron/main/serialized-lifecycle.ts index 1f72f5a78..bd175d982 100644 --- a/packages/electron-app/electron/main/serialized-lifecycle.ts +++ b/packages/electron-app/electron/main/serialized-lifecycle.ts @@ -8,8 +8,9 @@ export class SerializedLifecycle { return queued } - stop(operation: () => Promise): Promise { + stop(operation: () => Promise, interrupt?: () => void): Promise { this.stopped = true + interrupt?.() return this.enqueue(async () => { try { return await operation() @@ -19,4 +20,9 @@ export class SerializedLifecycle { } }) } + + resume(operation: () => Promise): Promise { + this.stopped = false + return this.enqueue(operation) + } } diff --git a/packages/electron-app/electron/main/startup.test.ts b/packages/electron-app/electron/main/startup.test.ts new file mode 100644 index 000000000..bedbe13a5 --- /dev/null +++ b/packages/electron-app/electron/main/startup.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict" +import { mkdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { allocateLocalWindowIdentity, BackendBootstrapCoordinator, createLaunchIntentQueue, isRemoteCertificateAllowed, parseLaunchIntent, resolveRemoteSessionPartition, resolveStorageScope, resolveUpdateChannel, startPrimaryInstance } from "./startup" + +test("update channel honors the environment, forces unpackaged dev, and only infers packaged versions", () => { + assert.equal(resolveUpdateChannel("Beta", "1.0.0-dev.2", false), "beta") + assert.equal(resolveUpdateChannel(undefined, "1.0.0", false), "dev") + assert.equal(resolveUpdateChannel(undefined, "1.0.0-dev-2", true), "dev") + assert.equal(resolveUpdateChannel(undefined, "1.0.0", true), "stable") +}) + +test("stable default storage preserves paths while dev and alternate configs are scoped", () => { + const base = join(tmpdir(), "codenomad-startup-base") + const stable = resolveStorageScope({ appVersion: "1.0.0", cwd: base, baseUserDataPath: base, packaged: true }) + assert.equal(stable.userDataPath, base) + assert.equal(stable.sessionDataPath, join(base, "session-data-v2")) + assert.equal(stable.clientStateElectionDirectory, undefined) + const dev = resolveStorageScope({ appVersion: "1.0.0-dev.1", cwd: base, baseUserDataPath: base, packaged: true }) + const alternate = resolveStorageScope({ appVersion: "1.0.0", cliConfig: "other/config.json", cwd: base, baseUserDataPath: base, packaged: true }) + assert.match(dev.userDataPath, /scopes[\\/]dev-[0-9a-f]{16}$/) + assert.equal(dev.clientStateElectionDirectory, join(dev.userDataPath, "client-state", "election")) + assert.match(alternate.userDataPath, /scopes[\\/]stable-[0-9a-f]{16}$/) + assert.equal(alternate.clientStateElectionDirectory, join(alternate.userDataPath, "client-state", "election")) + assert.equal(resolveStorageScope({ appVersion: "1.0.0", cliConfig: "other/config.yaml", cwd: base, baseUserDataPath: base, packaged: true }).userDataPath, alternate.userDataPath) +}) + +test("remote profiles use isolated persistent partitions and TLS exceptions stay with their webContents", () => { + const first = resolveRemoteSessionPartition("profile-a") + assert.match(first, /^persist:codenomad-remote-[0-9a-f]{24}$/) + assert.equal(resolveRemoteSessionPartition("profile-a"), first) + assert.notEqual(resolveRemoteSessionPartition("profile-b"), first) + assert.match(resolveRemoteSessionPartition("profile-a", "proxy-1"), /^codenomad-remote-/) + const allowlists = new Map([[7, new Set(["https://unsafe.example"])], [8, new Set(["https://other.example"])]] as const) + assert.equal(isRemoteCertificateAllowed(7, "https://unsafe.example/path", allowlists), true) + assert.equal(isRemoteCertificateAllowed(8, "https://unsafe.example/path", allowlists), false) +}) + +test("new local windows reuse retained records and otherwise fall back to ephemeral identities", async () => { + let additions = 0 + assert.deepEqual(await allocateLocalWindowIdentity(["retained"], () => false, async () => { additions++; return "new" }), { id: "retained", persisted: true }) + assert.equal(additions, 0) + assert.deepEqual(await allocateLocalWindowIdentity([], () => false, async () => null, undefined, () => "ephemeral"), { id: "ephemeral", persisted: false }) + assert.deepEqual(await allocateLocalWindowIdentity([], () => false, async () => { throw new Error("frozen") }, () => {}, () => "fallback"), { id: "fallback", persisted: false }) +}) + +test("launch intents wait for readiness and remain serialized across async window creation", async () => { + const calls: string[] = [] + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { releaseFirst = resolve }) + const queue = createLaunchIntentQueue(async (intent) => { + calls.push(`start:${intent.folders[0]}`) + if (intent.folders[0] === "first") await firstGate + calls.push(`end:${intent.folders[0]}`) + }, (error) => assert.fail(String(error))) + const first = queue.enqueue({ newWindow: true, folders: ["first"] }) + const second = queue.enqueue({ newWindow: false, folders: ["second"] }) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, []) + queue.start() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["start:first"]) + releaseFirst() + await Promise.all([first, second, queue.idle()]) + assert.deepEqual(calls, ["start:first", "end:first", "start:second", "end:second"]) +}) + +test("launch arguments resolve valid folders relative to launch cwd and ignore unknown flags", () => { + const root = join(tmpdir(), `codenomad-launch-${process.pid}`) + const folder = join(root, "workspace") + mkdirSync(folder, { recursive: true }) + try { + assert.deepEqual(parseLaunchIntent(["--ignored", "--new-window", "--folder", "workspace", "missing", "workspace"], root), { + newWindow: true, + folders: [folder], + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("a losing native lock quits without primary side effects", () => { + const calls: string[] = [] + assert.equal(startPrimaryInstance(() => (calls.push("lock"), false), () => calls.push("quit"), () => calls.push("construct")), false) + assert.deepEqual(calls, ["lock", "quit"]) +}) + +test("bootstrap waits for a same-generation URL and token and discards late completion", async () => { + const calls: string[] = [] + let release!: (accepted: boolean) => void + const firstExchange = new Promise((resolve) => { release = resolve }) + let exchanges = 0 + const coordinator = new BackendBootstrapCoordinator( + async (url, token) => { calls.push(`exchange:${url}:${token}`); return exchanges++ === 0 ? firstExchange : true }, + (url) => { calls.push(`navigate:${url}`) }, + ) + coordinator.setReady("http://old") + assert.deepEqual(calls, []) + coordinator.setToken("old-token") + assert.deepEqual(calls, ["exchange:http://old:old-token"]) + coordinator.reset() + coordinator.setToken("new-token") + coordinator.setReady("http://new") + await coordinator.idle() + release(true) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["exchange:http://old:old-token", "exchange:http://new:new-token", "navigate:http://new"]) +}) diff --git a/packages/electron-app/electron/main/startup.ts b/packages/electron-app/electron/main/startup.ts new file mode 100644 index 000000000..9e0c27bec --- /dev/null +++ b/packages/electron-app/electron/main/startup.ts @@ -0,0 +1,219 @@ +import { createHash, randomUUID } from "node:crypto" +import { statSync } from "node:fs" +import { homedir } from "node:os" +import { isAbsolute, join, normalize, resolve } from "node:path" + +export interface LaunchIntent { + newWindow: boolean + folders: string[] +} + +export interface StorageScope { + channel: string + configIdentity: string + scoped: boolean + userDataPath: string + sessionDataPath: string + clientStateElectionDirectory?: string +} + +export interface LocalWindowIdentity { + id: string + persisted: boolean +} + +export class BackendBootstrapCoordinator { + private generation = 0 + private ready: { generation: number; url: string } | undefined + private token: { generation: number; value: string } | undefined + private inFlight: Promise | undefined + + constructor( + private readonly exchange: (url: string, token: string) => Promise, + private readonly navigate: (url: string) => void | Promise, + private readonly reportError: (error: unknown) => void = () => {}, + ) {} + + reset(): void { + this.generation++ + this.ready = undefined + this.token = undefined + this.inFlight = undefined + } + + setReady(url: string): void { + this.ready = { generation: this.generation, url } + this.start() + } + + setToken(token: string): void { + this.token = { generation: this.generation, value: token } + this.start() + } + + idle(): Promise { + return this.inFlight ?? Promise.resolve() + } + + private start(): void { + if (this.inFlight || !this.ready || !this.token || this.ready.generation !== this.token.generation) return + const generation = this.generation + const url = this.ready.url + const token = this.token.value + this.inFlight = this.exchange(url, token).then( + (accepted) => this.generation === generation ? this.navigate(accepted ? url : `${url}/login`) : undefined, + (error) => { + this.reportError(error) + return this.generation === generation ? this.navigate(`${url}/login`) : undefined + }, + ).then(() => undefined).finally(() => { + if (this.generation === generation) { + this.ready = undefined + this.token = undefined + this.inFlight = undefined + } + }) + } +} + +export function startPrimaryInstance(requestLock: () => boolean, losingLaunch: () => void, primaryLaunch: () => void): boolean { + if (!requestLock()) { + losingLaunch() + return false + } + primaryLaunch() + return true +} + +function normalizeConfigIdentity(raw: string | undefined, cwd: string): string { + let target = raw?.trim() || "~/.config/codenomad/config.json" + if (target === "~" || target.startsWith("~/") || target.startsWith("~\\")) { + target = join(homedir(), target.slice(2)) + } else if (!isAbsolute(target)) { + target = resolve(cwd, target) + } + target = normalize(target) + if (/\.json$/i.test(target)) target = join(resolve(target, ".."), "config.yaml") + if (!/\.ya?ml$/i.test(target)) target = join(target, "config.yaml") + return process.platform === "win32" ? target.toLowerCase() : target +} + +export function resolveUpdateChannel(environmentChannel: string | undefined, appVersion: string, packaged: boolean): string { + const explicit = environmentChannel?.trim().toLowerCase() + if (explicit) return explicit.replace(/[^a-z0-9._-]+/g, "-") + if (!packaged) return "dev" + return /-dev(?:\.|-)/i.test(appVersion) ? "dev" : "stable" +} + +export function resolveStorageScope(options: { + appVersion: string + environmentChannel?: string + cliConfig?: string + cwd: string + baseUserDataPath: string + packaged: boolean +}): StorageScope { + const channel = resolveUpdateChannel(options.environmentChannel, options.appVersion, options.packaged) + const configIdentity = normalizeConfigIdentity(options.cliConfig, options.cwd) + const defaultIdentity = normalizeConfigIdentity(undefined, options.cwd) + const scoped = channel !== "stable" || configIdentity !== defaultIdentity + const suffix = createHash("sha256").update(`${channel}\0${configIdentity}`).digest("hex").slice(0, 16) + const userDataPath = scoped ? join(options.baseUserDataPath, "scopes", `${channel}-${suffix}`) : options.baseUserDataPath + return { + channel, + configIdentity, + scoped, + userDataPath, + sessionDataPath: join(userDataPath, options.packaged ? "session-data-v2" : "session-data"), + ...(scoped ? { clientStateElectionDirectory: join(userDataPath, "client-state", "election") } : {}), + } +} + +export function resolveRemoteSessionPartition(profileId: string, proxySessionId?: string): string { + const identity = proxySessionId ? `${profileId}\0${proxySessionId}` : profileId + const suffix = createHash("sha256").update(identity).digest("hex").slice(0, 24) + return `${proxySessionId ? "" : "persist:"}codenomad-remote-${suffix}` +} + +export function isRemoteCertificateAllowed( + webContentsId: number, + url: string, + insecureOrigins: ReadonlyMap>, +): boolean { + try { return insecureOrigins.get(webContentsId)?.has(new URL(url).origin) ?? false } catch { return false } +} + +export async function allocateLocalWindowIdentity( + persistedIds: readonly string[], + isRegistered: (id: string) => boolean, + addWindow: () => Promise, + reportError: (error: unknown) => void = () => {}, + createId: () => string = randomUUID, +): Promise { + const retained = persistedIds.find((id) => !isRegistered(id)) + if (retained) return { id: retained, persisted: true } + try { + const id = await addWindow() + if (id) return { id, persisted: true } + } catch (error) { + reportError(error) + } + return { id: createId(), persisted: false } +} + +export function createLaunchIntentQueue( + handle: (intent: LaunchIntent) => void | Promise, + reportError: (error: unknown) => void, +) { + let start!: () => void + const ready = new Promise((resolve) => { start = resolve }) + let tail = Promise.resolve() + return { + enqueue(intent: LaunchIntent): Promise { + const operation = tail.then(() => ready).then(() => handle(intent)) + tail = operation.catch(reportError) + return operation + }, + start, + idle: () => tail, + } +} + +function existingDirectory(value: string, cwd: string): string | undefined { + const candidate = resolve(cwd, value) + try { + return statSync(candidate).isDirectory() ? candidate : undefined + } catch { + return undefined + } +} + +export function parseLaunchIntent(argv: string[], cwd: string): LaunchIntent { + const folders: string[] = [] + let newWindow = false + for (let index = 0; index < argv.length; index++) { + const value = argv[index]! + if (value === "--new-window") { + newWindow = true + continue + } + if (value === "--folder") { + const folder = argv[index + 1] + if (folder && !folder.startsWith("-")) { + index++ + const resolved = existingDirectory(folder, cwd) + if (resolved) folders.push(resolved) + } + continue + } + if (value.startsWith("--folder=")) { + const resolved = existingDirectory(value.slice("--folder=".length), cwd) + if (resolved) folders.push(resolved) + continue + } + if (value.startsWith("-")) continue + const resolved = existingDirectory(value, cwd) + if (resolved) folders.push(resolved) + } + return { newWindow, folders: [...new Set(folders)] } +} diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts index fae89974e..94c6a761a 100644 --- a/packages/electron-app/electron/main/window-state.test.ts +++ b/packages/electron-app/electron/main/window-state.test.ts @@ -15,6 +15,16 @@ test("normalizes persisted window state", () => { ) }) +test("mixed-DPI monitor selection compares intersections in physical coordinates", () => { + assert.deepEqual(clampWindowBounds( + { x: 1000, y: 500, width: 700, height: 600 }, + [ + { x: 0, y: 0, width: 1920, height: 1080, scaleFactor: 1 }, + { x: 960, y: 0, width: 1280, height: 720, scaleFactor: 2 }, + ], + ), { x: 1000, y: 120, width: 800, height: 600 }) +}) + test("normalizes unsafe zoom factors", () => { assert.equal(normalizeZoomFactor(Number.POSITIVE_INFINITY), 1) assert.equal(normalizeZoomFactor(0.01), 0.25) diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts index b595b495f..13c114530 100644 --- a/packages/electron-app/electron/main/window-state.ts +++ b/packages/electron-app/electron/main/window-state.ts @@ -1,5 +1,19 @@ import type { BrowserWindow } from "electron" -import type { ClientStateManager, NativeWindowState, WindowBounds } from "./client-state" +import type { ClientStateManager } from "./client-state" + +export interface WindowBounds { + x: number + y: number + width: number + height: number +} + +export interface NativeWindowState { + bounds: WindowBounds + maximized: boolean + fullscreen: boolean + zoomFactor: number +} export const DEFAULT_WINDOW_WIDTH = 1400 export const DEFAULT_WINDOW_HEIGHT = 900 @@ -15,6 +29,7 @@ export interface DisplayWorkArea { y: number width: number height: number + scaleFactor?: number } function isFiniteNumber(value: unknown): value is number { @@ -25,13 +40,28 @@ function clamp(value: number, minimum: number, maximum: number): number { return Math.min(Math.max(value, minimum), maximum) } +function physicalBounds(bounds: WindowBounds, scaleFactor: number): WindowBounds { + return { + x: bounds.x * scaleFactor, + y: bounds.y * scaleFactor, + width: bounds.width * scaleFactor, + height: bounds.height * scaleFactor, + } +} + function intersectionArea(bounds: WindowBounds, area: DisplayWorkArea): number { + const scaleFactor = isFiniteNumber(area.scaleFactor) && area.scaleFactor > 0 ? area.scaleFactor : 1 + bounds = physicalBounds(bounds, scaleFactor) + area = physicalBounds(area, scaleFactor) const width = Math.max(0, Math.min(bounds.x + bounds.width, area.x + area.width) - Math.max(bounds.x, area.x)) const height = Math.max(0, Math.min(bounds.y + bounds.height, area.y + area.height) - Math.max(bounds.y, area.y)) return width * height } function centerDistanceSquared(bounds: WindowBounds, area: DisplayWorkArea): number { + const scaleFactor = isFiniteNumber(area.scaleFactor) && area.scaleFactor > 0 ? area.scaleFactor : 1 + bounds = physicalBounds(bounds, scaleFactor) + area = physicalBounds(area, scaleFactor) const x = bounds.x + bounds.width / 2 - (area.x + area.width / 2) const y = bounds.y + bounds.height / 2 - (area.y + area.height / 2) return x * x + y * y @@ -100,16 +130,19 @@ export function clampWindowBounds(bounds: WindowBounds, displays: DisplayWorkAre return centerDistanceSquared(normalized, area) < centerDistanceSquared(normalized, best) ? area : best }) - const maximumWidth = Math.max(1, Math.floor(display.width)) - const maximumHeight = Math.max(1, Math.floor(display.height)) - const minimumWidth = Math.min(MIN_WINDOW_WIDTH, maximumWidth) - const minimumHeight = Math.min(MIN_WINDOW_HEIGHT, maximumHeight) - const width = clamp(normalized.width, minimumWidth, maximumWidth) - const height = clamp(normalized.height, minimumHeight, maximumHeight) - const x = clamp(normalized.x, display.x, display.x + maximumWidth - width) - const y = clamp(normalized.y, display.y, display.y + maximumHeight - height) + const scaleFactor = isFiniteNumber(display.scaleFactor) && display.scaleFactor > 0 ? display.scaleFactor : 1 + const physical = physicalBounds(normalized, scaleFactor) + const physicalDisplay = physicalBounds(display, scaleFactor) + const maximumWidth = Math.max(1, Math.floor(physicalDisplay.width)) + const maximumHeight = Math.max(1, Math.floor(physicalDisplay.height)) + const minimumWidth = Math.min(MIN_WINDOW_WIDTH * scaleFactor, maximumWidth) + const minimumHeight = Math.min(MIN_WINDOW_HEIGHT * scaleFactor, maximumHeight) + const width = clamp(physical.width, minimumWidth, maximumWidth) + const height = clamp(physical.height, minimumHeight, maximumHeight) + const x = clamp(physical.x, physicalDisplay.x, physicalDisplay.x + maximumWidth - width) + const y = clamp(physical.y, physicalDisplay.y, physicalDisplay.y + maximumHeight - height) - return { x, y, width, height } + return { x: x / scaleFactor, y: y / scaleFactor, width: width / scaleFactor, height: height / scaleFactor } } export function restoreWindowState(window: BrowserWindow, state: NativeWindowState | undefined, bounds: WindowBounds | undefined) { @@ -160,6 +193,7 @@ export class WindowStateTracker { private readonly window: BrowserWindow, private readonly clientState: ClientStateManager, initialState?: NativeWindowState, + private readonly windowId = clientState.activeWindowId, ) { this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor) const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0] @@ -241,7 +275,7 @@ export class WindowStateTracker { maximized: this.window.isMaximized(), fullscreen: this.window.isFullScreen(), zoomFactor: this.desiredZoomFactor, - }) + }, this.windowId) } private captureNormalBounds(): void { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 618b5858a..5e3164eec 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -7,20 +7,25 @@ function resolveWindowContext() { return context === "remote" ? "remote" : "local" } -function resolveRuntimeHost(windowContext) { - return "electron" +function resolveWindowId() { + const prefix = "--codenomad-window-id=" + const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix)) + return arg ? arg.slice(prefix.length) : null } const windowContext = resolveWindowContext() +const windowId = resolveWindowId() const localElectronAPI = { onCliStatus: (callback) => { - ipcRenderer.on("cli:status", (_, data) => callback(data)) - return () => ipcRenderer.removeAllListeners("cli:status") + const handler = (_, data) => callback(data) + ipcRenderer.on("cli:status", handler) + return () => ipcRenderer.removeListener("cli:status", handler) }, onCliError: (callback) => { - ipcRenderer.on("cli:error", (_, data) => callback(data)) - return () => ipcRenderer.removeAllListeners("cli:error") + const handler = (_, data) => callback(data) + ipcRenderer.on("cli:error", handler) + return () => ipcRenderer.removeListener("cli:error", handler) }, getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), restartCli: () => ipcRenderer.invoke("cli:restart"), @@ -28,6 +33,14 @@ const localElectronAPI = { getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload), setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)), + newWindow: () => ipcRenderer.invoke("window:new"), + nextPendingFolder: () => ipcRenderer.invoke("window:nextFolder"), + acknowledgePendingFolder: (folder, opened) => ipcRenderer.invoke("window:ackFolder", folder, Boolean(opened)), + onPendingFolders: (callback) => { + const handler = () => callback() + ipcRenderer.on("window:folders-pending", handler) + return () => ipcRenderer.removeListener("window:folders-pending", handler) + }, onMenuAction: (callback) => { const handler = (_event, action) => callback(action) ipcRenderer.on("menu:action", handler) @@ -47,6 +60,8 @@ const localElectronAPI = { claimClientStateAccess: (token) => ipcRenderer.invoke("client-state:claimAccess", token), loadClientState: (token) => ipcRenderer.invoke("client-state:load", token), saveClientState: (token, snapshot) => ipcRenderer.invoke("client-state:save", token, snapshot), + commitClientStatePartitions: (token, payload) => ipcRenderer.invoke("client-state:commitPartitions", token, payload), + loadClientStatePartition: (token, key) => ipcRenderer.invoke("client-state:loadPartition", token, key), setClientStateRestoreEnabled: (token, enabled) => ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), @@ -63,4 +78,5 @@ contextBridge.exposeInMainWorld( windowContext === "local" ? localElectronAPI : remoteElectronAPI, ) contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext) -contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext)) +contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_ID__", windowContext === "local" ? windowId : null) +contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", "electron") diff --git a/packages/electron-app/electron/preload/index.test.ts b/packages/electron-app/electron/preload/index.test.ts new file mode 100644 index 000000000..2d0e56983 --- /dev/null +++ b/packages/electron-app/electron/preload/index.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" +import vm from "node:vm" + +test("CLI event disposers remove only their own wrapper listeners", () => { + const listeners = new Map>() + let api: Record | undefined + const ipcRenderer = { + on(channel: string, listener: Function) { + const channelListeners = listeners.get(channel) ?? new Set() + channelListeners.add(listener) + listeners.set(channel, channelListeners) + }, + removeListener(channel: string, listener: Function) { listeners.get(channel)?.delete(listener) }, + invoke() {}, + } + vm.runInNewContext(readFileSync(new URL("./index.cjs", import.meta.url), "utf8"), { + require: () => ({ + contextBridge: { exposeInMainWorld(name: string, value: Record) { if (name === "electronAPI") api = value } }, + ipcRenderer, + webUtils: {}, + }), + process: { argv: [] }, + }) + + for (const [subscribe, channel] of [["onCliStatus", "cli:status"], ["onCliError", "cli:error"]] as const) { + const calls: string[] = [] + const disposeFirst = api![subscribe]((value: string) => calls.push(`first:${value}`)) + api![subscribe]((value: string) => calls.push(`second:${value}`)) + disposeFirst() + for (const listener of listeners.get(channel) ?? []) listener({}, "event") + assert.deepEqual(calls, ["second:event"]) + } +}) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 58db2c558..4864c4f62 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -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/workspace-open.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/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/navigation-security.test.ts electron/main/process-exit.test.ts electron/main/process-stop.test.ts electron/main/remote-window-registry.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", @@ -80,10 +80,6 @@ "!icon.icns", "!icon.ico" ] - }, - { - "from": "../server/dist/opencode-plugin", - "to": "opencode-plugin" } ], "mac": { diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md deleted file mode 100644 index 16b8a8192..000000000 --- a/packages/opencode-plugin/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# CodeNomad OpenCode Plugin - -## TLDR -Packaged OpenCode plugin injected into every OpenCode instance that CodeNomad launches. It provides the CodeNomad bridge for local event exchange between the CLI server and OpenCode. - -## What it is -An npm-packable plugin package. Production builds ship a local `.tgz` and inject it through `OPENCODE_CONFIG_CONTENT`; dev runs reference the TypeScript plugin entry directly with a `file://` URL. - -## How it works -- CodeNomad sets `OPENCODE_CONFIG_CONTENT` when spawning each OpenCode instance (`packages/server/src/workspaces/manager.ts`). -- The server packs this package during build (`packages/server/scripts/package-opencode-plugin.mjs`). -- OpenCode loads the plugin from `plugin` entries injected into the config content. -- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-plugin/plugin/lib/client.ts`). -- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`). - -## Expectations -- Local-only bridge (no auth/token yet). -- Plugin must fail startup if it cannot connect after 3 retries. -- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers. -- Keep event shapes small and explicit; use `type` + `properties` only. - -## Ideas -- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands). -- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed. -- Promote stable event shapes and version tags once the protocol settles. - -## Pointers -- Plugin entry: `packages/opencode-plugin/plugin/codenomad.ts` -- Plugin client: `packages/opencode-plugin/plugin/lib/client.ts` -- Plugin server routes: `packages/server/src/server/routes/plugin.ts` -- Plugin event handling: `packages/server/src/plugins/handlers.ts` -- Workspace env injection: `packages/server/src/workspaces/manager.ts` diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json deleted file mode 100644 index 6f7e8c630..000000000 --- a/packages/opencode-plugin/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@codenomad/codenomad-opencode-plugin", - "version": "0.19.0", - "private": true, - "license": "MIT", - "type": "module", - "main": "dist/codenomad.js", - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json" - }, - "dependencies": { - "@opencode-ai/plugin": "1.3.7" - }, - "devDependencies": { - "@types/node": "^22.18.0", - "typescript": "^5.6.3" - } -} diff --git a/packages/opencode-plugin/plugin/codenomad.ts b/packages/opencode-plugin/plugin/codenomad.ts deleted file mode 100644 index 61d1827f0..000000000 --- a/packages/opencode-plugin/plugin/codenomad.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client.js" -import { createBackgroundProcessTools } from "./lib/background-process.js" - -let voiceModeEnabled = false - -export async function CodeNomadPlugin(input: PluginInput): Promise<{ - tool: ReturnType - "chat.message": CodeNomadChatMessageHook - event: CodeNomadEventHook -}> { - const config = getCodeNomadConfig() - const client = createCodeNomadClient(config) - const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory }) - - await client.startEvents((event) => { - if (event.type === "codenomad.ping") { - void client.postEvent({ - type: "codenomad.pong", - properties: { - ts: Date.now(), - pingTs: (event.properties as any)?.ts, - }, - }).catch(() => {}) - return - } - - if (event.type === "codenomad.voiceMode") { - voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled) - } - }) - - return { - tool: { - ...backgroundProcessTools, - }, - async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) { - if (!voiceModeEnabled) { - return - } - - output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n") - }, - async event(input: { event: any }) { - const opencodeEvent = input?.event - if (!opencodeEvent || typeof opencodeEvent !== "object") return - - }, - } -} - -type CodeNomadChatMessageHook = ( - _input: { sessionID: string }, - output: { message: { system?: string } }, -) => Promise - -type CodeNomadEventHook = (input: { event: any }) => Promise - -function buildVoiceModePrompt(): string { - return [ - "Voice conversation mode is enabled.", - "Prepend your reply with a fenced code block using language `spoken`.", - "The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.", - "In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.", - "Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.", - "Do not add generic phrases about whether the user should read more.", - "Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.", - "When referring to that written detail, say `below` or `in the message` rather than `detailed section`.", - "After the `spoken` block, continue with your normal detailed response.", - "Example:", - "```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```", - ].join("\n\n") -} diff --git a/packages/opencode-plugin/plugin/lib/background-process.ts b/packages/opencode-plugin/plugin/lib/background-process.ts deleted file mode 100644 index 6840737d6..000000000 --- a/packages/opencode-plugin/plugin/lib/background-process.ts +++ /dev/null @@ -1,265 +0,0 @@ -import path from "path" -import { tool } from "@opencode-ai/plugin/tool" -import { createCodeNomadRequester, type CodeNomadConfig } from "./request.js" - -type BackgroundProcess = { - id: string - title: string - command: string - status: "running" | "stopped" | "error" - startedAt: string - stoppedAt?: string - exitCode?: number - outputSizeBytes?: number -} - -type BackgroundProcessNotificationRequest = { - sessionID: string - directory: string -} - -type BackgroundProcessOptions = { - baseDir: string -} - -type ParsedCommand = { - head: string - args: string[] -} - -export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) { - const requester = createCodeNomadRequester(config) - - const request = async (path: string, init?: RequestInit): Promise => { - return requester.requestJson(`/background-processes${path}`, init) - } - - return { - run_background_process: tool({ - description: - "Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.", - args: { - title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), - command: tool.schema.string().describe("Shell command to run in the workspace"), - notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"), - }, - async execute(args, context) { - assertCommandWithinBase(args.command, options.baseDir) - const notification: BackgroundProcessNotificationRequest | undefined = args.notify - ? { - sessionID: context.sessionID, - directory: context.directory, - } - : undefined - const process = await request("", { - method: "POST", - body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }), - }) - - return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}` - }, - }), - list_background_processes: tool({ - description: "List background processes running for this workspace.", - args: {}, - async execute() { - const response = await request<{ processes: BackgroundProcess[] }>("") - if (response.processes.length === 0) { - return "No background processes running." - } - - return response.processes - .map((process) => { - const status = process.status === "running" ? "running" : process.status - const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : "" - const size = - typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : "" - return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}` - }) - .join("\n") - }, - }), - read_background_process_output: tool({ - description: "Read output from a background process. Use full, grep, head, or tail.", - args: { - id: tool.schema.string().describe("Background process ID"), - method: tool.schema - .enum(["full", "grep", "head", "tail"]) - .default("full") - .describe("Method to read output"), - pattern: tool.schema.string().optional().describe("Pattern for grep method"), - lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"), - }, - async execute(args) { - if (args.method === "grep" && !args.pattern) { - return "Pattern is required for grep method." - } - - const params = new URLSearchParams({ method: args.method }) - if (args.pattern) { - params.set("pattern", args.pattern) - } - if (args.lines) { - params.set("lines", String(args.lines)) - } - - const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>( - `/${args.id}/output?${params.toString()}`, - ) - - const header = response.truncated - ? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):` - : `Output (${Math.round(response.sizeBytes / 1024)}KB):` - - return `${header}\n\n${response.content}` - }, - }), - stop_background_process: tool({ - description: "Stop a background process (SIGTERM) but keep its output and entry.", - args: { - id: tool.schema.string().describe("Background process ID"), - }, - async execute(args) { - const process = await request(`/${args.id}/stop`, { method: "POST" }) - return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}` - }, - }), - terminate_background_process: tool({ - description: "Terminate a background process and delete its output + entry.", - args: { - id: tool.schema.string().describe("Background process ID"), - }, - async execute(args) { - await request(`/${args.id}/terminate`, { method: "POST" }) - return `Terminated background process ${args.id} and removed its output.` - }, - }), - } -} - -const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"]) -const EXPANSION_CHARS = /[~*$?\[\]`$]/ - -function assertCommandWithinBase(command: string, baseDir: string) { - const normalizedBase = path.resolve(baseDir) - const commands = splitCommands(command) - - for (const item of commands) { - if (!FILE_COMMANDS.has(item.head)) { - continue - } - - for (const arg of item.args) { - if (!arg) continue - if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue - - const literalArg = unquote(arg) - if (EXPANSION_CHARS.test(literalArg)) { - throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`) - } - - const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg) - if (!isWithinBase(normalizedBase, resolved)) { - throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`) - } - } - } -} - -function splitCommands(command: string): ParsedCommand[] { - const tokens = tokenize(command) - const commands: ParsedCommand[] = [] - let current: string[] = [] - - for (const token of tokens) { - if (isSeparator(token)) { - if (current.length > 0) { - commands.push({ head: current[0], args: current.slice(1) }) - current = [] - } - continue - } - current.push(token) - } - - if (current.length > 0) { - commands.push({ head: current[0], args: current.slice(1) }) - } - - return commands -} - -function tokenize(input: string): string[] { - const tokens: string[] = [] - let current = "" - let quote: "'" | '"' | null = null - let escape = false - - const flush = () => { - if (current.length > 0) { - tokens.push(current) - current = "" - } - } - - for (let index = 0; index < input.length; index += 1) { - const char = input[index] - - if (escape) { - current += char - escape = false - continue - } - - if (char === "\\" && quote !== "'") { - escape = true - continue - } - - if (quote) { - current += char - if (char === quote) { - quote = null - } - continue - } - - if (char === "'" || char === '"') { - quote = char - current += char - continue - } - - if (char === " " || char === "\n" || char === "\t") { - flush() - continue - } - - if (char === "|" || char === "&" || char === ";") { - flush() - tokens.push(char) - continue - } - - current += char - } - - flush() - return tokens -} - -function isSeparator(token: string): boolean { - return token === "|" || token === "&" || token === ";" -} - -function unquote(token: string): string { - if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) { - return token.slice(1, -1) - } - return token -} - -function isWithinBase(base: string, candidate: string): boolean { - const relative = path.relative(base, candidate) - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) -} diff --git a/packages/opencode-plugin/plugin/lib/client.ts b/packages/opencode-plugin/plugin/lib/client.ts deleted file mode 100644 index aee7a15dc..000000000 --- a/packages/opencode-plugin/plugin/lib/client.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request.js" - -export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request.js" - -export function createCodeNomadClient(config: CodeNomadConfig) { - const requester = createCodeNomadRequester(config) - - return { - postEvent: (event: PluginEvent) => - requester.requestVoid("/event", { - method: "POST", - body: JSON.stringify(event), - }), - startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent), - } -} - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function startPluginEvents( - requester: ReturnType, - onEvent: (event: PluginEvent) => void, -) { - // Fail plugin startup if we cannot establish the initial connection. - const initialBody = await connectWithRetries(requester, 3) - - // After startup, keep reconnecting; throw after 3 consecutive failures. - void consumeWithReconnect(requester, onEvent, initialBody) -} - -async function connectWithRetries(requester: ReturnType, maxAttempts: number) { - let lastError: unknown - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - return await requester.requestSseBody("/events") - } catch (error) { - lastError = error - await delay(500 * attempt) - } - } - - const reason = lastError instanceof Error ? lastError.message : String(lastError) - const url = requester.buildUrl("/events") - throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`) -} - -async function consumeWithReconnect( - requester: ReturnType, - onEvent: (event: PluginEvent) => void, - initialBody: ReadableStream, -) { - let consecutiveFailures = 0 - let body: ReadableStream | null = initialBody - - while (true) { - try { - if (!body) { - body = await connectWithRetries(requester, 3) - } - - await consumeSseBody(body, onEvent) - body = null - consecutiveFailures = 0 - } catch (error) { - body = null - consecutiveFailures += 1 - if (consecutiveFailures >= 3) { - const reason = error instanceof Error ? error.message : String(error) - throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`) - } - await delay(500 * consecutiveFailures) - } - } -} - -async function consumeSseBody(body: ReadableStream, onEvent: (event: PluginEvent) => void) { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = "" - - while (true) { - const { done, value } = await reader.read() - if (done || !value) { - break - } - - buffer += decoder.decode(value, { stream: true }) - - let separatorIndex = buffer.indexOf("\n\n") - while (separatorIndex >= 0) { - const chunk = buffer.slice(0, separatorIndex) - buffer = buffer.slice(separatorIndex + 2) - separatorIndex = buffer.indexOf("\n\n") - - const event = parseSseChunk(chunk) - if (event) { - onEvent(event) - } - } - } - - throw new Error("SSE stream ended") -} - -function parseSseChunk(chunk: string): PluginEvent | null { - const lines = chunk.split(/\r?\n/) - const dataLines: string[] = [] - - for (const line of lines) { - if (line.startsWith(":")) continue - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()) - } - } - - if (dataLines.length === 0) return null - - const payload = dataLines.join("\n").trim() - if (!payload) return null - - try { - const parsed = JSON.parse(payload) - if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") { - return null - } - return parsed as PluginEvent - } catch { - return null - } -} diff --git a/packages/opencode-plugin/plugin/lib/request.ts b/packages/opencode-plugin/plugin/lib/request.ts deleted file mode 100644 index 5025a5013..000000000 --- a/packages/opencode-plugin/plugin/lib/request.ts +++ /dev/null @@ -1,214 +0,0 @@ -import http from "http" -import https from "https" -import { Readable } from "stream" - -export type PluginEvent = { - type: string - properties?: Record -} - -export type CodeNomadConfig = { - instanceId: string - baseUrl: string -} - -export function getCodeNomadConfig(): CodeNomadConfig { - return { - instanceId: requireEnv("CODENOMAD_INSTANCE_ID"), - baseUrl: requireEnv("CODENOMAD_BASE_URL"), - } -} - -export function createCodeNomadRequester(config: CodeNomadConfig) { - const rawBaseUrl = (config.baseUrl ?? "").trim() - const baseUrl = rawBaseUrl.replace(/\/+$/, "") - const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin` - const authorization = buildInstanceAuthorizationHeader() - - const buildUrl = (path: string) => { - if (path.startsWith("http://") || path.startsWith("https://")) { - return path - } - const normalized = path.startsWith("/") ? path : `/${path}` - return `${pluginBase}${normalized}` - } - - const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record => { - const output: Record = normalizeHeaders(headers) - output.Authorization = authorization - if (hasBody) { - output["Content-Type"] = output["Content-Type"] ?? "application/json" - } - return output - } - - const fetchWithAuth = async (path: string, init?: RequestInit): Promise => { - const url = buildUrl(path) - const hasBody = init?.body !== undefined - const headers = buildHeaders(init?.headers, hasBody) - - // The CodeNomad plugin only talks to the local CodeNomad server. - // Use a single request implementation that tolerates custom/self-signed certs - // without disabling TLS verification for the whole Node process. - return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false }) - } - - const requestJson = async (path: string, init?: RequestInit): Promise => { - const response = await fetchWithAuth(path, init) - if (!response.ok) { - const message = await response.text().catch(() => "") - throw new Error(message || `Request failed with ${response.status}`) - } - - if (response.status === 204) { - return undefined as T - } - - return (await response.json()) as T - } - - const requestVoid = async (path: string, init?: RequestInit): Promise => { - const response = await fetchWithAuth(path, init) - if (!response.ok) { - const message = await response.text().catch(() => "") - throw new Error(message || `Request failed with ${response.status}`) - } - } - - const requestSseBody = async (path: string): Promise> => { - const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } }) - if (!response.ok || !response.body) { - throw new Error(`SSE unavailable (${response.status})`) - } - return response.body as ReadableStream - } - - return { - buildUrl, - fetch: fetchWithAuth, - requestJson, - requestVoid, - requestSseBody, - } -} - -async function nodeFetch( - url: string, - init: RequestInit & { headers?: Record }, - tls: { rejectUnauthorized: boolean }, -): Promise { - const parsed = new URL(url) - const isHttps = parsed.protocol === "https:" - const requestFn = isHttps ? https.request : http.request - - const method = (init.method ?? "GET").toUpperCase() - const headers = init.headers ?? {} - const body = init.body - - return await new Promise((resolve, reject) => { - const req = requestFn( - { - protocol: parsed.protocol, - hostname: parsed.hostname, - port: parsed.port ? Number(parsed.port) : undefined, - path: `${parsed.pathname}${parsed.search}`, - method, - headers, - ...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}), - }, - (res) => { - const responseHeaders = new Headers() - for (const [key, value] of Object.entries(res.headers)) { - if (value === undefined) continue - if (Array.isArray(value)) { - responseHeaders.set(key, value.join(", ")) - } else { - responseHeaders.set(key, String(value)) - } - } - - // Convert Node stream -> Web ReadableStream for Response. - const webBody = Readable.toWeb(res) as unknown as ReadableStream - resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders })) - }, - ) - - const signal = init.signal - const abort = () => { - const err = new Error("Request aborted") - ;(err as any).name = "AbortError" - req.destroy(err) - reject(err) - } - - if (signal) { - if (signal.aborted) { - abort() - return - } - signal.addEventListener("abort", abort, { once: true }) - req.once("close", () => signal.removeEventListener("abort", abort)) - } - - req.once("error", reject) - - if (body === undefined || body === null) { - req.end() - return - } - - if (typeof body === "string") { - req.end(body) - return - } - - if (body instanceof Uint8Array) { - req.end(Buffer.from(body)) - return - } - - if (body instanceof ArrayBuffer) { - req.end(Buffer.from(new Uint8Array(body))) - return - } - - // Fallback for less common BodyInit types. - req.end(String(body)) - }) -} - -function requireEnv(key: string): string { - const value = process.env[key] - if (!value || !value.trim()) { - throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`) - } - return value -} - -function buildInstanceAuthorizationHeader(): string { - const username = requireEnv("OPENCODE_SERVER_USERNAME") - const password = requireEnv("OPENCODE_SERVER_PASSWORD") - const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64") - return `Basic ${token}` -} - -function normalizeHeaders(headers: HeadersInit | undefined): Record { - const output: Record = {} - if (!headers) return output - - if (headers instanceof Headers) { - headers.forEach((value, key) => { - output[key] = value - }) - return output - } - - if (Array.isArray(headers)) { - for (const [key, value] of headers) { - output[key] = value - } - return output - } - - return { ...headers } -} diff --git a/packages/opencode-plugin/tsconfig.json b/packages/opencode-plugin/tsconfig.json deleted file mode 100644 index 09a866276..000000000 --- a/packages/opencode-plugin/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": false, - "outDir": "dist", - "rootDir": "plugin", - "types": ["node"] - }, - "include": ["plugin/**/*.ts"], - "exclude": ["dist", "node_modules"] -} diff --git a/packages/server/README.md b/packages/server/README.md index 3f19b5a13..cd4e0d95d 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -20,7 +20,9 @@ ## Prerequisites -- **OpenCode**: `opencode` must be installed and configured on your system. +- **OpenCode V2**: Install a compatible `opencode2` CLI. CodeNomad uses the CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to OpenCode's externally owned global daemon, then validates service health and API behavior rather than an exact CLI version string. +- **OpenCode data**: The global daemon owns its platform-default storage, database, and service registration. Configured startup environment applies only when CodeNomad starts a missing daemon; an existing daemon is unchanged. +- **Windows to WSL**: A configured WSL UNC binary uses Linux `service status`, `service start`, and `service get password`; Windows must have WSL localhost forwarding enabled to reach its loopback service. - 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,7 +98,7 @@ You can configure the server using flags or environment variables: | `--ui-dir ` | `CLI_UI_DIR` | Directory containing the built UI bundle | | `--ui-dev-server ` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | -| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` | +| `--ui-auto-update ` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true`) | | `--ui-manifest-url ` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | ### Dev Releases (Advanced) @@ -215,8 +217,22 @@ 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, messages, and service registration**: OpenCode's platform-default global locations. +- **Desktop restore state**: `~/.codenomad/client-state/v2/` + +CodeNomad owns no private OpenCode port, database, service registration, or daemon PID. Configured allowed `server.environmentVariables` and the current `NODE_EXTRA_CA_CERTS` apply only when CodeNomad starts a missing daemon. Existing daemons are unchanged; legacy `OPENCODE_DB` and `XDG_STATE_HOME` ownership variables are ignored. WSL lifecycle commands run inside Linux and never inspect or signal Linux PIDs from Windows. + +Explicit **Stop Workspace** evicts that location and its resources from the global service without stopping the daemon. Closing a UI tab or native window only detaches local state and never evicts. Backend shutdown clears only CodeNomad's in-memory connection state and never stops the global service. + +### Event Delivery + +CodeNomad holds one shared OpenCode V2 `client.event.subscribe()` stream. It routes native location-scoped events to logical workspaces and multiplexes them with CodeNomad events over `GET /api/events` for browser `EventSource` clients. + +The stream is volatile and has no replay guarantee. After reconnecting, clients must refetch authoritative sessions and pending permission, question, and form requests; file and config consumers must also refetch after `filesystem.changed` and `config.updated` invalidations. ### Provider Plan Usage diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 7b45e1366..a84196827 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -9,16 +9,17 @@ "version": "0.19.0", "license": "MIT", "dependencies": { - "@fastify/cors": "^11.2.0", - "@fastify/reply-from": "^12.6.2", - "@fastify/static": "^9.1.1", + "@fastify/cors": "^8.5.0", + "@fastify/reply-from": "^9.8.0", + "@fastify/static": "^7.0.4", + "@opencode-ai/client": "0.0.0-beta-17595", "commander": "^12.1.0", - "fastify": "^5.8.5", + "fastify": "^4.28.1", "fuzzysort": "^2.0.4", "node-forge": "^1.3.3", "openai": "^6.27.0", "pino": "^9.4.0", - "undici": "^8.1.0", + "undici": "^6.19.8", "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" @@ -29,7 +30,6 @@ "devDependencies": { "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", - "bun": "^1.3.13", "cross-env": "^7.0.3", "ts-node": "^10.9.2", "tsx": "^4.20.6", @@ -492,231 +492,137 @@ } }, "node_modules/@fastify/accept-negotiator": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", - "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", + "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } }, "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", "license": "MIT", "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" } }, "node_modules/@fastify/cors": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", - "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", + "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", "license": "MIT", "dependencies": { - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" } }, "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", - "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", "license": "MIT", "dependencies": { - "fast-json-stringify": "^6.0.0" + "fast-json-stringify": "^5.7.0" } }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", "license": "MIT", "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" + "fast-deep-equal": "^3.1.3" } }, "node_modules/@fastify/reply-from": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-12.6.2.tgz", - "integrity": "sha512-FhMvsRJa4HMG0q0/Yi06sgwVFd/Wc8E/+RbHsqB0Q+883C66RPrS6qwZKBPje2mVzsyEb9sjq7wlyvi35zRW0A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz", + "integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==", "license": "MIT", "dependencies": { - "@fastify/error": "^4.0.0", + "@fastify/error": "^3.0.0", "end-of-stream": "^1.4.4", - "fast-content-type-parse": "^3.0.0", - "fast-querystring": "^1.1.2", - "fastify-plugin": "^5.0.1", + "fast-content-type-parse": "^1.1.0", + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0", "toad-cache": "^3.7.0", - "undici": "^7.0.0" + "undici": "^5.19.1" } }, "node_modules/@fastify/reply-from/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, "engines": { - "node": ">=20.18.1" + "node": ">=14.0" } }, "node_modules/@fastify/send": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", - "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", + "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", "license": "MIT", "dependencies": { - "@lukeed/ms": "^2.0.2", + "@lukeed/ms": "^2.0.1", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "http-errors": "^2.0.0", - "mime": "^3" + "http-errors": "2.0.0", + "mime": "^3.0.0" } }, "node_modules/@fastify/static": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", - "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz", + "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==", "license": "MIT", "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "@fastify/send": "^4.0.0", - "content-disposition": "^1.0.1", - "fastify-plugin": "^5.0.0", - "fastq": "^1.17.1", - "glob": "^13.0.0" + "@fastify/accept-negotiator": "^1.0.0", + "@fastify/send": "^2.0.0", + "content-disposition": "^0.5.3", + "fastify-plugin": "^4.0.0", + "fastq": "^1.17.0", + "glob": "^10.3.4" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/@jridgewell/resolve-uri": { @@ -756,173 +662,125 @@ "node": ">=8" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.13.tgz", - "integrity": "sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg==", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.13.tgz", - "integrity": "sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ==", - "cpu": [ - "x64" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.13.tgz", - "integrity": "sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ==", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ] }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.13.tgz", - "integrity": "sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ - "arm64" + "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.13.tgz", - "integrity": "sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.13.tgz", - "integrity": "sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.13.tgz", - "integrity": "sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A==", + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.13.tgz", - "integrity": "sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.13.tgz", - "integrity": "sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "win32" ] }, - "node_modules/@oven/bun-windows-aarch64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.13.tgz", - "integrity": "sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@opencode-ai/client": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-17595.tgz", + "integrity": "sha512-uDM6jztQiyXulYQL9G7kx283cv+0WhgQ98AzfClKQnV5mOB5wVBTGIjD1FGRwyxhUNGJBrfaP+NqOwWFjZhoeg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@opencode-ai/protocol": "0.0.0-beta-17595", + "@opencode-ai/schema": "0.0.0-beta-17595" + }, + "peerDependencies": { + "effect": "4.0.0-beta.107", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.13.tgz", - "integrity": "sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@opencode-ai/protocol": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-17595.tgz", + "integrity": "sha512-du77bQXIvOvqzyqS/I5gJMY3FZhXH2Oi3gs3ywAOnHCGRkwmVE9ofvFwR4G/Rzx/oVUOuGkyN28si29AluXGWw==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-17595", + "effect": "4.0.0-beta.107" + } }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.13.tgz", - "integrity": "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@opencode-ai/schema": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-17595.tgz", + "integrity": "sha512-448/fW6HIHd2JT+TSgm4izcAWl51f17GR6WcfGbS9SyWmXFNnX1Fi03xNRgf7dnul9yyceXEsMbreUvteKxAYg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.107" + } }, "node_modules/@pinojs/redact": { "version": "0.4.0", @@ -930,6 +788,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -964,6 +838,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1021,9 +896,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1037,9 +912,9 @@ } }, "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -1053,6 +928,46 @@ } } }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1070,44 +985,28 @@ } }, "node_modules/avvio": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", - "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", "license": "MIT", "dependencies": { - "@fastify/error": "^4.0.0", + "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0" } }, "node_modules/buffer-crc32": { @@ -1119,41 +1018,24 @@ "node": "*" } }, - "node_modules/bun": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.13.tgz", - "integrity": "sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw==", - "cpu": [ - "arm64", - "x64" - ], - "dev": true, - "hasInstallScript": true, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" + "dependencies": { + "color-name": "~1.1.4" }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.13", - "@oven/bun-darwin-x64": "1.3.13", - "@oven/bun-darwin-x64-baseline": "1.3.13", - "@oven/bun-linux-aarch64": "1.3.13", - "@oven/bun-linux-aarch64-musl": "1.3.13", - "@oven/bun-linux-x64": "1.3.13", - "@oven/bun-linux-x64-baseline": "1.3.13", - "@oven/bun-linux-x64-musl": "1.3.13", - "@oven/bun-linux-x64-musl-baseline": "1.3.13", - "@oven/bun-windows-aarch64": "1.3.13", - "@oven/bun-windows-x64": "1.3.13", - "@oven/bun-windows-x64-baseline": "1.3.13" + "engines": { + "node": ">=7.0.0" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1164,29 +1046,24 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "safe-buffer": "5.2.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">= 0.6" } }, "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/create-require": { @@ -1219,7 +1096,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1239,13 +1115,14 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/diff": { @@ -1258,6 +1135,31 @@ "node": ">=0.3.1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "4.0.0-beta.107", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.107.tgz", + "integrity": "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "uuid": "^14.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1315,20 +1217,32 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/fastify" + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" }, { "type": "opencollective", - "url": "https://opencollective.com/fastify" + "url": "https://opencollective.com/fast-check" } ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", "license": "MIT" }, "node_modules/fast-decode-uri-component": { @@ -1344,29 +1258,37 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", - "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", "license": "MIT", "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0", - "json-schema-ref-resolver": "^3.0.0", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", "rfdc": "^1.2.0" } }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -1377,25 +1299,15 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.4.tgz", + "integrity": "sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug==", + "license": "MIT" }, "node_modules/fastify": { - "version": "5.8.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", - "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", "funding": [ { "type": "github", @@ -1408,37 +1320,34 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" } }, "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, + "node_modules/fastify/node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", "license": "MIT" }, "node_modules/fastq": { @@ -1460,17 +1369,42 @@ } }, "node_modules/find-my-way": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", - "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", "engines": { - "node": ">=20" + "node": ">= 0.6" } }, "node_modules/fsevents": { @@ -1508,40 +1442,40 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "18 || 20 || >=22" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/inherits": { @@ -1551,38 +1485,51 @@ "license": "ISC" }, "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=8" } }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", "license": "MIT", "dependencies": { - "dequal": "^2.0.3" + "fast-deep-equal": "^3.1.3" } }, "node_modules/json-schema-traverse": { @@ -1591,51 +1538,34 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", "license": "BSD-3-Clause", "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" } }, "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/make-error": { "version": "1.3.6", @@ -1657,15 +1587,15 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1680,6 +1610,46 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -1689,6 +1659,27 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1728,27 +1719,32 @@ } } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1813,6 +1809,35 @@ ], "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -1848,9 +1873,9 @@ } }, "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", "license": "MIT", "engines": { "node": ">=10" @@ -1872,26 +1897,33 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/fastify" + "url": "https://github.com/sponsors/feross" }, { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", "license": "MIT", "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" + "ret": "~0.4.0" } }, "node_modules/safe-stable-stringify": { @@ -1904,19 +1936,9 @@ } }, "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", "license": "BSD-3-Clause" }, "node_modules/semver": { @@ -1947,7 +1969,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1960,12 +1981,23 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -1985,14 +2017,110 @@ } }, "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2090,6 +2218,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2099,12 +2228,12 @@ } }, "node_modules/undici": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", - "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -2114,6 +2243,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -2125,7 +2267,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2137,6 +2278,97 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2144,9 +2376,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -2183,6 +2415,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/server/package.json b/packages/server/package.json index bb658946a..32439f6db 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,10 +17,9 @@ "codenomad": "dist/bin.js" }, "scripts": { - "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-plugin", + "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs", "build:ui": "npm run build --prefix ../ui", "prepare-ui": "node ./scripts/copy-ui-dist.mjs", - "prepare-plugin": "node ./scripts/package-opencode-plugin.mjs", "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, @@ -28,7 +27,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/sdk": "^1.17.8", + "@opencode-ai/client": "0.0.0-beta-17595", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/scripts/package-opencode-plugin.mjs b/packages/server/scripts/package-opencode-plugin.mjs deleted file mode 100644 index 319905476..000000000 --- a/packages/server/scripts/package-opencode-plugin.mjs +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -import { readdirSync, renameSync, rmSync, mkdirSync } from "fs" -import path from "path" -import { spawnSync } from "child_process" -import { fileURLToPath } from "url" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const serverRoot = path.resolve(__dirname, "..") -const workspaceRoot = path.resolve(serverRoot, "../..") -const pluginRoot = path.resolve(serverRoot, "../opencode-plugin") -const targetDir = path.resolve(serverRoot, "dist/opencode-plugin") -const targetTarballName = "codenomad-opencode-plugin.tgz" -const pluginWorkspace = "@codenomad/codenomad-opencode-plugin" -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm" - -function run(command, args, options) { - const result = spawnSync(command, args, { - stdio: options?.capture ? ["ignore", "pipe", "inherit"] : "inherit", - shell: process.platform === "win32", - encoding: "utf8", - ...options, - }) - - if (result.error) { - console.error(`[package-opencode-plugin] ${command} failed to start`, result.error) - process.exit(1) - } - - if (result.status !== 0) { - console.error(`[package-opencode-plugin] ${command} exited with code ${result.status ?? 1}`) - process.exit(result.status ?? 1) - } - - return result.stdout ?? "" -} - -rmSync(targetDir, { recursive: true, force: true }) -mkdirSync(targetDir, { recursive: true }) - -console.log(`[package-opencode-plugin] Building ${pluginWorkspace}`) -run(npmCommand, ["run", "build", "--workspace", pluginWorkspace], { cwd: workspaceRoot }) - -console.log(`[package-opencode-plugin] Packing ${pluginWorkspace}`) -run(npmCommand, ["pack", "--pack-destination", targetDir], { cwd: pluginRoot, capture: true }) - -const tarballs = readdirSync(targetDir).filter((name) => name.endsWith(".tgz")) -if (tarballs.length !== 1) { - console.error(`[package-opencode-plugin] Expected exactly one packed plugin tarball in ${targetDir}, found ${tarballs.length}`) - process.exit(1) -} - -const packedTarball = path.join(targetDir, tarballs[0]) -const targetTarball = path.join(targetDir, targetTarballName) -if (packedTarball !== targetTarball) { - renameSync(packedTarball, targetTarball) -} - -console.log(`[package-opencode-plugin] Packed ${targetTarball}`) diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d61b12f0e..9259e2997 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -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. @@ -40,9 +41,7 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string - binaryPath?: string requestId?: string - forceNew?: boolean } export interface WorkspaceCloneRequest { @@ -111,14 +110,6 @@ export interface WorktreeCreateRequest { branch?: string } -export interface WorktreeMap { - version: 1 - /** Default worktree to use for new sessions and as fallback. */ - defaultWorktreeSlug: string - /** Mapping of *parent* session IDs to a worktree slug. */ - parentSessionWorktreeSlug: Record -} - export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" export interface WorktreeGitStatusEntry { @@ -287,11 +278,7 @@ export interface InstanceData { export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected" -export interface InstanceStreamEvent { - type: string - properties?: Record - [key: string]: unknown -} +export type InstanceStreamEvent = OpenCodeEvent export type SideCarKind = "port" @@ -407,18 +394,10 @@ export interface SpeechSynthesisResponse { mimeType: string } -export interface VoiceModeStateResponse { - enabled: boolean -} - export interface YoloStateResponse { enabled: boolean } -export interface SessionMetadataResponse { - metadata: Record -} - export interface RemoteServerProfile { id: string name: string @@ -483,7 +462,7 @@ export type WorkspaceEventPayload = | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } - | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; generation: number; reason?: string } | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } @@ -545,37 +524,6 @@ export interface ServerMeta { update?: LatestReleaseInfo | null } -export type BackgroundProcessStatus = "running" | "stopped" | "error" - -export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated" - -export interface BackgroundProcess { - id: string - workspaceId: string - title: string - command: string - cwd: string - status: BackgroundProcessStatus - pid?: number - startedAt: string - stoppedAt?: string - exitCode?: number - outputSizeBytes?: number - terminalReason?: BackgroundProcessTerminalReason - notifyEnabled?: boolean -} - -export interface BackgroundProcessListResponse { - processes: BackgroundProcess[] -} - -export interface BackgroundProcessOutputResponse { - id: string - content: string - truncated: boolean - sizeBytes: number -} - export type { Preferences, ModelPreference, diff --git a/packages/server/src/background-processes/manager.test.ts b/packages/server/src/background-processes/manager.test.ts deleted file mode 100644 index 636994bc0..000000000 --- a/packages/server/src/background-processes/manager.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { promises as fs } from "node:fs" -import path from "node:path" -import os from "node:os" - -import { BackgroundProcessManager } from "./manager" -import type { WorkspaceManager } from "../workspaces/manager" -import type { EventBus } from "../events/bus" -import type { Logger } from "../logger" - -const WORKSPACE_ID = "ws-test" -const SESSION_ID = "sess-1" -const INSTANCE_PORT = 9999 -const AUTH_HEADER = "Basic test-auth" -const TERMINAL_TIMEOUT_MS = 3000 - -interface CapturedRequest { - method: string - url: string - headers: Headers - body: string -} - -/** - * Drives the real {@link BackgroundProcessManager} lifecycle (spawn a - * fast-exiting command with notify enabled) against a mocked transport, so the - * migrated `sendCompletionPrompt` path — factory + SDK client + `fetch` — is - * exercised end to end without touching production wiring. - * - * The workspace temp directory is intentionally left in place (under - * `os.tmpdir()`, OS-reaped): removing it from the test races the manager's - * asynchronous finalization writes, which intermittently fail with ENOENT. - */ -async function runCompletionPrompt( - fetchImpl: (input: Request, init: RequestInit | undefined) => Promise, -): Promise<{ requests: CapturedRequest[]; warned: boolean; directory: string }> { - const requests: CapturedRequest[] = [] - const originalFetch = globalThis.fetch - // Captured now but swapped in only inside the try below, so a failure during - // setup (mkdtemp, manager construction) can't leak the mocked fetch. - const fetchMock = (async (input: any, init: any) => { - const req = input instanceof Request ? input : new Request(String(input), init) - requests.push({ - method: req.method, - url: req.url, - headers: req.headers, - body: await req.text(), - }) - return fetchImpl(input instanceof Request ? input : req, init) - }) as typeof fetch - - let warned = false - const logger = { - warn: () => { warned = true }, - debug: () => {}, - trace: () => {}, - info: () => {}, - error: () => {}, - fatal: () => {}, - isLevelEnabled: () => false, - level: "info", - child: () => logger, - } as unknown as Logger - - const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "bp-test-")) - // Distinct from the workspace root so the directory-override assertion is - // discriminating: if `sendCompletionPrompt` stops passing `notify.directory`, - // the factory would fall back to `workspacePath` and the header check fails. - const sessionDir = path.join(workspacePath, "session-worktree") - - // Resolve once the manager publishes a terminal (non-running) status update. - let resolveTerminal: () => void = () => {} - const terminal = new Promise((resolve) => { resolveTerminal = resolve }) - const eventBus = { - on: () => {}, - publish: (event: any) => { - if (event?.type === "instance.event") { - const type = event?.event?.type - const status = event?.event?.properties?.process?.status - if (type === "background.process.removed" || (status && status !== "running")) resolveTerminal() - } - return true - }, - } as unknown as EventBus - - const workspaceManager = { - get: () => ({ path: workspacePath }), - getInstancePort: () => INSTANCE_PORT, - getInstanceAuthorizationHeader: () => AUTH_HEADER, - } as unknown as WorkspaceManager - - const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger }) - - try { - globalThis.fetch = fetchMock - await manager.start(WORKSPACE_ID, "test-proc", "true", { - notify: true, - notification: { sessionID: SESSION_ID, directory: sessionDir }, - }) - // The terminal status update is published at the very end of finalize, so - // resolving on it is a deterministic completion signal. Fail loudly rather - // than racing a silent timeout that could mask a hang. - let timeoutHandle: NodeJS.Timeout | undefined - const reachedTerminal = await Promise.race([ - terminal.then(() => true), - new Promise((resolve) => { - timeoutHandle = setTimeout(() => resolve(false), TERMINAL_TIMEOUT_MS) - }), - ]) - if (timeoutHandle) clearTimeout(timeoutHandle) - if (!reachedTerminal) { - throw new Error("background process did not reach a terminal state in time") - } - } finally { - globalThis.fetch = originalFetch - } - - return { requests, warned, directory: sessionDir } -} - -describe("BackgroundProcessManager.sendCompletionPrompt", () => { - it("posts the synthetic completion prompt to the instance via the SDK route", async () => { - const { requests, directory } = await runCompletionPrompt(async () => - new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), - ) - - const promptCall = requests.find((r) => r.url.includes("/prompt_async")) - assert.ok(promptCall, "expected a prompt_async request") - assert.equal(promptCall.method, "POST") - assert.equal( - promptCall.url, - `http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`, - ) - assert.equal(promptCall.headers.get("authorization"), AUTH_HEADER) - // The prompt is scoped to the session's directory (a POST keeps the - // directory as a header — the SDK only rewrites header→query for GET/HEAD). - assert.equal(promptCall.headers.get("x-opencode-directory"), encodeURIComponent(directory)) - - const body = JSON.parse(promptCall.body) - assert.equal(body.parts.length, 1) - assert.equal(body.parts[0].type, "text") - assert.equal(body.parts[0].synthetic, true) - assert.match(body.parts[0].text, /test-proc/) - }) - - it("swallows a failed prompt and logs it without aborting finalization", async () => { - const { warned } = await runCompletionPrompt(async () => - new Response("boom", { status: 500 }), - ) - assert.equal(warned, true) - }) -}) diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts deleted file mode 100644 index edcb53432..000000000 --- a/packages/server/src/background-processes/manager.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { spawn, spawnSync, type ChildProcess } from "child_process" -import { createWriteStream, existsSync, promises as fs } from "fs" -import path from "path" -import { randomBytes } from "crypto" -import type { EventBus } from "../events/bus" -import type { WorkspaceManager } from "../workspaces/manager" -import { createInstanceClient } from "../workspaces/instance-client" -import type { Logger } from "../logger" -import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types" - -const ROOT_DIR = ".codenomad/background_processes" -const INDEX_FILE = "index.json" -const OUTPUT_FILE = "output.txt" -const STOP_TIMEOUT_MS = 2000 -const EXIT_WAIT_TIMEOUT_MS = 5000 -const MAX_OUTPUT_BYTES = 20 * 1024 -const OUTPUT_PUBLISH_INTERVAL_MS = 1000 - -interface ManagerDeps { - workspaceManager: WorkspaceManager - eventBus: EventBus - logger: Logger -} - -interface RunningProcess { - id: string - child: ChildProcess - outputPath: string - exitPromise: Promise - workspaceId: string - completion?: ProcessCompletion -} - -interface ProcessCompletion { - reason: BackgroundProcessTerminalReason - endContext: "normal" | "workspace_cleanup" - removeAfterFinalize?: boolean -} - -interface BackgroundProcessNotificationState { - sessionID: string - directory: string - sentAt?: string -} - -interface PersistedBackgroundProcess extends BackgroundProcess { - notify?: BackgroundProcessNotificationState -} - -interface StartOptions { - notify?: boolean - notification?: { - sessionID: string - directory: string - } -} - -export class BackgroundProcessManager { - private readonly running = new Map() - - constructor(private readonly deps: ManagerDeps) { - this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId)) - this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id)) - } - - async list(workspaceId: string): Promise { - const records = await this.readIndex(workspaceId) - const enriched = await Promise.all( - records.map(async (record) => ({ - ...this.toPublicProcess(record), - outputSizeBytes: await this.getOutputSize(workspaceId, record.id), - })), - ) - return enriched - } - - async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise { - const workspace = this.deps.workspaceManager.get(workspaceId) - if (!workspace) { - throw new Error("Workspace not found") - } - - const id = this.generateId() - const processDir = await this.ensureProcessDir(workspaceId, id) - const outputPath = path.join(processDir, OUTPUT_FILE) - - const outputStream = createWriteStream(outputPath, { flags: "a" }) - - const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command) - - const child = spawn(shellCommand, shellArgs, { - cwd: workspace.path, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - ...spawnOptions, - }) - - child.on("exit", () => { - this.killProcessTree(child, "SIGTERM") - }) - - const record: PersistedBackgroundProcess = { - id, - workspaceId, - title, - command, - cwd: workspace.path, - status: "running", - pid: child.pid, - startedAt: new Date().toISOString(), - outputSizeBytes: 0, - notify: options.notify && options.notification - ? { - sessionID: options.notification.sessionID, - directory: options.notification.directory, - } - : undefined, - } - - const runningState: RunningProcess = { - id, - child, - outputPath, - exitPromise: Promise.resolve(), - workspaceId, - } - - const exitPromise = new Promise((resolve) => { - child.on("close", async (code) => { - await new Promise((resolve) => outputStream.end(resolve)) - this.running.delete(id) - - const completion = runningState.completion ?? this.completionFromExit(code) - - record.terminalReason = completion.reason - record.status = this.statusFromReason(completion.reason) - record.exitCode = code === null ? undefined : code - record.stoppedAt = new Date().toISOString() - - await this.finalizeRecord(workspaceId, record, completion) - resolve() - }) - }) - - runningState.exitPromise = exitPromise - - this.running.set(id, runningState) - - let lastPublishAt = 0 - const maybePublishSize = () => { - const now = Date.now() - if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) { - return - } - lastPublishAt = now - this.publishUpdate(workspaceId, record) - } - - child.stdout?.on("data", (data) => { - outputStream.write(data) - record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length - maybePublishSize() - }) - child.stderr?.on("data", (data) => { - outputStream.write(data) - record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length - maybePublishSize() - }) - - await this.upsertIndex(workspaceId, record) - record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) - this.publishUpdate(workspaceId, record) - return this.toPublicProcess(record) - } - - async stop(workspaceId: string, processId: string): Promise { - const record = await this.findProcess(workspaceId, processId) - if (!record) { - return null - } - - const running = this.running.get(processId) - if (running?.child && !running.child.killed) { - running.completion = { reason: "user_stopped", endContext: "normal" } - this.killProcessTree(running.child, "SIGTERM") - await this.waitForExit(running) - const updated = await this.findProcess(workspaceId, processId) - return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record) - } - - if (record.status === "running") { - record.status = "stopped" - record.terminalReason = "user_stopped" - record.stoppedAt = new Date().toISOString() - await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" }) - } - - return this.toPublicProcess(record) - } - - async terminate(workspaceId: string, processId: string): Promise { - const record = await this.findProcess(workspaceId, processId) - if (!record) return - - const running = this.running.get(processId) - if (running?.child && !running.child.killed) { - running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true } - this.killProcessTree(running.child, "SIGTERM") - await this.waitForExit(running) - return - } - - record.status = "stopped" - record.terminalReason = "user_terminated" - record.stoppedAt = new Date().toISOString() - await this.finalizeRecord(workspaceId, record, { - reason: "user_terminated", - endContext: "normal", - removeAfterFinalize: true, - }) - } - - async readOutput( - workspaceId: string, - processId: string, - options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number }, - ) { - const outputPath = this.getOutputPath(workspaceId, processId) - if (!existsSync(outputPath)) { - return { id: processId, content: "", truncated: false, sizeBytes: 0 } - } - - const stats = await fs.stat(outputPath) - const sizeBytes = stats.size - const method = options.method ?? "full" - const lineCount = options.lines ?? 10 - - const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes) - let content = raw - - switch (method) { - case "head": - content = this.headLines(raw, lineCount) - break - case "tail": - content = this.tailLines(raw, lineCount) - break - case "grep": - if (!options.pattern) { - throw new Error("Pattern is required for grep output") - } - content = this.grepLines(raw, options.pattern) - break - default: - content = raw - } - - const effectiveMaxBytes = options.maxBytes - return { - id: processId, - content, - truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes, - sizeBytes, - } - } - - async streamOutput(workspaceId: string, processId: string, reply: any) { - const outputPath = this.getOutputPath(workspaceId, processId) - if (!existsSync(outputPath)) { - reply.code(404).send({ error: "Output not found" }) - return - } - - reply.raw.setHeader("Content-Type", "text/event-stream") - reply.raw.setHeader("Cache-Control", "no-cache") - reply.raw.setHeader("Connection", "keep-alive") - reply.raw.flushHeaders?.() - reply.hijack() - - const file = await fs.open(outputPath, "r") - let position = (await file.stat()).size - - const tick = async () => { - const stats = await file.stat() - if (stats.size <= position) return - - const length = stats.size - position - const buffer = Buffer.alloc(length) - await file.read(buffer, 0, length, position) - position = stats.size - - const content = buffer.toString("utf-8") - reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`) - } - - const interval = setInterval(() => { - tick().catch((error) => { - this.deps.logger.warn({ err: error }, "Failed to stream background process output") - }) - }, 1000) - - const close = () => { - clearInterval(interval) - file.close().catch(() => undefined) - reply.raw.end?.() - } - - reply.raw.on("close", close) - reply.raw.on("error", close) - } - - private async cleanupWorkspace(workspaceId: string) { - for (const [, running] of this.running.entries()) { - if (running.workspaceId !== workspaceId) continue - running.completion = { - reason: "user_terminated", - endContext: "workspace_cleanup", - removeAfterFinalize: true, - } - this.killProcessTree(running.child, "SIGTERM") - await this.waitForExit(running) - } - - await this.removeWorkspaceDir(workspaceId) - } - - private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) { - const pid = child.pid - if (!pid) return - - if (process.platform === "win32") { - const args = this.buildWindowsTaskkillArgs(pid, signal) - try { - spawnSync("taskkill", args, { stdio: "ignore" }) - return - } catch { - // Fall back to killing the direct child. - } - } else { - try { - process.kill(-pid, signal) - return - } catch { - // Fall back to killing the direct child. - } - } - - try { - child.kill(signal) - } catch { - // ignore - } - } - - private async waitForExit(running: RunningProcess) { - let exited = false - const exitPromise = running.exitPromise.finally(() => { - exited = true - }) - - const killTimeout = setTimeout(() => { - if (!exited) { - this.killProcessTree(running.child, "SIGKILL") - } - }, STOP_TIMEOUT_MS) - - try { - await Promise.race([ - exitPromise, - new Promise((resolve) => { - setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS) - }), - ]) - - if (!exited) { - this.killProcessTree(running.child, "SIGKILL") - this.running.delete(running.id) - this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit") - } - } finally { - clearTimeout(killTimeout) - } - } - - - private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record } { - if (process.platform === "win32") { - const comspec = process.env.ComSpec || "cmd.exe" - return { - shellCommand: comspec, - shellArgs: ["/d", "/s", "/c", command], - spawnOptions: { windowsVerbatimArguments: true }, - } - } - - // Keep bash for macOS/Linux. - return { shellCommand: "bash", shellArgs: ["-c", command] } - } - - private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] { - // Default to graceful termination (no /F), then force kill when we escalate. - const force = signal === "SIGKILL" - const args = ["/PID", String(pid), "/T"] - if (force) { - args.push("/F") - } - return args - } - - private completionFromExit(code: number | null): ProcessCompletion { - if (code === 0) { - return { reason: "finished", endContext: "normal" } - } - - return { reason: "failed", endContext: "normal" } - } - - private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus { - if (reason === "failed") return "error" - return "stopped" - } - - private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise { - if (maxBytes === undefined || sizeBytes <= maxBytes) { - return await fs.readFile(outputPath, "utf-8") - } - - const start = Math.max(0, sizeBytes - maxBytes) - const file = await fs.open(outputPath, "r") - const buffer = Buffer.alloc(sizeBytes - start) - await file.read(buffer, 0, buffer.length, start) - await file.close() - return buffer.toString("utf-8") - } - - private headLines(input: string, lines: number): string { - const parts = input.split(/\r?\n/) - return parts.slice(0, Math.max(0, lines)).join("\n") - } - - private tailLines(input: string, lines: number): string { - const parts = input.split(/\r?\n/) - return parts.slice(Math.max(0, parts.length - lines)).join("\n") - } - - private grepLines(input: string, pattern: string): string { - let matcher: RegExp - try { - matcher = new RegExp(pattern) - } catch { - throw new Error("Invalid grep pattern") - } - return input - .split(/\r?\n/) - .filter((line) => matcher.test(line)) - .join("\n") - } - - private async ensureProcessDir(workspaceId: string, processId: string) { - const root = await this.ensureWorkspaceDir(workspaceId) - const processDir = path.join(root, processId) - await fs.mkdir(processDir, { recursive: true }) - return processDir - } - - private async ensureWorkspaceDir(workspaceId: string) { - const workspace = this.deps.workspaceManager.get(workspaceId) - if (!workspace) { - throw new Error("Workspace not found") - } - const root = path.join(workspace.path, ROOT_DIR, workspaceId) - await fs.mkdir(root, { recursive: true }) - return root - } - - private getOutputPath(workspaceId: string, processId: string) { - const workspace = this.deps.workspaceManager.get(workspaceId) - if (!workspace) { - throw new Error("Workspace not found") - } - return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE) - } - - private async findProcess(workspaceId: string, processId: string): Promise { - const records = await this.readIndex(workspaceId) - return records.find((entry) => entry.id === processId) ?? null - } - - private async readIndex(workspaceId: string): Promise { - const indexPath = await this.getIndexPath(workspaceId) - if (!existsSync(indexPath)) return [] - - try { - const raw = await fs.readFile(indexPath, "utf-8") - const parsed = JSON.parse(raw) - return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : [] - } catch { - return [] - } - } - - private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) { - const records = await this.readIndex(workspaceId) - const index = records.findIndex((entry) => entry.id === record.id) - if (index >= 0) { - records[index] = record - } else { - records.push(record) - } - await this.writeIndex(workspaceId, records) - } - - private async removeFromIndex(workspaceId: string, processId: string) { - const records = await this.readIndex(workspaceId) - const next = records.filter((entry) => entry.id !== processId) - await this.writeIndex(workspaceId, next) - } - - private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) { - const indexPath = await this.getIndexPath(workspaceId) - await fs.mkdir(path.dirname(indexPath), { recursive: true }) - await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) - } - - private async getIndexPath(workspaceId: string) { - const workspace = this.deps.workspaceManager.get(workspaceId) - if (!workspace) { - throw new Error("Workspace not found") - } - return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE) - } - - private async removeProcessDir(workspaceId: string, processId: string) { - const workspace = this.deps.workspaceManager.get(workspaceId) - if (!workspace) { - return - } - const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId) - await fs.rm(processDir, { recursive: true, force: true }) - } - - private async removeWorkspaceDir(workspaceId: string) { - const workspace = this.deps.workspaceManager.get(workspaceId) - if (!workspace) { - return - } - const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId) - await fs.rm(workspaceDir, { recursive: true, force: true }) - } - - private async getOutputSize(workspaceId: string, processId: string): Promise { - const outputPath = this.getOutputPath(workspaceId, processId) - if (!existsSync(outputPath)) { - return 0 - } - try { - const stats = await fs.stat(outputPath) - return stats.size - } catch { - return 0 - } - } - - private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) { - this.deps.eventBus.publish({ - type: "instance.event", - instanceId: workspaceId, - event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } }, - }) - } - - private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess { - return { - id: record.id, - workspaceId: record.workspaceId, - title: record.title, - command: record.command, - cwd: record.cwd, - status: record.status, - pid: record.pid, - startedAt: record.startedAt, - stoppedAt: record.stoppedAt, - exitCode: record.exitCode, - outputSizeBytes: record.outputSizeBytes, - terminalReason: record.terminalReason, - notifyEnabled: Boolean(record.notify), - } - } - - private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) { - if (this.shouldSendCompletionPrompt(record, completion)) { - try { - await this.sendCompletionPrompt(workspaceId, record) - if (record.notify) { - record.notify.sentAt = new Date().toISOString() - } - } catch (error) { - this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt") - } - } - - if (completion.removeAfterFinalize) { - await this.removeFromIndex(workspaceId, record.id) - await this.removeProcessDir(workspaceId, record.id) - - this.deps.eventBus.publish({ - type: "instance.event", - instanceId: workspaceId, - event: { type: "background.process.removed", properties: { processId: record.id } }, - }) - return - } - - await this.upsertIndex(workspaceId, record) - record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) - this.publishUpdate(workspaceId, record) - } - - private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) { - if (completion.endContext === "workspace_cleanup") return false - if (!record.notify) return false - return !record.notify.sentAt - } - - private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) { - const notify = record.notify - if (!notify || !record.terminalReason) return - - const client = createInstanceClient(this.deps.workspaceManager, workspaceId, { - directory: notify.directory, - }) - if (!client) { - throw new Error("Workspace instance is not ready") - } - - await client.session.promptAsync( - { - sessionID: notify.sessionID, - parts: [ - { - type: "text", - text: this.buildSyntheticCompletionPrompt(record), - synthetic: true, - }, - ], - }, - { throwOnError: true }, - ) - } - - private buildCompletionPrompt(record: PersistedBackgroundProcess): string { - const ref = `Background process "${record.title}" (${record.id})` - - switch (record.terminalReason) { - case "finished": - return `${ref} finished successfully.` - case "failed": - return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.` - case "user_stopped": - return `${ref} was stopped by user.` - case "user_terminated": - return `${ref} was terminated by user.` - } - - return `${ref} ended.` - } - - private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string { - return `${this.escapeTaggedText(this.buildCompletionPrompt(record))}` - } - - private escapeTaggedText(input: string): string { - return input - .replace(/&/g, "&") - .replace(//g, ">") - } - - private generateId(): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) - const random = randomBytes(3).toString("hex") - return `proc_${timestamp}_${random}` - } -} diff --git a/packages/server/src/clients/connection-manager.test.ts b/packages/server/src/clients/connection-manager.test.ts new file mode 100644 index 000000000..238df2fd8 --- /dev/null +++ b/packages/server/src/clients/connection-manager.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { Logger } from "../logger.ts" +import { ClientConnectionManager } from "./connection-manager" + +const logger = { debug() {}, warn() {} } as unknown as Logger + +describe("ClientConnectionManager", () => { + it("does not let an old unregister closure remove its replacement", () => { + const manager = new ClientConnectionManager(logger) + let replacementCloses = 0 + const oldUnregister = manager.register({ clientId: "client", connectionId: "window", close() {} }) + manager.register({ clientId: "client", connectionId: "window", close() { replacementCloses += 1 } }) + + oldUnregister() + assert.equal(manager.pong({ clientId: "client", connectionId: "window" }), true) + assert.equal(replacementCloses, 0) + manager.shutdown() + }) + + it("keeps delimiter-colliding identifier tuples independent", () => { + const manager = new ClientConnectionManager(logger) + manager.register({ clientId: "client:window", connectionId: "one", close() {} }) + manager.register({ clientId: "client", connectionId: "window:one", close() {} }) + + assert.equal(manager.pong({ clientId: "client:window", connectionId: "one" }), true) + assert.equal(manager.pong({ clientId: "client", connectionId: "window:one" }), true) + manager.shutdown() + }) +}) diff --git a/packages/server/src/clients/connection-manager.ts b/packages/server/src/clients/connection-manager.ts index 7eaa426ff..f348fb2a1 100644 --- a/packages/server/src/clients/connection-manager.ts +++ b/packages/server/src/clients/connection-manager.ts @@ -10,23 +10,15 @@ export interface ClientConnectionRef { export interface ClientConnectionRecord extends ClientConnectionRef { key: string - connectedAt: number lastSeenAt: number } -type ConnectionChangeEvent = { - type: "connected" | "disconnected" - connection: ClientConnectionRecord - reason?: string -} - interface RegisteredConnection extends ClientConnectionRecord { close: () => void } export class ClientConnectionManager { private readonly connections = new Map() - private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>() private readonly sweepTimer: NodeJS.Timeout constructor(private readonly logger: Logger) { @@ -41,11 +33,6 @@ export class ClientConnectionManager { } } - subscribe(listener: (event: ConnectionChangeEvent) => void): () => void { - this.subscribers.add(listener) - return () => this.subscribers.delete(listener) - } - register(input: ClientConnectionRef & { close: () => void }): () => void { const key = getConnectionKey(input) const now = Date.now() @@ -60,14 +47,12 @@ export class ClientConnectionManager { key, clientId: input.clientId, connectionId: input.connectionId, - connectedAt: now, lastSeenAt: now, close: input.close, } this.connections.set(key, connection) this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected") - this.notify({ type: "connected", connection }) - return () => this.disconnect(key, "closed") + return () => this.disconnect(key, "closed", true, connection) } pong(input: ClientConnectionRef): boolean { @@ -82,10 +67,6 @@ export class ClientConnectionManager { return true } - isConnected(input: ClientConnectionRef): boolean { - return this.connections.has(getConnectionKey(input)) - } - private sweepStaleConnections(): void { const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS for (const connection of Array.from(this.connections.values())) { @@ -95,9 +76,9 @@ export class ClientConnectionManager { } } - private disconnect(key: string, reason: string, invokeClose = true): void { + private disconnect(key: string, reason: string, invokeClose = true, expected?: RegisteredConnection): void { const connection = this.connections.get(key) - if (!connection) return + if (!connection || (expected && connection !== expected)) return this.connections.delete(key) this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected") @@ -108,21 +89,9 @@ export class ClientConnectionManager { this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection") } } - - this.notify({ type: "disconnected", connection, reason }) - } - - private notify(event: ConnectionChangeEvent): void { - for (const subscriber of this.subscribers) { - try { - subscriber(event) - } catch (error) { - this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed") - } - } } } function getConnectionKey(input: ClientConnectionRef): string { - return `${input.clientId}:${input.connectionId}` + return JSON.stringify([input.clientId, input.connectionId]) } diff --git a/packages/server/src/events/bus.test.ts b/packages/server/src/events/bus.test.ts index 71757ceb2..40ebf1c1f 100644 --- a/packages/server/src/events/bus.test.ts +++ b/packages/server/src/events/bus.test.ts @@ -7,20 +7,20 @@ import type { WorkspaceEventPayload } from "../api-types" describe("event bus instance status replay", () => { it("replays the latest instance status to a late subscriber", () => { const bus = new EventBus() - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" }) - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting", generation: 1 }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected", generation: 1 }) const received: WorkspaceEventPayload[] = [] bus.onEvent((event) => received.push(event)) assert.deepEqual(received, [ - { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }, + { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected", generation: 1 }, ]) }) it("delivers terminal disconnects live without replaying stopped workspaces", () => { const bus = new EventBus() - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected", generation: 1 }) const live: WorkspaceEventPayload[] = [] bus.onEvent((event) => live.push(event)) live.length = 0 @@ -29,6 +29,7 @@ describe("event bus instance status replay", () => { type: "instance.eventStatus", instanceId: "workspace-1", status: "disconnected", + generation: 1, reason: "workspace stopped", }) @@ -36,6 +37,7 @@ describe("event bus instance status replay", () => { type: "instance.eventStatus", instanceId: "workspace-1", status: "disconnected", + generation: 1, reason: "workspace stopped", }]) const replayed: WorkspaceEventPayload[] = [] diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3993ed7a6..a0f812886 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -29,8 +29,6 @@ import { SpeechService } from "./speech/service" import { SideCarManager } from "./sidecars/manager" import { PreviewManager } from "./previews/manager" import { ClientConnectionManager } from "./clients/connection-manager" -import { PluginChannelManager } from "./plugins/channel" -import { VoiceModeManager } from "./plugins/voice-mode" import { runCliUpgrade } from "./cli-upgrade" import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShutdownTrigger } from "./shutdown" import { AutoAcceptManager } from "./permissions/auto-accept-manager" @@ -362,8 +360,6 @@ async function main() { logger: logger.child({ component: "tls" }), }) - const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined - const settings = new SettingsService(configLocation, eventBus, configLogger) const binaryResolver = new BinaryResolver(settings) const workspaceManager = new WorkspaceManager({ @@ -372,8 +368,6 @@ async function main() { binaryResolver, eventBus, logger: workspaceLogger, - getServerBaseUrl: () => serverMeta.localUrl, - nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, @@ -388,11 +382,11 @@ async function main() { }) const previewManager = new PreviewManager() const yoloLogger = logger.child({ component: "yolo" }) - const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager) + const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager, settings) const yoloManager = new AutoAcceptManager({ eventBus, logger: yoloLogger, - replier: createOpencodePermissionReplier({ workspaceManager, logger: yoloLogger }), + replier: createOpencodePermissionReplier({ workspaceManager }), persistence: sessionMetadataPersistence, }) yoloManager.start() @@ -450,18 +444,11 @@ async function main() { const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" })) - const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" })) const remoteProxySessionManager = new RemoteProxySessionManager({ authManager, logger: logger.child({ component: "remote-proxy" }), httpsOptions: tlsResolution?.httpsOptions, }) - const voiceModeManager = new VoiceModeManager({ - connections: clientConnectionManager, - channel: pluginChannel, - logger: logger.child({ component: "voice-mode" }), - }) - const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) @@ -494,11 +481,8 @@ async function main() { previewManager, authManager, clientConnectionManager, - pluginChannel, - voiceModeManager, remoteProxySessionManager, yoloManager, - sessionMetadataPersistence, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -523,11 +507,8 @@ async function main() { previewManager, authManager, clientConnectionManager, - pluginChannel, - voiceModeManager, remoteProxySessionManager, yoloManager, - sessionMetadataPersistence, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, diff --git a/packages/server/src/opencode-plugin.test.ts b/packages/server/src/opencode-plugin.test.ts deleted file mode 100644 index dda5f9881..000000000 --- a/packages/server/src/opencode-plugin.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { buildOpencodeConfigContent } from "./opencode-plugin" - -describe("buildOpencodeConfigContent", () => { - it("creates config content with the CodeNomad plugin", () => { - const content = buildOpencodeConfigContent(undefined, "file:///plugin.tgz") - - assert.deepEqual(JSON.parse(content), { - "$schema": "https://opencode.ai/config.json", - plugin: ["file:///plugin.tgz"], - }) - }) - - it("merges with existing JSONC content", () => { - const content = buildOpencodeConfigContent( - `{ - // user plugin - "plugin": ["npm:user-plugin",], - "model": "test-model", - }`, - "file:///plugin.tgz", - ) - - assert.deepEqual(JSON.parse(content), { - "$schema": "https://opencode.ai/config.json", - plugin: ["npm:user-plugin", "file:///plugin.tgz"], - model: "test-model", - }) - }) - - it("does not duplicate the CodeNomad plugin", () => { - const content = buildOpencodeConfigContent('{"plugin":["file:///plugin.tgz"]}', "file:///plugin.tgz") - - assert.deepEqual(JSON.parse(content).plugin, ["file:///plugin.tgz"]) - }) -}) diff --git a/packages/server/src/opencode-plugin.ts b/packages/server/src/opencode-plugin.ts deleted file mode 100644 index 761292da5..000000000 --- a/packages/server/src/opencode-plugin.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { existsSync, readdirSync } from "fs" -import path from "path" -import { fileURLToPath, pathToFileURL } from "url" -import { createLogger } from "./logger" - -const log = createLogger({ component: "opencode-plugin" }) -const pluginPackageName = "@codenomad/codenomad-opencode-plugin" -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath -const devPluginEntry = path.resolve(__dirname, "../../opencode-plugin/plugin/codenomad.ts") -const prodPluginDirs = [ - resourcesPath ? path.resolve(resourcesPath, "opencode-plugin") : undefined, - resourcesPath ? path.resolve(resourcesPath, "server/dist/opencode-plugin") : undefined, - path.resolve(__dirname, "opencode-plugin"), -].filter((dir): dir is string => Boolean(dir)) - -const isDevBuild = Boolean( - process.env.CODENOMAD_DEV ?? - process.env.CLI_UI_DEV_SERVER ?? - process.env.VITE_DEV_SERVER_URL ?? - process.env.ELECTRON_RENDERER_URL, -) -const isSourceRun = path.basename(__dirname) === "src" && existsSync(devPluginEntry) - -export function getCodeNomadPluginUrl(): string { - if (isDevBuild || isSourceRun) { - if (!existsSync(devPluginEntry)) { - throw new Error(`CodeNomad OpenCode plugin entry missing at ${devPluginEntry}`) - } - - log.debug({ pluginEntry: devPluginEntry }, "Using OpenCode plugin source directly (dev mode)") - return pathToFileURL(devPluginEntry).href - } - - for (const dir of prodPluginDirs) { - const tarball = findPluginTarball(dir) - if (tarball) { - return toNpmFileSpecifier(tarball) - } - } - - throw new Error(`CodeNomad OpenCode plugin package missing in ${prodPluginDirs.join(", ")}`) -} - -export function buildOpencodeConfigContent(existingContent: string | undefined, pluginUrl: string): string { - const config = existingContent?.trim() ? parseJsoncObject(existingContent) : {} - const existingPlugins = normalizePluginEntries(config.plugin) - if (!existingPlugins.includes(pluginUrl)) { - existingPlugins.push(pluginUrl) - } - return JSON.stringify( - { - "$schema": typeof config["$schema"] === "string" ? config["$schema"] : "https://opencode.ai/config.json", - ...config, - plugin: existingPlugins, - }, - null, - 2, - ) -} - -export function resolveExistingOpencodeConfigContent(userEnvironment: Record): string | undefined { - const userValue = normalizeConfigContentValue(userEnvironment.OPENCODE_CONFIG_CONTENT) - if (userValue) { - return userValue - } - return normalizeConfigContentValue(process.env.OPENCODE_CONFIG_CONTENT) -} - -function toNpmFileSpecifier(filePath: string): string { - return `${pluginPackageName}@file:${filePath.replace(/\\/g, "/")}` -} - -function findPluginTarball(dir: string): string | null { - if (!existsSync(dir)) { - return null - } - - const tarballs = readdirSync(dir) - .filter((name) => name.endsWith(".tgz")) - .sort() - return tarballs.length > 0 ? path.resolve(dir, tarballs[tarballs.length - 1]) : null -} - -function normalizeConfigContentValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined -} - -function parseJsoncObject(content: string): Record { - try { - const parsed = JSON.parse(stripJsonc(content)) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("OPENCODE_CONFIG_CONTENT must be a JSON object") - } - return parsed as Record - } catch (error) { - const reason = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to parse OPENCODE_CONFIG_CONTENT: ${reason}`) - } -} - -function normalizePluginEntries(value: unknown): string[] { - if (value === undefined) { - return [] - } - if (typeof value === "string") { - return [value] - } - if (Array.isArray(value) && value.every((item) => typeof item === "string")) { - return [...value] - } - throw new Error("OPENCODE_CONFIG_CONTENT plugin field must be a string or string array") -} - -function stripJsonc(input: string): string { - let output = "" - let inString = false - let escape = false - - for (let index = 0; index < input.length; index += 1) { - const char = input[index] - const next = input[index + 1] - - if (escape) { - output += char - escape = false - continue - } - - if (char === "\\" && inString) { - output += char - escape = true - continue - } - - if (char === '"') { - output += char - inString = !inString - continue - } - - if (!inString && char === "/" && next === "/") { - while (index < input.length && input[index] !== "\n") { - index += 1 - } - output += "\n" - continue - } - - if (!inString && char === "/" && next === "*") { - index += 2 - while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) { - output += input[index] === "\n" ? "\n" : "" - index += 1 - } - index += 1 - continue - } - - if (!inString && char === ",") { - let lookahead = index + 1 - while (lookahead < input.length && /\s/.test(input[lookahead])) { - lookahead += 1 - } - if (input[lookahead] === "}" || input[lookahead] === "]") { - continue - } - } - - output += char - } - - return output -} diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 2b6315292..591747288 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -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, + TARGET_OPENCODE_VERSION, + buildOpenCodeUpgradeCommand, + compareOpenCodeVersionStrings, + detectOpenCodePackageManager, + type OpenCodeUpdateServiceDeps, +} from "./service" function createDeps(overrides: Partial = {}): OpenCodeUpdateServiceDeps { - let currentVersion = "1.0.0" + let currentVersion = "0.0.0-beta-1" return { resolveBinary: () => ({ path: "opencode", label: "OpenCode" }), probeBinary: () => ({ valid: true, version: currentVersion }), - findReadyInstanceId: () => "workspace-1", - fetchLatestVersion: async () => "1.1.0", - upgradeInstance: async (_instanceId, target) => { + canUpgradeBinary: () => true, + upgradeBinary: async (_binary, target) => { currentVersion = target return { success: true, version: target } }, @@ -17,72 +24,48 @@ function createDeps(overrides: Partial = {}): OpenCod } } -test("reports an available update and caches the latest version", async () => { - let checks = 0 - const service = new OpenCodeUpdateService(createDeps({ - fetchLatestVersion: async () => { - checks += 1 - return "1.1.0" - }, - })) +test("reports only the startup-compatible pinned version", async () => { + const service = new OpenCodeUpdateService(createDeps()) assert.deepEqual(await service.getStatus(), { - currentVersion: "1.0.0", - latestVersion: "1.1.0", + currentVersion: "0.0.0-beta-1", + latestVersion: TARGET_OPENCODE_VERSION, updateAvailable: true, canUpgrade: true, }) - await service.getStatus() - 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", - latestVersion: "1.1.0", + currentVersion: "0.0.0-beta-1", + latestVersion: TARGET_OPENCODE_VERSION, updateAvailable: true, canUpgrade: false, }) }) -test("preserves the installed version when the registry check fails", async () => { - const service = new OpenCodeUpdateService(createDeps({ - fetchLatestVersion: async () => { - throw new Error("registry unavailable") - }, - })) - - assert.deepEqual(await service.getStatus(), { - currentVersion: "1.0.0", - latestVersion: null, - updateAvailable: null, - canUpgrade: false, - checkError: "update_check_failed", - }) -}) - -test("upgrades through the matching OpenCode instance to the advertised version", async () => { - const calls: Array<{ instanceId: string; target: string }> = [] - let currentVersion = "1.0.0" +test("upgrades the managed OpenCode binary to the advertised version", async () => { + const calls: Array<{ path: string; target: string }> = [] + let currentVersion = "0.0.0-beta-1" 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(await service.upgrade(), { success: true, version: TARGET_OPENCODE_VERSION }) + assert.deepEqual(calls, [{ path: "opencode", target: TARGET_OPENCODE_VERSION }]) }) 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 }), + probeBinary: () => ({ valid: true, version: "0.0.0-beta-1" }), + upgradeBinary: async (_binary, target) => ({ success: true, version: target }), })) await assert.rejects( @@ -92,7 +75,7 @@ test("rejects success when the configured binary was not updated", async () => { }) test("joins concurrent upgrades for the same binary", async () => { - let currentVersion = "1.0.0" + let currentVersion = "0.0.0-beta-1" let upgrades = 0 let finishUpgrade: (() => void) | undefined const gate = new Promise((resolve) => { @@ -100,7 +83,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 @@ -113,17 +96,46 @@ test("joins concurrent upgrades for the same binary", async () => { finishUpgrade?.() assert.deepEqual(await Promise.all([first, second]), [ - { success: true, version: "1.1.0" }, - { success: true, version: "1.1.0" }, + { success: true, version: TARGET_OPENCODE_VERSION }, + { success: true, version: TARGET_OPENCODE_VERSION }, ]) 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) +}) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index c84167d2d..4fb9c8bb0 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -1,15 +1,16 @@ -import { fetch } from "undici" +import { spawn } from "node:child_process" +import { createRequire } from "node:module" import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types" import type { SettingsService } from "../settings/service" import { BinaryResolver, type ResolvedBinary } from "../settings/binaries" import type { WorkspaceManager } from "../workspaces/manager" -import { createInstanceClient } from "../workspaces/instance-client" import { probeBinaryVersion } from "../workspaces/spawn" import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor" -const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest" -const LATEST_VERSION_CACHE_MS = 5 * 60_000 -const UPGRADE_TIMEOUT_MS = 10 * 60_000 +const OPENCODE_PACKAGE_NAME = "@opencode-ai/cli" +const require = createRequire(import.meta.url) +const packageJson = require("../../package.json") as { dependencies: { "@opencode-ai/client": string } } +export const TARGET_OPENCODE_VERSION = packageJson.dependencies["@opencode-ai/client"] const inFlightUpgrades = new Map>() type UpgradeResult = { success: true; version: string } | { success: false; error: string } @@ -17,17 +18,15 @@ 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 - fetchLatestVersion: () => Promise - now?: () => number + canUpgradeBinary: (binary: ResolvedBinary) => boolean + upgradeBinary: (binary: ResolvedBinary, target: string) => Promise } export class OpenCodeUpdateError extends Error { constructor( readonly code: | "binary_unavailable" - | "no_ready_instance" + | "unsupported_binary" | "update_check_failed" | "upgrade_failed" | "upgrade_verification_failed", @@ -39,34 +38,20 @@ export class OpenCodeUpdateError extends Error { } export class OpenCodeUpdateService { - private latestVersionCache: { version: string; expiresAt: number } | null = null - constructor(private readonly deps: OpenCodeUpdateServiceDeps) {} async getStatus(): Promise { const binary = this.deps.resolveBinary() const currentVersion = this.readCurrentVersion(binary.path) - let latestVersion: string - try { - latestVersion = await this.readLatestVersion() - } catch (error) { - if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed") throw error - return { - currentVersion, - latestVersion: null, - updateAvailable: null, - canUpgrade: false, - checkError: "update_check_failed", - } - } - const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0 - const readyInstanceId = this.deps.findReadyInstanceId(binary.path) + const latestVersion = TARGET_OPENCODE_VERSION + const updateAvailable = compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 + const canUpgrade = this.deps.canUpgradeBinary(binary) return { currentVersion, latestVersion, updateAvailable, - canUpgrade: updateAvailable && Boolean(readyInstanceId), + canUpgrade: updateAvailable && canUpgrade, } } @@ -84,27 +69,26 @@ export class OpenCodeUpdateService { private async performUpgrade(binary: ResolvedBinary): Promise { const currentVersion = this.readCurrentVersion(binary.path) - const latestVersion = await this.readLatestVersion() + const latestVersion = TARGET_OPENCODE_VERSION - 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}`, @@ -131,42 +115,76 @@ export class OpenCodeUpdateService { } return version } +} - private async readLatestVersion(): Promise { - const now = (this.deps.now ?? Date.now)() - if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) { - return this.latestVersionCache.version - } +export type OpenCodePackageManager = "npm" | "pnpm" | "bun" | "yarn" - try { - const version = stripTagPrefix(await this.deps.fetchLatestVersion()) - if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { - throw new Error("OpenCode registry returned an invalid version") - } - this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS } - return version - } catch (error) { - throw new OpenCodeUpdateError( - "update_check_failed", - error instanceof Error ? error.message : "Unable to check the latest OpenCode version", - ) - } - } +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 async function fetchLatestOpenCodeVersion(): Promise { - const response = await fetch(OPENCODE_LATEST_URL, { - headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" }, - signal: AbortSignal.timeout(10_000), - }) - if (!response.ok) { - throw new Error(`OpenCode registry responded with ${response.status}`) +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] } } - const payload = (await response.json()) as { version?: unknown } - if (typeof payload.version !== "string") { - throw new Error("OpenCode registry response did not include a version") + if (packageManager === "yarn") { + return { command: "yarn", args: ["global", "add", packageSpec] } } - return payload.version + return { command: "npm", args: ["install", "-g", packageSpec] } +} + +export function installOpenCodeCli( + binary: ResolvedBinary, + version: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + 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( @@ -180,15 +198,7 @@ export function createOpenCodeUpdateService( return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) } }, probeBinary: probeBinaryVersion, - findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath), - fetchLatestVersion: fetchLatestOpenCodeVersion, - upgradeInstance: async (instanceId, target) => { - const client = createInstanceClient(workspaceManager, instanceId, { timeoutMs: UPGRADE_TIMEOUT_MS }) - if (!client) { - throw new OpenCodeUpdateError("no_ready_instance", "OpenCode instance is not ready") - } - const { data } = await client.global.upgrade({ target }, { throwOnError: true }) - return data - }, + canUpgradeBinary: () => binaryResolver.resolveDefault().path === "opencode2", + upgradeBinary: installOpenCodeCli, }) } diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index d803587be..26ccc73e4 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -21,70 +21,74 @@ const noopLogger: Logger = { } as unknown as Logger function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { - bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) + const type = event.type === "permission.v2.asked" + ? "permission.asked" + : event.type === "permission.v2.replied" + ? "permission.replied" + : event.type + const { properties, ...nativeEvent } = event + const wrapped = properties as { info?: Record } | 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, ) { - 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() + 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) - 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) - + publishSession(bus, "wrong-owner", "session.updated", { id: "session", parentID: null }) + await flushMicrotasks() + assert.equal(replier.calls.length, 1) manager.stop() }) - it("treats a session with revert as a fork root", () => { + 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 }) - publishSession(bus, "inst", "session.updated", { - id: "fork", - parentID: "master", - revert: { messageID: "m", partID: "p" }, + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "foreign-permission", sessionID: "foreign-session" }, }) + await flushMicrotasks() - manager.toggle("inst", "fork") - assert.equal(manager.isEnabled("inst", "fork"), true) - assert.equal(manager.isEnabled("inst", "master"), false) - + assert.equal(replier.calls.length, 0) manager.stop() }) - it("session.deleted removes the tree entry but keeps the toggle", () => { - const bus = new EventBus(noopLogger) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - 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) - - 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[] = [] @@ -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 () => { + it("replies once when duplicate logical workspaces receive the same native permission", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() - const accepted: Record[] = [] - 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 () => { - 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[] = [] - 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) diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index 3fc7e7e60..e8d8ae429 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -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 @@ -32,7 +27,6 @@ export type PermissionReplier = (reply: AutoAcceptReply) => Promise interface PendingPermission { permissionId: string sessionId: string - source: PermissionSource } interface AutoAcceptManagerDeps { @@ -44,33 +38,27 @@ interface AutoAcceptManagerDeps { export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo { yoloEnabled: boolean - workspaceId?: string } export interface AutoAcceptPersistence { loadSessions(instanceId: string): Promise - persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise + loadSession?(instanceId: string, sessionId: string): Promise + persist(instanceId: string, rootSessionId: string, enabled: boolean): Promise } -const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) -const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) -const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) -const 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() /** instanceId -> (permissionId -> pending permission) awaiting a reply */ private readonly pending = new Map>() - /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */ + /** Native permission id -> failure count, to stop retrying stuck permissions. */ private readonly replyAttempts = new Map() private readonly hydratedInstances = new Set() private readonly hydration = new Map>() private readonly queuedEvents = new Map() private readonly instanceGeneration = new Map() - private readonly sessionWorkspaces = new Map>() private readonly mutations = new Map>() private unsubscribe?: () => void @@ -134,12 +122,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() 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 +162,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 +175,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 +187,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,68 +224,68 @@ 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) + if (event.type === "session.created") { + 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) + if (event.type === "session.deleted") { + 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) } return } - if (PERMISSION_REPLIED_TYPES.has(event.type)) { - this.handlePermissionReplied(instanceId, event.properties) + if (event.type === "permission.replied") { + this.handlePermissionReplied(instanceId, event.data) return } - if (PERMISSION_ASK_TYPES.has(event.type)) { - this.handlePermissionRequest(instanceId, event.type, event.properties) + if (event.type === "permission.asked") { + 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() - 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 +300,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 +321,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 } + if (!this.store.isEnabled(instanceId, sessionId)) return + this.tryAutoAccept(instanceId, permissionId, sessionId) + } - this.addPending(instanceId, { permissionId, sessionId, source }) + private async hydrateSession(instanceId: string, sessionId: string): Promise { + try { + const session = await this.deps.persistence?.loadSession?.(instanceId, sessionId) + if (!session || !Array.from(this.pending.get(instanceId)?.values() ?? []).some((entry) => entry.sessionId === sessionId)) return + this.ingestPersistedSession(instanceId, session) + this.drainPending(instanceId, sessionId) + } catch (error) { + this.deps.logger.warn({ instanceId, sessionId, err: error }, "Failed to hydrate Yolo permission session") + } + } - if (!this.store.isEnabled(instanceId, sessionId)) return - this.tryAutoAccept(instanceId, permissionId, sessionId, source) + private ingestPersistedSession(instanceId: string, session: PersistedAutoAcceptSession): void { + this.store.upsertSession(instanceId, session) } private handlePermissionReplied(instanceId: string, properties: unknown): void { @@ -373,27 +368,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 +401,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 +419,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 +443,15 @@ export class AutoAcceptManager { interface InstanceStreamPayload { type?: string - properties?: Record + data?: unknown } interface SessionProperties { id?: string + sessionID?: string parentID?: string | null parentId?: string | null - revert?: unknown - workspaceID?: string + fork?: unknown } interface PermissionProperties { diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts index 5ccfd5409..9ef1e44c0 100644 --- a/packages/server/src/permissions/auto-accept-store.test.ts +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -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) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts index b53e62585..500b86c44 100644 --- a/packages/server/src/permissions/auto-accept-store.ts +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -5,18 +5,17 @@ * (`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 - * through OpenCode session metadata. + * through CodeNomad's state store. */ 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. */ diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts new file mode 100644 index 000000000..8671b1d63 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient } from "@opencode-ai/client" + +import type { WorkspaceManager } from "../workspaces/manager" +import { createOpencodePermissionReplier } from "./opencode-replier" + +describe("createOpencodePermissionReplier", () => { + it("does not reply across logical workspace ownership", async () => { + const calls: Array> = [] + const client = { + session: { get: async () => ({ location: { directory: "/other" } }) }, + permission: { reply: async (input: Record) => { calls.push(input) } }, + } as unknown as OpenCodeClient + const workspaceManager = { + get: () => ({ path: "/repo" }), + getSharedServiceClient: async () => client, + ownsDirectory: async () => false, + } as unknown as WorkspaceManager + const replier = createOpencodePermissionReplier({ workspaceManager }) + + await assert.rejects(replier({ + instanceId: "instance", + sessionId: "foreign-session", + permissionId: "permission", + }), /does not belong/) + assert.deepEqual(calls, []) + }) +}) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 2ed9bfc9e..06a5f9970 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -1,47 +1,31 @@ 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 } /** * Default {@link PermissionReplier} that calls the OpenCode instance via the - * generated SDK client over loopback, using the same `"once"` reply the UI - * previously sent. - * - * Uses `createInstanceClient` so routes and body shapes are always correct - * for the installed SDK version — no hand-assembled URLs. + * native Promise client, using the same `"once"` reply the UI previously sent. */ export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { return async (reply: AutoAcceptReply) => { - const client = createInstanceClient(deps.workspaceManager, reply.instanceId) + const client = await createInstanceClient(deps.workspaceManager, reply.instanceId) if (!client) { - throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + throw new Error(`Yolo: instance ${reply.instanceId} is not ready`) } - const opts = { throwOnError: true } as const - - if (reply.source === "v2") { - await client.v2.session.permission.reply( - { - sessionID: reply.sessionId, - requestID: reply.permissionId, - reply: reply.reply, - }, - opts, - ) - } else { - await client.permission.reply( - { - requestID: reply.permissionId, - reply: reply.reply, - }, - opts, - ) + 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: "once", + }) } } diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index d31d56523..a71709988 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -1,61 +1,119 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { createOpencodeYoloPersistence, hasPersistedYolo, mergePersistedYolo } from "./opencode-yolo-metadata" +import type { OpenCodeClient } from "@opencode-ai/client" -describe("OpenCode Yolo metadata", () => { - it("preserves unrelated metadata while replacing Yolo state", () => { - assert.deepEqual( - mergePersistedYolo({ thirdParty: { keep: true }, codenomad: { version: 1, worktreeSlug: "feature" } }, "root", true), +import type { SettingsService } from "../settings/service" +import type { WorkspaceManager } from "../workspaces/manager" +import { createOpencodeYoloPersistence } from "./opencode-yolo-metadata" + +function createHarness(serviceDirectory = "/repo") { + let owner: Record = {} + const settings = { + getOwner: () => owner, + mergePatchOwner: (_kind: string, _owner: string, patch: { sessions: Record }) => { + owner = { + ...owner, + sessions: { ...((owner.sessions as Record) ?? {}), ...patch.sessions }, + } + return owner + }, + } as unknown as SettingsService + const listInputs: Record[] = [] + 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) { + listInputs.push(input) + const cursor = input.cursor + return { + data: cursor ? [ + { + id: "second-page", + parentID: undefined, + fork: undefined, + location: { directory: "/repo", workspaceID: "workspace" }, + }, + ] : [ + { + id: "root", + parentID: undefined, + fork: undefined, + location: { directory: "/repo", workspaceID: "workspace" }, + }, + ], + 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", + }, + } + }, + }, + } as unknown as OpenCodeClient + const persistence = createOpencodeYoloPersistence( + workspaceManager, + settings, + async () => client, + ) + return { persistence, listInputs } +} + +describe("OpenCode Yolo persistence", () => { + it("loads native sessions and Yolo state from the CodeNomad store", async () => { + const { persistence, listInputs } = createHarness() + await persistence.persist("instance", "root", true) + + assert.deepEqual(await persistence.loadSessions("instance"), [ + { + id: "root", + parentId: null, + fork: undefined, + yoloEnabled: true, + }, { - thirdParty: { keep: true }, - codenomad: { version: 1, worktreeSlug: "feature", yolo: { enabled: true, rootSessionId: "root" } }, + 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("accepts only a marker owned by its session", () => { - const metadata = mergePersistedYolo({}, "root", true) - assert.equal(hasPersistedYolo("root", metadata), true) - assert.equal(hasPersistedYolo("fork", metadata), false) - assert.equal(hasPersistedYolo("root", mergePersistedYolo({}, "root", false)), false) + 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("uses the session workspace for metadata updates", async () => { - const calls: Array> = [] - const client = { - session: { - async list() { return { data: [{ id: "root", parentID: null, workspaceID: "workspace", metadata: {} }] } }, - async get(parameters: Record) { calls.push(parameters); return { data: { metadata: {} } } }, - async update(parameters: Record) { calls.push(parameters); return { data: {} } }, - }, - } - const persistence = createOpencodeYoloPersistence({} as never, () => client as never) - const [session] = await persistence.loadSessions("instance") - await persistence.persist("instance", "root", true, session?.workspaceId) - assert.equal(session?.workspaceId, "workspace") - assert.equal(calls[0]?.workspace, "workspace") - assert.equal(calls[1]?.workspace, "workspace") - }) + it("restores a persisted Yolo session from an owned worktree", async () => { + const { persistence } = createHarness() + await persistence.persist("instance", "worktree", true) - it("serializes Yolo and worktree metadata writes across instances", async () => { - let metadata: Record = { thirdParty: true } - const client = { - session: { - async get() { return { data: { metadata } } }, - async update(parameters: Record) { - metadata = parameters.metadata as Record - return { data: { metadata } } - }, - }, - } - const persistence = createOpencodeYoloPersistence({} as never, () => client as never) - await Promise.all([ - persistence.persist("instance-a", "root", true), - persistence.setWorktreeSlug("instance-b", "root", "feature"), - ]) - assert.deepEqual(metadata, { - thirdParty: true, - codenomad: { version: 1, yolo: { enabled: true, rootSessionId: "root" }, worktreeSlug: "feature" }, + 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, }) }) + }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index bca929015..eee343f62 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -1,111 +1,110 @@ -import type { OpencodeClient } from "@opencode-ai/sdk/v2/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" import type { AutoAcceptPersistence, PersistedAutoAcceptSession } from "./auto-accept-manager" -const CODENOMAD_METADATA_VERSION = 1 const SESSION_LIST_LIMIT = 10_000 +const STATE_OWNER = "codenomad" type Metadata = Record -export interface OpencodeYoloPersistence extends AutoAcceptPersistence { - hasProjectSession(instanceId: string, sessionId: string): Promise - setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise +interface PersistedSessionState { + yoloEnabled?: boolean } +export type OpencodeYoloPersistence = AutoAcceptPersistence + function record(value: unknown): Metadata { return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Metadata) } : {} } -export function hasPersistedYolo(sessionId: string, metadata: unknown): boolean { - const codenomad = record(record(metadata).codenomad) - const yolo = record(codenomad.yolo) - return codenomad.version === CODENOMAD_METADATA_VERSION - && yolo.enabled === true - && yolo.rootSessionId === sessionId +function sessionState(settings: SettingsService, sessionId: string): PersistedSessionState { + const sessions = record(settings.getOwner("state", STATE_OWNER).sessions) + return record(sessions[sessionId]) as PersistedSessionState } -export function mergePersistedYolo(metadata: unknown, rootSessionId: string, enabled: boolean): Metadata { - const current = record(metadata) - const codenomad = record(current.codenomad) - return { - ...current, - codenomad: { - ...codenomad, - version: CODENOMAD_METADATA_VERSION, - yolo: { enabled, rootSessionId }, - }, - } -} - -export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string): Metadata { - const current = record(metadata) - const codenomad = record(current.codenomad) - return { - ...current, - codenomad: { ...codenomad, version: CODENOMAD_METADATA_VERSION, worktreeSlug }, - } +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, - createClient: (manager: WorkspaceManager, instanceId: string) => OpencodeClient | null = createInstanceClient, + settings: SettingsService, + createClient: (manager: WorkspaceManager, instanceId: string) => Promise = createInstanceClient, ): OpencodeYoloPersistence { const writes = new Map>() - const clientFor = (instanceId: string) => { - const client = createClient(workspaceManager, instanceId) - if (!client) throw new Error(`Yolo: instance ${instanceId} has no open port`) + const clientFor = async (instanceId: string) => { + const client = await createClient(workspaceManager, instanceId) + if (!client) throw new Error(`Yolo: instance ${instanceId} is not ready`) return client } - const updateMetadata = ( - instanceId: string, + const listSessions = async (instanceId: string) => { + const workspace = workspaceManager.get(instanceId) + if (!workspace) throw new Error(`Yolo: instance ${instanceId} is not ready`) + 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, - workspaceId: string | undefined, - update: (metadata: unknown) => Metadata, - ): Promise => { - const writeKey = sessionId - const write = (writes.get(writeKey) ?? Promise.resolve()).catch(() => undefined).then(async () => { - const client = clientFor(instanceId) - const scope = { sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}) } - const { data: session } = await client.session.get(scope, { throwOnError: true }) - const metadata = update(session.metadata) - const { data } = await client.session.update({ ...scope, metadata }, { throwOnError: true }) - return record(data?.metadata ?? metadata) + enabled: boolean, + ): Promise => { + const write = (writes.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(() => { + const next = { ...sessionState(settings, sessionId), yoloEnabled: enabled } + settings.mergePatchOwner("state", STATE_OWNER, { sessions: { [sessionId]: next } }) }) const settled = write.finally(() => { - if (writes.get(writeKey) === settled) writes.delete(writeKey) + if (writes.get(sessionId) === settled) writes.delete(sessionId) }) - writes.set(writeKey, settled) + writes.set(sessionId, settled) return settled } + return { async loadSessions(instanceId): Promise { - const { data } = await clientFor(instanceId).session.list( - { scope: "project", limit: SESSION_LIST_LIMIT }, - { throwOnError: true }, - ) - return (data ?? []).map((session) => ({ - id: session.id, - parentId: session.parentID ?? null, - revert: session.revert, - workspaceId: session.workspaceID, - yoloEnabled: hasPersistedYolo(session.id, session.metadata), + 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) }, - persist(instanceId, rootSessionId, enabled, workspaceId): Promise { - return updateMetadata(instanceId, rootSessionId, workspaceId, - (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined) - }, - async hasProjectSession(instanceId, sessionId): Promise { - const { data } = await clientFor(instanceId).session.list( - { scope: "project", limit: SESSION_LIST_LIMIT }, - { throwOnError: true }, - ) - return (data ?? []).some((session) => session.id === sessionId) + async loadSession(instanceId, sessionId): Promise { + 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 + } }, - setWorktreeSlug(instanceId, sessionId, worktreeSlug): Promise { - return updateMetadata(instanceId, sessionId, undefined, - (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug)) + persist(_instanceId, rootSessionId, enabled): Promise { + return updateYolo(rootSessionId, enabled) }, } } diff --git a/packages/server/src/plugins/channel.ts b/packages/server/src/plugins/channel.ts deleted file mode 100644 index c4d645ea2..000000000 --- a/packages/server/src/plugins/channel.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { FastifyReply } from "fastify" -import type { Logger } from "../logger" - -export interface PluginOutboundEvent { - type: string - properties?: Record -} - -interface ClientConnection { - reply: FastifyReply - workspaceId: string -} - -export class PluginChannelManager { - private readonly clients = new Set() - - constructor(private readonly logger: Logger) {} - - register(workspaceId: string, reply: FastifyReply) { - const connection: ClientConnection = { workspaceId, reply } - this.clients.add(connection) - this.logger.debug({ workspaceId }, "Plugin SSE client connected") - - let closed = false - const close = () => { - if (closed) return - closed = true - this.clients.delete(connection) - this.logger.debug({ workspaceId }, "Plugin SSE client disconnected") - } - - return { close } - } - - send(workspaceId: string, event: PluginOutboundEvent) { - for (const client of this.clients) { - if (client.workspaceId !== workspaceId) continue - this.write(client.reply, event) - } - } - - broadcast(event: PluginOutboundEvent) { - for (const client of this.clients) { - this.write(client.reply, event) - } - } - - private write(reply: FastifyReply, event: PluginOutboundEvent) { - try { - reply.raw.write(`data: ${JSON.stringify(event)}\n\n`) - } catch (error) { - this.logger.warn({ err: error }, "Failed to write plugin SSE event") - } - } -} diff --git a/packages/server/src/plugins/handlers.ts b/packages/server/src/plugins/handlers.ts deleted file mode 100644 index 7844f1957..000000000 --- a/packages/server/src/plugins/handlers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { EventBus } from "../events/bus" -import type { WorkspaceManager } from "../workspaces/manager" -import type { Logger } from "../logger" -import type { PluginOutboundEvent } from "./channel" - -export interface PluginInboundEvent { - type: string - properties?: Record -} - -interface HandlerDeps { - workspaceManager: WorkspaceManager - eventBus: EventBus - logger: Logger -} - -export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) { - switch (event.type) { - case "codenomad.pong": - deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received") - return - - default: - deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event") - } -} - -export function buildPingEvent(): PluginOutboundEvent { - - return { - type: "codenomad.ping", - properties: { - ts: Date.now(), - }, - } -} diff --git a/packages/server/src/plugins/voice-mode.ts b/packages/server/src/plugins/voice-mode.ts deleted file mode 100644 index a44ae4e6e..000000000 --- a/packages/server/src/plugins/voice-mode.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Logger } from "../logger" -import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager" -import type { PluginChannelManager } from "./channel" - -interface VoiceModeManagerOptions { - connections: ClientConnectionManager - channel: PluginChannelManager - logger: Logger -} - -export class VoiceModeManager { - private readonly enabledConnectionsByInstance = new Map>() - private readonly aggregateByInstance = new Map() - - constructor(private readonly options: VoiceModeManagerOptions) { - this.options.connections.subscribe((event) => { - if (event.type !== "disconnected") return - this.clearConnection(event.connection) - }) - } - - setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean { - if (enabled && !this.options.connections.isConnected(connection)) { - this.options.logger.debug( - { instanceId, clientId: connection.clientId, connectionId: connection.connectionId }, - "Ignoring voice mode enable for disconnected client connection", - ) - return false - } - - const key = getConnectionKey(connection) - const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set() - - if (enabled) { - current.add(key) - this.enabledConnectionsByInstance.set(instanceId, current) - } else if (current.delete(key)) { - if (current.size === 0) { - this.enabledConnectionsByInstance.delete(instanceId) - } else { - this.enabledConnectionsByInstance.set(instanceId, current) - } - } - - this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection") - this.publishIfChanged(instanceId) - return true - } - - syncInstance(instanceId: string): void { - this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId))) - } - - isEnabled(instanceId: string): boolean { - return this.aggregateByInstance.get(instanceId) === true - } - - private clearConnection(connection: ClientConnectionRef): void { - const key = getConnectionKey(connection) - for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) { - if (!enabledConnections.delete(key)) continue - if (enabledConnections.size === 0) { - this.enabledConnectionsByInstance.delete(instanceId) - } - this.publishIfChanged(instanceId) - } - } - - private publishIfChanged(instanceId: string): void { - const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0 - const previous = this.aggregateByInstance.get(instanceId) === true - if (enabled === previous) return - - if (enabled) { - this.aggregateByInstance.set(instanceId, true) - } else { - this.aggregateByInstance.delete(instanceId) - } - - this.options.logger.debug( - { instanceId, enabled }, - "Broadcasting aggregate voice mode", - ) - this.options.channel.send(instanceId, buildVoiceModeEvent(enabled)) - } -} - -function buildVoiceModeEvent(enabled: boolean) { - return { - type: "codenomad.voiceMode", - properties: { - enabled, - formatVersion: "v1", - }, - } -} - -function getConnectionKey(connection: ClientConnectionRef): string { - return `${connection.clientId}:${connection.connectionId}` -} diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts new file mode 100644 index 000000000..d116ca314 --- /dev/null +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -0,0 +1,753 @@ +import assert from "node:assert/strict" +import { afterEach, describe, it } from "node:test" +import { Readable } from "node:stream" +import Fastify, { type FastifyInstance } from "fastify" +import replyFrom from "@fastify/reply-from" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { Logger } from "../../logger" +import { redactSecrets, registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server" +import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" + +const apps: FastifyInstance[] = [] +afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))) + +const cursor = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url") + +function logger(): Logger { + const value = { debug() {}, trace() {}, error() {}, isLevelEnabled() { return false } } + return value as unknown as Logger +} + +async function harness( + sessionDirectory = "/repo/worktree", + activeSessions: Record = {}, + sessionLocations: Record = {}, + workspacePath = "/repo", + serviceDirectory = workspacePath, + pathMappings: Record = {}, + ptyDirectories: Record = {}, + shellDirectories: Record = {}, + directoryMappings: Record = {}, +) { + const upstream = Fastify() + apps.push(upstream) + let requests = 0 + let releaseDelayedUpstream: (() => void) | undefined + let markDelayedUpstreamStarted!: () => void + const delayedUpstreamStarted = new Promise((resolve) => { markDelayedUpstreamStarted = resolve }) + upstream.all("/*", async (request, reply) => { + requests++ + if (request.headers["x-test-delay-upstream-body"] === "1") { + let started = false + const stream = new Readable({ + read() { + if (started) return + started = true + this.push('{"started":') + markDelayedUpstreamStarted() + releaseDelayedUpstream = () => { + this.push("true}") + this.push(null) + } + }, + }) + return reply.type("application/json").send(stream) + } + if (request.headers["x-test-delay-upstream"] === "1") { + markDelayedUpstreamStarted() + await new Promise((resolve) => { releaseDelayedUpstream = resolve }) + } + reply.header("set-cookie", "upstream_session=secret; Path=/") + reply.header("www-authenticate", 'Basic realm="OpenCode"') + reply.header("proxy-authenticate", 'Basic realm="OpenCode proxy"') + if (request.headers["x-test-challenge"] === "1") reply.code(401) + return { url: request.raw.url, body: request.body, headers: request.headers } + }) + await upstream.listen({ host: "127.0.0.1", port: 0 }) + const address = upstream.server.address() + assert.ok(address && typeof address === "object") + + const owned = new Set([workspacePath, serviceDirectory, "/repo", "/repo/worktree", ...Object.keys(directoryMappings)]) + const sessionGets: string[] = [] + const pathOwnershipChecks: string[] = [] + const servicePathCalls: string[] = [] + let invalidations = 0 + 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) + 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 } } + }, + }, + shell: { + list: async () => ({ + location: { directory: serviceDirectory, project: { id: "project", directory: serviceDirectory, canonical: serviceDirectory } }, + data: Object.entries(shellDirectories).filter((entry): entry is [string, string] => typeof entry[1] === "string").map(([id, cwd]) => ({ + id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running" as const, pid: 42, metadata: {}, time: { started: 1 }, + })), + }), + get: async ({ id }: { id: string }) => { + const cwd = shellDirectories[id] ?? sessionDirectory + if (cwd instanceof Error) throw cwd + return { data: { id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 } } } + }, + }, + } as OpenCodeClient + const manager: InstanceProxyWorkspaceManager = { + get: () => ({ id: "workspace", path: workspacePath }) as never, + getSharedServiceEndpoint: async () => ({ url: `http://127.0.0.1:${address.port}` }), + invalidateSharedServiceConnection: () => { invalidations += 1 }, + getInstanceAuthorizationHeader: () => "Basic internal-secret", + getServiceDirectory: () => serviceDirectory, + getServiceDirectoryForPath: async (_id, directory) => directory === workspacePath + ? serviceDirectory + : owned.has(directory) ? directoryMappings[directory] ?? directory : undefined, + getWorktreeIdentityForPath: async (_id, directory) => { + const canonical = directory === workspacePath ? serviceDirectory : directoryMappings[directory] ?? directory + if (!owned.has(directory)) return undefined + return canonical.includes("/worktree") ? "workspace:worktree" : "workspace:root" + }, + 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, + ownsLocationWorkspace: (_id, workspaceID) => workspaceID === "owned-location", + 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() + const worktreeDeletionFence = new WorktreeDeletionFence() + apps.push(app) + await app.register(replyFrom) + registerInstanceProxyRoutes(app, { workspaceManager: manager, logger: logger(), worktreeDeletionFence }) + await app.ready() + return { + app, + servicePathCalls, + sessionGets, + worktreeDeletionFence, + requestCount: () => requests, + delayedUpstreamStarted, + releaseDelayedUpstream: () => releaseDelayedUpstream?.(), + invalidationCount: () => invalidations, + } +} + +describe("instance proxy location enforcement", () => { + 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 () => { + const { app, requestCount } = await harness() + const bodyResponse = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { location: { directory: "/other" } }, + }) + const queryResponse = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?directory=%2Fother", + }) + assert.equal(bodyResponse.statusCode, 403) + assert.equal(queryResponse.statusCode, 403) + assert.equal(requestCount(), 0) + assert.doesNotMatch(bodyResponse.body, /internal-secret/) + }) + + it("rejects session admission while a worktree deletion is pending", async () => { + const { app, worktreeDeletionFence, requestCount } = await harness() + let release!: () => void + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], () => ( + new Promise((resolve) => { release = resolve }) + )) + + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { location: { directory: "/repo/worktree" } }, + }) + + assert.equal(response.statusCode, 409) + assert.equal(requestCount(), 0) + release() + await deletion + }) + + it("holds mutation admission until the upstream response arrives", async () => { + const { app, worktreeDeletionFence, delayedUpstreamStarted, releaseDelayedUpstream } = await harness() + const mutation = app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + headers: { "x-test-delay-upstream": "1" }, + payload: { location: { directory: "/repo/worktree" } }, + }) + await delayedUpstreamStarted + let deleted = false + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], async () => { deleted = true }) + + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(deleted, false) + releaseDelayedUpstream() + assert.equal((await mutation).statusCode, 200) + await deletion + assert.equal(deleted, true) + }) + + it("holds mutation admission until the upstream response body ends", async () => { + const { app, worktreeDeletionFence, delayedUpstreamStarted, releaseDelayedUpstream } = await harness() + const mutation = app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + headers: { "x-test-delay-upstream-body": "1" }, + payload: { location: { directory: "/repo/worktree" } }, + }) + await delayedUpstreamStarted + let deleted = false + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], async () => { deleted = true }) + + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(deleted, false) + releaseDelayedUpstream() + assert.equal((await mutation).statusCode, 200) + await deletion + assert.equal(deleted, true) + }) + + it("blocks lexical aliases after translation to the canonical worktree", async () => { + const { app, worktreeDeletionFence, requestCount } = await harness( + "/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, {}, { "/repo/worktree/.": "/repo/worktree" }, + ) + let release!: () => void + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], () => ( + new Promise((resolve) => { release = resolve }) + )) + + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { location: { directory: "/repo/worktree/." } }, + }) + + assert.equal(response.statusCode, 409) + assert.equal(requestCount(), 0) + release() + await deletion + }) + + it("blocks existing-session mutations through their canonical directory", async () => { + const { app, worktreeDeletionFence, requestCount } = await harness( + "/repo/worktree", {}, { session: "/repo/worktree/." }, "/repo", "/repo", {}, {}, {}, { "/repo/worktree/.": "/repo/worktree" }, + ) + let release!: () => void + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], () => ( + new Promise((resolve) => { release = resolve }) + )) + + const response = await app.inject({ + method: "DELETE", + url: "/workspaces/workspace/instance/api/session/session", + }) + + assert.equal(response.statusCode, 409) + assert.equal(requestCount(), 0) + release() + await deletion + }) + + it("blocks mutations in nested directories of the deleting worktree", async () => { + const { app, worktreeDeletionFence, requestCount } = await harness( + "/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, {}, { "/repo/worktree/nested": "/repo/worktree/nested" }, + ) + let release!: () => void + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], () => ( + new Promise((resolve) => { release = resolve }) + )) + + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { location: { directory: "/repo/worktree/nested" } }, + }) + + assert.equal(response.statusCode, 409) + assert.equal(requestCount(), 0) + release() + await deletion + }) + + it("allows the scoped native location bootstrap", async () => { + const { app } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/location" }) + assert.equal(response.statusCode, 200) + assert.match(JSON.parse(response.body).url, /\/api\/location\?location%5Bdirectory%5D=%2Frepo/) + }) + + it("allows owned native VCS reads and rejects foreign locations", async () => { + const { app, requestCount } = await harness() + const owned = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/vcs?location%5Bdirectory%5D=%2Frepo%2Fworktree", + }) + const foreign = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/vcs?location%5Bdirectory%5D=%2Fother", + }) + + assert.equal(owned.statusCode, 200) + assert.equal(JSON.parse(owned.body).url, "/api/vcs?location%5Bdirectory%5D=%2Frepo%2Fworktree") + assert.equal(foreign.statusCode, 403) + assert.equal(requestCount(), 1) + }) + + it("authorizes project-only session lists without adding a directory", async () => { + const { app } = await harness() + const response = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?project=owned-project", + }) + assert.equal(response.statusCode, 200) + assert.equal(JSON.parse(response.body).url, "/api/session?project=owned-project") + + const foreign = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?project=foreign-project", + }) + assert.equal(foreign.statusCode, 403) + }) + + it("uses the cursor scope and rejects malformed or forged session-list cursors", async () => { + const { app, requestCount } = await harness() + const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } }) + const response = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother`, + }) + assert.equal(response.statusCode, 200) + const upstreamUrl = JSON.parse(response.body).url as string + assert.match(upstreamUrl, new RegExp(`cursor=${ownedCursor}`)) + assert.doesNotMatch(upstreamUrl, /directory=/) + + const forgedCursor = cursor({ directory: "/other", anchor: { id: "session-1", time: 1, direction: "next" } }) + assert.equal((await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${forgedCursor}&directory=%2Frepo`, + })).statusCode, 403) + assert.equal((await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?cursor=not-json", + })).statusCode, 400) + assert.equal((await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ directory: "/repo" })}`, + })).statusCode, 400) + assert.equal((await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "foreign-location", directory: "/repo", anchor: { id: "session-1", time: 1, direction: "next" } })}`, + })).statusCode, 403) + assert.equal((await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "owned-location", anchor: { id: "session-1", time: 1, direction: "next" } })}`, + })).statusCode, 200) + 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 () => { + const { app, invalidationCount } = await harness() + const response = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session", + headers: { + authorization: "Bearer browser-secret", + 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", + }, + }) + const headers = JSON.parse(response.body).headers + assert.equal(headers.authorization, "Basic internal-secret") + 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) + assert.equal(response.headers["www-authenticate"], undefined) + assert.equal(response.headers["proxy-authenticate"], undefined) + + const challenge = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session", + headers: { "x-test-challenge": "1" }, + }) + assert.equal(challenge.statusCode, 401) + assert.equal(invalidationCount(), 1) + assert.equal(challenge.headers["www-authenticate"], undefined) + assert.equal(challenge.headers["proxy-authenticate"], undefined) + }) + + 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" }) + assert.equal(response.statusCode, 403) + assert.equal(requestCount(), 0) + assert.doesNotMatch(response.body, /internal-secret/) + }) + + it("lists owned shells and rejects foreign shell 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/shell?location%5Bdirectory%5D=%2Frepo%2Fworktree", + }) + assert.equal(listed.statusCode, 200) + assert.deepEqual(JSON.parse(listed.body).data.map((shell: { id: string }) => shell.id), ["owned"]) + for (const [method, path] of [["DELETE", "foreign/"], ["GET", "foreign/output/"]] as const) { + assert.equal((await app.inject({ + method, + url: `/workspaces/workspace/instance/api/shell/${path}?location%5Bdirectory%5D=%2Frepo%2Fworktree`, + })).statusCode, 403) + } + assert.equal(requestCount(), 0) + }) + + it("permits only native global Forms actions without session hydration", async () => { + const { app, sessionGets, requestCount } = await harness("/other") + for (const action of ["reply", "cancel"]) { + const response = await app.inject({ + method: "POST", + url: `/workspaces/workspace/instance/api/session/global/form/form-1/${action}`, + payload: action === "reply" ? { answers: {} } : {}, + }) + assert.equal(response.statusCode, 200) + assert.equal(JSON.parse(response.body).url, `/api/session/global/form/form-1/${action}?location%5Bdirectory%5D=%2Frepo`) + } + assert.deepEqual(sessionGets, []) + assert.equal(requestCount(), 2) + + assert.equal((await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/prompt", + payload: { text: "no" }, + })).statusCode, 403) + assert.deepEqual(sessionGets, ["global"]) + }) + + it("forwards a validated global Form root location instead of browser routing headers", async () => { + const { app } = await harness("/repo/worktree", {}, {}, "/repo", "/srv/repo") + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", + headers: { + "x-opencode-directory": encodeURIComponent("/repo"), + "x-opencode-workspace": "untrusted-workspace", + }, + payload: { answers: {} }, + }) + + assert.equal(response.statusCode, 200) + const upstream = JSON.parse(response.body) + assert.equal(upstream.headers["x-opencode-directory"], encodeURIComponent("/srv/repo")) + assert.equal(upstream.headers["x-opencode-workspace"], undefined) + }) + + it("translates and forwards a validated global Form worktree location", async () => { + const { app } = await harness( + "/repo/worktree", {}, {}, "/repo", "/srv/repo", {}, {}, {}, + { "/repo/worktree": "/srv/worktree" }, + ) + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/form/form-1/cancel", + headers: { "x-opencode-directory": encodeURIComponent("/repo/worktree") }, + payload: {}, + }) + + assert.equal(response.statusCode, 200) + assert.equal(JSON.parse(response.body).headers["x-opencode-directory"], encodeURIComponent("/srv/worktree")) + }) + + it("rejects a foreign global Form location before proxying", async () => { + const { app, requestCount } = await harness() + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", + headers: { "x-opencode-directory": encodeURIComponent("/other") }, + payload: { answers: {} }, + }) + + assert.equal(response.statusCode, 403) + assert.equal((await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", + headers: { "x-opencode-directory": "%ZZ" }, + payload: { answers: {} }, + })).statusCode, 400) + assert.equal(requestCount(), 0) + }) + + it("decodes, translates, and re-encodes Unicode global Form locations", async () => { + const directory = "/工作/100% ready" + const serviceDirectory = "/服务/工作 100%" + const { app } = await harness(directory, {}, {}, directory, serviceDirectory) + const response = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + payload: { answers: {} }, + }) + + assert.equal(response.statusCode, 200) + assert.equal(JSON.parse(response.body).headers["x-opencode-directory"], encodeURIComponent(serviceDirectory)) + }) + + 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> = 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", () => { + assert.deepEqual(redactSecrets({ + authorization: "Basic internal-secret", + apiKey: "key-value", + nested: { authorizationCode: "code-value", password: "password-value", safe: "visible", retries: 2 }, + items: [{ accessToken: "token-value" }, { clientSecret: "secret-value" }], + }), { + authorization: "", + apiKey: "", + nested: { authorizationCode: "", password: "", safe: "visible", retries: 2 }, + items: [{ accessToken: "" }, { clientSecret: "" }], + }) +}) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 44e855b27..9ffaf787e 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,6 +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 { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type OpenCodeClient } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -20,8 +21,6 @@ import { registerConfigFileRoutes } from "./routes/config-files" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" -import { registerPluginRoutes } from "./routes/plugin" -import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerYoloRoutes } from "./routes/yolo" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" @@ -33,20 +32,17 @@ import { registerPreviewRoutes } from "./routes/previews" import { registerUsageRoutes } from "./routes/usage" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" -import { BackgroundProcessManager } from "../background-processes/manager" 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" import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" -import { PluginChannelManager } from "../plugins/channel" -import { VoiceModeManager } from "../plugins/voice-mode" import type { SideCarManager } from "../sidecars/manager" import type { PreviewManager } from "../previews/manager" import type { RemoteProxySessionManager } from "./remote-proxy" import { createOpenCodeUpdateService } from "../opencode-update/service" +import { WorktreeDeletionFence } from "../workspaces/worktree-session-evacuation" interface HttpServerDeps { bindHost: string @@ -66,11 +62,8 @@ interface HttpServerDeps { previewManager: PreviewManager authManager: AuthManager clientConnectionManager: ClientConnectionManager - pluginChannel: PluginChannelManager - voiceModeManager: VoiceModeManager remoteProxySessionManager: RemoteProxySessionManager yoloManager: AutoAcceptManager - sessionMetadataPersistence: OpencodeYoloPersistence uiStaticDir: string uiDevServerUrl?: string logger: Logger @@ -131,7 +124,12 @@ export function createHttpServer(deps: HttpServerDeps) { } apiLogger.debug(base, "HTTP request completed") if (apiLogger.isLevelEnabled("trace")) { - apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload") + apiLogger.trace({ + ...base, + params: redactSecrets(request.params), + query: redactSecrets(request.query), + body: typeof request.body === "string" ? "" : redactSecrets(request.body), + }, "HTTP request payload") } done() }) @@ -200,12 +198,6 @@ export function createHttpServer(deps: HttpServerDeps) { }, }) - const backgroundProcessManager = new BackgroundProcessManager({ - workspaceManager: deps.workspaceManager, - eventBus: deps.eventBus, - logger: deps.logger.child({ component: "background-processes" }), - }) - registerAuthRoutes(app, { authManager: deps.authManager }) app.addHook("preHandler", (request, reply, done) => { @@ -232,21 +224,6 @@ export function createHttpServer(deps: HttpServerDeps) { const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") || pathname.startsWith("/previews/") if (requiresAuthForApi && !session) { - // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. - const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) - if (pluginMatch) { - const workspaceId = pluginMatch[1] - const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId) - const provided = Array.isArray(request.headers.authorization) - ? request.headers.authorization[0] - : request.headers.authorization - - if (expected && provided && provided === expected) { - done() - return - } - } - sendUnauthorized(request, reply) return } @@ -296,10 +273,8 @@ export function createHttpServer(deps: HttpServerDeps) { logger: sseLogger, connectionManager: deps.clientConnectionManager, }) - registerWorktreeRoutes(app, { - workspaceManager: deps.workspaceManager, - sessionMetadataPersistence: deps.sessionMetadataPersistence, - }) + const worktreeDeletionFence = new WorktreeDeletionFence() + registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager, worktreeDeletionFence }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus, @@ -323,16 +298,8 @@ export function createHttpServer(deps: HttpServerDeps) { authManager: deps.authManager, logger: proxyLogger, }) - registerPluginRoutes(app, { - workspaceManager: deps.workspaceManager, - eventBus: deps.eventBus, - logger: proxyLogger, - channel: deps.pluginChannel, - voiceModeManager: deps.voiceModeManager, - }) - registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerYoloRoutes(app, { yoloManager: deps.yoloManager }) - registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) + registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger, worktreeDeletionFence }) if (deps.uiDevServerUrl) { @@ -394,9 +361,25 @@ export function createHttpServer(deps: HttpServerDeps) { } } +export interface InstanceProxyWorkspaceManager { + get(id: string): ReturnType + getSharedServiceEndpoint(id: string): ReturnType + invalidateSharedServiceConnection?(): void + getInstanceAuthorizationHeader(id: string): string | undefined + getServiceDirectory?(id: string): string | undefined + getServiceDirectoryForPath?(id: string, directory: string): Promise + getWorktreeIdentityForPath(id: string, directory: string): Promise + getServicePathForPath?(id: string, candidate: string): Promise + getSharedServiceClient(): Promise + ownsLocationWorkspace(id: string, workspaceID: string): boolean + ownsDirectory(id: string, directory: string): Promise + ownsPath(id: string, candidate: string): Promise +} + interface InstanceProxyDeps { - workspaceManager: WorkspaceManager + workspaceManager: InstanceProxyWorkspaceManager logger: Logger + worktreeDeletionFence: WorktreeDeletionFence } interface SideCarProxyDeps { @@ -523,9 +506,16 @@ function setupPreviewWebSocketProxy(app: FastifyInstance, deps: PreviewWebSocket }) } -function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { +export function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() + instance.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => { + try { + done(null, body.length ? JSON.parse(body.toString()) : {}) + } catch { + done(Object.assign(new Error("Invalid JSON request body"), { statusCode: 400 }), undefined) + } + }) instance.addContentTypeParser("*", (req, body, done) => done(null, body)) const proxyBaseHandler = async ( @@ -536,6 +526,7 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe request, reply, workspaceManager: deps.workspaceManager, + worktreeDeletionFence: deps.worktreeDeletionFence, pathSuffix: "", logger: deps.logger, }) @@ -549,6 +540,7 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe request, reply, workspaceManager: deps.workspaceManager, + worktreeDeletionFence: deps.worktreeDeletionFence, pathSuffix: request.params["*"] ?? "", logger: deps.logger, }) @@ -559,147 +551,781 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe }) } -const INSTANCE_PROXY_HOST = "127.0.0.1" - async function proxyWorkspaceRequest(args: { request: FastifyRequest reply: FastifyReply - workspaceManager: WorkspaceManager + workspaceManager: InstanceProxyWorkspaceManager + worktreeDeletionFence: WorktreeDeletionFence logger: Logger pathSuffix?: string }) { - const { request, reply, workspaceManager, logger } = args + const { request, reply, workspaceManager, logger, worktreeDeletionFence } = args const workspaceId = (request.params as { id: string }).id const workspace = workspaceManager.get(workspaceId) - const bodyToJson = (body: unknown): unknown => { - if (body == null) return null + if (!workspace) { + reply.code(404).send({ error: "Workspace not found" }) + return + } + + 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 routeUrl = buildInstanceTargetUrl("http://127.0.0.1", args.pathSuffix) + if (!routeUrl) { + reply.code(400).send({ error: "Invalid workspace instance path" }) + return + } + const pathname = decodeURIComponent(routeUrl.pathname) + if (!isAllowedInstanceApiRoute(request.method, pathname)) { + reply.code(403).send({ error: "OpenCode route is not available through a workspace" }) + return + } + + const endpoint = await workspaceManager.getSharedServiceEndpoint(workspaceId) + if (!endpoint) { + reply.code(502).send({ error: "OpenCode service is not ready" }) + return + } - const anyBody = body as any - if (anyBody && typeof anyBody.pipe === "function") { - // Don't consume streams (would break proxying). - // Best-effort: if the stream already has buffered chunks, parse those. + const targetUrl = buildInstanceTargetUrl(endpoint.url, args.pathSuffix) + if (!targetUrl) { + reply.code(400).send({ error: "Invalid workspace instance path" }) + return + } + appendIncomingQuery(targetUrl, request.raw.url ?? "") + 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 buffered = anyBody?._readableState?.buffer - if (Array.isArray(buffered) && buffered.length > 0) { - const chunks: Buffer[] = [] - for (const entry of buffered) { - if (!entry) continue - if (Buffer.isBuffer(entry)) { - chunks.push(entry) - continue - } - const data = (entry as any).data - if (Buffer.isBuffer(data)) { - chunks.push(data) - } - } - - if (chunks.length > 0) { - const text = Buffer.concat(chunks).toString("utf-8") - try { - return JSON.parse(text) - } catch { - return { __raw: text } - } - } - } + const session = await client.session.get({ sessionID: sessionId }) + return await workspaceManager.ownsDirectory(workspaceId, session.location.directory) ? [sessionId, status] as const : null } catch { - // fall through + return null } - - return { __stream: true } + })) + reply.send(Object.fromEntries(entries.filter((entry): entry is NonNullable => 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 => project !== null)) + return + } + const sessionListHasScope = request.method === "GET" + && pathname.replace(/\/+$/, "") === "/api/session" + && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project")) + const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId) + if (sessionListScope !== "allowed") { + reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" }) + return + } + const serviceDirectory = workspaceManager.getServiceDirectory?.(workspaceId) ?? workspace.path + let globalFormDirectory: string | undefined + if (isGlobalFormAction(pathname, request.method)) { + const header = request.headers["x-opencode-directory"] + if (Array.isArray(header)) { + reply.code(400).send({ error: "Invalid Form location" }) + return } - - const maybeParse = (input: string): unknown => { + if (header !== undefined) { try { - return JSON.parse(input) + globalFormDirectory = decodeURIComponent(header) } catch { - return { __raw: input } + reply.code(400).send({ error: "Invalid Form location" }) + return + } + if (!globalFormDirectory.trim()) { + reply.code(400).send({ error: "Invalid Form location" }) + return } } - - if (Buffer.isBuffer(body)) { - return maybeParse(body.toString("utf-8")) + } + const imported = prepareSessionImport( + pathname, + request.method, + stripLocationSelectors(targetUrl, request.body, workspace.path, serviceDirectory), + serviceDirectory, + ) + const requestLocations = readRequestDirectories(targetUrl, imported.body) + if (globalFormDirectory) requestLocations.directories.push(globalFormDirectory) + 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() + 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 mutationIdentities = new Set() + if (request.method !== "GET" && request.method !== "HEAD") { + for (const directory of requestLocations.directories) { + const identity = await workspaceManager.getWorktreeIdentityForPath(workspaceId, directory) + if (!identity) { + reply.code(403).send({ error: "Location does not belong to workspace" }) + return + } + mutationIdentities.add(identity) + } + } + const serviceBody = replaceRequestDirectories(targetUrl, imported.body, translatedDirectories, pathname, request.method) + if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) { + reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" }) + return + } + const translatedPromptPaths = new Map() + for (const candidate of new Set(promptFiles.paths)) { + const translated = workspaceManager.getServicePathForPath + ? await workspaceManager.getServicePathForPath(workspaceId, candidate) + : candidate + if (!translated) { + reply.code(403).send({ error: "Prompt file does not belong to workspace" }) + return + } + translatedPromptPaths.set(candidate, translated) + } + const promptBody = replacePromptFileUris(serviceBody, translatedPromptPaths) + + const requestedDirectory = requestLocations.directories[0] + const runtimeLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } + if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") { + const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: runtimeLocation }) + 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 + } - if (typeof body === "string") { - return maybeParse(body) + const ptyId = getPtyRouteId(pathname) + if (ptyId) { + try { + const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: runtimeLocation }) + 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 } + } - if (typeof body === "object") { - return body + if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") { + const result = await (await workspaceManager.getSharedServiceClient()).shell.list({ location: runtimeLocation }) + const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))) + reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) + return + } + + const shellId = getShellRouteId(pathname) + if (shellId) { + try { + const shell = await (await workspaceManager.getSharedServiceClient()).shell.get({ id: shellId, location: runtimeLocation }) + if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) { + reply.code(403).send({ error: "Shell does not belong to workspace" }) + return + } + } catch (error) { + if (isShellNotFoundError(error)) { + reply.code(404).send({ error: "Shell not found" }) + return + } + throw error } + } - return body + const sessionId = getSessionRouteId(pathname) + if (sessionId && !isGlobalFormAction(pathname, request.method)) { + let session + try { + session = await (await workspaceManager.getSharedServiceClient()).session.get({ sessionID: sessionId }) + } 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" }) + return + } + const sessionWorktree = await workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory) + if (!sessionWorktree) { + reply.code(403).send({ error: "Session does not belong to workspace" }) + return + } + mutationIdentities.add(sessionWorktree) } - if (!workspace) { - reply.code(404).send({ error: "Workspace not found" }) + const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0 || sessionListHasScope, Boolean(sessionId) && !isGlobalFormAction(pathname, request.method)) + const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) + const releaseMutation = request.method === "GET" || request.method === "HEAD" + ? undefined + : worktreeDeletionFence.enter([...mutationIdentities]) + if (request.method !== "GET" && request.method !== "HEAD" && !releaseMutation) { + reply.code(409).send({ error: "Worktree deletion is in progress" }) return } + logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance") - const port = workspaceManager.getInstancePort(workspaceId) - if (!port) { - reply.code(502).send({ error: "Workspace instance is not ready" }) - return + try { + return reply.from(targetUrl.toString(), { + ...(body !== request.body ? { body } : {}), + rewriteRequestHeaders: (_originalRequest, headers) => { + const outgoingHeaders = sanitizeInstanceProxyRequestHeaders(headers, instanceAuthHeader) + if (globalFormDirectory) { + outgoingHeaders["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormDirectory)!) + } + + if (logger.isLevelEnabled("trace")) { + logger.trace( + { + workspaceId, + method: request.method, + targetUrl: targetUrl.toString(), + contentType: request.headers["content-type"], + headers: redactSecrets(outgoingHeaders), + }, + "Proxy -> OpenCode request", + ) + } + + return outgoingHeaders + }, + rewriteHeaders: sanitizeInstanceProxyResponseHeaders, + onResponse: (_proxyRequest, proxyReply, upstreamResponse) => { + if (proxyReply.statusCode === 401) { + workspaceManager.invalidateSharedServiceConnection?.() + } + const upstream = upstreamResponse as typeof upstreamResponse & { readableEnded?: boolean; destroyed?: boolean } + const release = () => releaseMutation?.() + if (upstream.readableEnded || upstream.destroyed) release() + else { + upstream.once("end", release) + upstream.once("close", release) + upstream.once("error", release) + } + proxyReply.send(upstream) + }, + onError: (proxyReply, { error }) => { + workspaceManager.invalidateSharedServiceConnection?.() + releaseMutation?.() + logger.error({ err: error, workspaceId, targetUrl: targetUrl.toString() }, "Failed to proxy workspace request") + if (!proxyReply.sent) { + proxyReply.code(502).send({ error: "Workspace instance proxy failed" }) + } + }, + }) + } catch (error) { + releaseMutation?.() + throw error } +} - const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) - const queryIndex = (request.raw.url ?? "").indexOf("?") - const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" - const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` - const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) +function appendIncomingQuery(targetUrl: URL, incomingUrl: string): URL { + const queryIndex = incomingUrl.indexOf("?") + const incomingSearch = queryIndex >= 0 ? incomingUrl.slice(queryIndex + 1) : "" + for (const [key, value] of new URLSearchParams(incomingSearch)) targetUrl.searchParams.append(key, value) + return targetUrl +} - logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance") - if (logger.isLevelEnabled("trace")) { - logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload") +function readRequestDirectories(targetUrl: URL, body: unknown): { directories: string[]; invalid: boolean } { + const directories: string[] = [] + let invalid = false + for (const key of ["location[directory]", "directory"]) { + for (const value of targetUrl.searchParams.getAll(key)) { + if (value.trim()) directories.push(value) + else invalid = true + } } - return reply.from(targetUrl, { - rewriteRequestHeaders: (_originalRequest, headers) => { - if (instanceAuthHeader) { - headers.authorization = instanceAuthHeader + if (body && typeof body === "object" && !Array.isArray(body) && !Buffer.isBuffer(body)) { + const input = body as Record + if ("directory" in input) { + if (typeof input.directory === "string" && input.directory.trim()) directories.push(input.directory) + else invalid = true + } + if ("location" in input) { + const location = input.location + if (location && typeof location === "object" && !Array.isArray(location)) { + const directory = (location as Record).directory + if (typeof directory === "string" && directory.trim()) directories.push(directory) + else invalid = true + } else if (location !== null && location !== undefined) { + invalid = true } + } + } + return { directories, invalid } +} - if (logger.isLevelEnabled("trace")) { - const outgoing: Record = {} - for (const [key, value] of Object.entries(headers as Record)) { - outgoing[key] = value - } +function readNativeCwd( + targetUrl: URL, + body: unknown, + locations: { directories: string[]; invalid: boolean }, +) { + if (!/^\/api\/(?:shell|pty)\/?$/.test(targetUrl.pathname) || !body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return + const input = body as Record + if (!("cwd" in input)) return + if (typeof input.cwd === "string" && input.cwd.trim()) locations.directories.push(input.cwd) + else locations.invalid = true +} - // Redact sensitive headers. - for (const key of Object.keys(outgoing)) { - const lower = key.toLowerCase() - if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") { - outgoing[key] = "" - } - } +function sanitizeInstanceProxyRequestHeaders( + headers: Record, + authorization: string | undefined, +) { + const blocked = new Set([ + "authorization", "connection", "cookie", "forwarded", "host", "keep-alive", "proxy-authenticate", + "proxy-authorization", "proxy-connection", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", + "x-forwarded-for", "x-forwarded-host", "x-forwarded-port", "x-forwarded-proto", + ]) + const connection = headers.connection + for (const name of (Array.isArray(connection) ? connection.join(",") : connection ?? "").split(",")) blocked.add(name.trim().toLowerCase()) + + const result: Record = {} + for (const [key, value] of Object.entries(headers)) { + const normalized = key.toLowerCase() + if (!blocked.has(normalized) && !normalized.startsWith("x-opencode-")) result[key] = value + } + if (authorization) result.authorization = authorization + return result +} + +function sanitizeInstanceProxyResponseHeaders(headers: Record) { + const blocked = new Set(["proxy-authenticate", "set-cookie", "set-cookie2", "www-authenticate"]) + return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase()))) +} + +export function redactSecrets(value: unknown): unknown { + if (value === null || value === undefined) return value + if (Array.isArray(value)) return value.map(redactSecrets) + if (typeof value !== "object") return value + if (Buffer.isBuffer(value)) return "" + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return "" + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ + key, + /(authorization|cookie|key|code|token|password|secret)/i.test(key) ? "" : redactSecrets(entry), + ])) +} + +async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, directories: string[]) { + 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, + method: string, + directory: string, + hasLocation: boolean, + sessionRoute: boolean, +): unknown { + if (hasLocation || sessionRoute) return body + if (targetUrl.pathname === "/api/session" && method === "GET") { + targetUrl.searchParams.set("directory", directory) + return body + } + if (targetUrl.pathname === "/api/session" && method === "POST") { + const input = body && typeof body === "object" && !Array.isArray(body) && !Buffer.isBuffer(body) + ? body as Record + : {} + return { ...input, location: { directory } } + } + targetUrl.searchParams.set("location[directory]", directory) + return body +} + +function getSessionRouteId(pathname: string): string | null { + const match = pathname.match(/^\/api\/(?:experimental\/)?session\/([^/]+)(?:\/|$)/) + if (!match || match[1] === "active" || match[1] === "import") return null + return match[1] +} - logger.trace( - { - workspaceId, - method: request.method, - targetUrl, - contentType: request.headers["content-type"], - body: bodyToJson(request.body), - headers: outgoing, - }, - "Proxy -> OpenCode request", - ) +function isGlobalFormAction(pathname: string, method: string): boolean { + return method === "POST" && /^\/api\/session\/global\/form\/[^/]+\/(?:reply|cancel)\/?$/.test(pathname) +} + +async function authorizeSessionList( + targetUrl: URL, + method: string, + manager: InstanceProxyWorkspaceManager, + workspaceId: string, +): Promise<"allowed" | "invalid" | "foreign"> { + if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed" + const cursors = targetUrl.searchParams.getAll("cursor") + if (cursors.length > 1) return "invalid" + if (cursors.length === 1) { + const scope = decodeSessionListCursor(cursors[0]) + if (!scope) return "invalid" + for (const key of ["directory", "location[directory]", "project", "subpath"]) targetUrl.searchParams.delete(key) + return ownsSessionListScope(manager, workspaceId, scope) + } + + const projects = targetUrl.searchParams.getAll("project") + const subpaths = targetUrl.searchParams.getAll("subpath") + if (projects.length > 1 || subpaths.length > 1 || (subpaths.length && !projects.length)) return "invalid" + if (!projects.length) return "allowed" + const project = projects[0] + const subpath = subpaths[0] + if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid" + return ownsSessionListScope(manager, workspaceId, { project, subpath }) +} + +type SessionListScope = { + workspace?: string + directory?: string + project?: string + subpath?: string +} + +function decodeSessionListCursor(cursor: string): SessionListScope | null { + if (!cursor || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null + try { + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record + if (!value || typeof value !== "object" || Array.isArray(value)) return null + const anchor = value.anchor as Record | undefined + if (!anchor || typeof anchor !== "object" || Array.isArray(anchor) + || typeof anchor.id !== "string" || !anchor.id + || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) + || (anchor.direction !== "previous" && anchor.direction !== "next")) return null + if (value.workspace !== undefined && (typeof value.workspace !== "string" || !value.workspace.trim())) return null + if (value.search !== undefined && typeof value.search !== "string") return null + if (value.order !== undefined && value.order !== "asc" && value.order !== "desc") return null + if (typeof value.directory === "string" && value.directory.trim() && value.project === undefined && value.subpath === undefined) { + return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), directory: value.directory } + } + if (typeof value.project === "string" && value.project.trim() && value.directory === undefined) { + if (value.subpath === undefined) { + return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), project: value.project } + } + if (typeof value.subpath === "string" && isSafeRelativePath(value.subpath)) { + return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), project: value.project, subpath: value.subpath } } + } + if (typeof value.workspace === "string" && value.directory === undefined && value.project === undefined && value.subpath === undefined) { + return { workspace: value.workspace } + } + return null + } catch { + return null + } +} - return headers - }, - onError: (proxyReply, { error }) => { - logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") - if (!proxyReply.sent) { - proxyReply.code(502).send({ error: "Workspace instance proxy failed" }) +function isSafeRelativePath(value: string): boolean { + return value === "" || (!path.posix.isAbsolute(value) && !path.win32.isAbsolute(value) && !value.split(/[\\/]/).includes("..")) +} + +async function ownsSessionListScope( + manager: InstanceProxyWorkspaceManager, + workspaceId: string, + scope: SessionListScope, +): Promise<"allowed" | "foreign"> { + if (scope.workspace && !manager.ownsLocationWorkspace(workspaceId, scope.workspace)) return "foreign" + if (scope.directory) return await manager.ownsDirectory(workspaceId, scope.directory) ? "allowed" : "foreign" + if (!scope.project) return scope.workspace ? "allowed" : "foreign" + const project = (await (await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project) + if (!project) return "foreign" + const directory = scope.subpath === undefined + ? project.canonical + : /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical) + ? path.win32.resolve(project.canonical, scope.subpath) + : path.posix.resolve(project.canonical, scope.subpath) + return await manager.ownsDirectory(workspaceId, directory) ? "allowed" : "foreign" +} + +function getPtyRouteId(pathname: string): string | null { + return pathname.replace(/\/+$/, "").match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null +} + +function getShellRouteId(pathname: string): string | null { + return pathname.replace(/\/+$/, "").match(/^\/api\/shell\/([^/]+)(?:\/output|\/timeout)?$/)?.[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|location|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|shell)(?:\/[^/]+(?:\/output)?)?$/], + ["POST", /^\/api\/(?:pty|shell)$/], + ["PUT", /^\/api\/pty\/[^/]+$/], + ["DELETE", /^\/api\/pty\/[^/]+$/], + ["DELETE", /^\/api\/shell\/[^/]+$/], + ["PATCH", /^\/api\/shell\/[^/]+\/timeout$/], + ["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, + pathname: string, + method: string, +): unknown { + for (const key of ["directory", "location[directory]"]) { + const values = targetUrl.searchParams.getAll(key) + if (!values.some((value) => replacements.has(value))) continue + targetUrl.searchParams.delete(key) + for (const value of values) targetUrl.searchParams.append(key, replacements.get(value) ?? value) + } + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body + const replaceLocation = (value: unknown): unknown => { + if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) return value + const location = value as Record + return typeof location.directory === "string" && replacements.has(location.directory) + ? { ...location, directory: replacements.get(location.directory) } + : value + } + const input = { ...(body as Record) } + for (const key of ["directory", "cwd"]) { + const value = input[key] + if (typeof value === "string" && replacements.has(value)) input[key] = replacements.get(value) + } + input.location = replaceLocation(input.location) + if (pathname !== "/api/session/import" || method !== "POST") return input + input.info = input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info) + ? { ...(input.info as Record), location: replaceLocation((input.info as Record).location) } + : input.info + if (Array.isArray(input.messages)) { + input.messages = input.messages.map((value) => { + if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) return value + const message = value as Record + if (message.type !== "location-switched") return value + const next: Record = { ...message, location: replaceLocation(message.location) } + if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) { + next.previous = { + ...(message.previous as Record), + location: replaceLocation((message.previous as Record).location), + } + } + return next + }) + } + return input +} + +function stripLocationSelectors(targetUrl: URL, body: unknown, workspaceDirectory: string, serviceDirectory: string): unknown { + for (const key of ["directory", "location[directory]"]) { + const values = targetUrl.searchParams.getAll(key) + if (values.includes(workspaceDirectory)) { + targetUrl.searchParams.delete(key) + for (const value of values) targetUrl.searchParams.append(key, value === workspaceDirectory ? serviceDirectory : value) + } + } + for (const key of ["workspace", "workspaceID", "location[workspace]", "location[workspaceID]"]) { + targetUrl.searchParams.delete(key) + } + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body + const input = body as Record + const canonicalInput = { ...input } + for (const key of ["directory", "cwd"]) { + if (canonicalInput[key] === workspaceDirectory) canonicalInput[key] = serviceDirectory + } + const location = input.location + if (!location || typeof location !== "object" || Array.isArray(location) || Buffer.isBuffer(location)) return canonicalInput + const { workspace: _workspace, workspaceID: _workspaceID, ...canonicalLocation } = location as Record + if (canonicalLocation.directory === workspaceDirectory) canonicalLocation.directory = serviceDirectory + return { ...canonicalInput, location: canonicalLocation } +} + +function readPromptFilePaths(pathname: string, method: string, body: unknown) { + const result = { paths: [] as string[], invalid: false } + if (method !== "POST" || !/^\/api\/session\/[^/]+\/(?:prompt|command)\/?$/.test(pathname)) return result + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return result + const files = (body as Record).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).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): unknown { + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body + const input = body as Record + if (!Array.isArray(input.files)) return body + return { + ...input, + files: input.files.map((file) => { + if (!file || typeof file !== "object" || Array.isArray(file) || Buffer.isBuffer(file)) return file + const source = file as Record + if (typeof source.uri !== "string" || !/^file:/i.test(source.uri)) return file + const parsed = parsePromptFileUri(source.uri) + const translated = parsed.path ? replacements.get(parsed.path) : undefined + if (!translated || translated === parsed.path) return file + const uri = new URL("file:///") + uri.pathname = translated.replace(/\\/g, "/") + return { ...source, uri: uri.href } + }), + } +} + +function prepareSessionImport(pathname: string, method: string, body: unknown, directory: string) { + const result = { body, directories: [] as string[], invalid: false } + if (pathname !== "/api/session/import" || method !== "POST") return result + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) { + result.invalid = true + return result + } + + const input = body as Record + const addLocation = (owner: Record, 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 + 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, "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 + 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, "location") + } + } + } + return result +} + function normalizeInstanceSuffix(pathSuffix: string | undefined) { if (!pathSuffix || pathSuffix === "/") { return "/" diff --git a/packages/server/src/server/routes/background-processes.ts b/packages/server/src/server/routes/background-processes.ts deleted file mode 100644 index df7bfca31..000000000 --- a/packages/server/src/server/routes/background-processes.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { FastifyInstance } from "fastify" -import { z } from "zod" -import type { BackgroundProcessManager } from "../../background-processes/manager" - -interface RouteDeps { - backgroundProcessManager: BackgroundProcessManager -} - -const StartSchema = z.object({ - title: z.string().trim().min(1), - command: z.string().trim().min(1), - notify: z.boolean().optional(), - notification: z - .object({ - sessionID: z.string().trim().min(1), - directory: z.string().trim().min(1), - }) - .optional(), -}).superRefine((value, ctx) => { - if (value.notify && !value.notification) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Notification metadata is required when notify is enabled", - path: ["notification"], - }) - } -}) - -const OutputQuerySchema = z.object({ - method: z.enum(["full", "tail", "head", "grep"]).optional(), - mode: z.enum(["full", "tail", "head", "grep"]).optional(), - pattern: z.string().optional(), - lines: z.coerce.number().int().positive().max(2000).optional(), - maxBytes: z.coerce.number().int().positive().optional(), -}) - -export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => { - const processes = await deps.backgroundProcessManager.list(request.params.id) - return { processes } - }) - - app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => { - const payload = StartSchema.parse(request.body ?? {}) - const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, { - notify: payload.notify, - notification: payload.notification, - }) - reply.code(201) - return process - }) - - app.post<{ Params: { id: string; processId: string } }>( - "/workspaces/:id/plugin/background-processes/:processId/stop", - async (request, reply) => { - const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId) - if (!process) { - reply.code(404) - return { error: "Process not found" } - } - return process - }, - ) - - app.post<{ Params: { id: string; processId: string } }>( - "/workspaces/:id/plugin/background-processes/:processId/terminate", - async (request, reply) => { - await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId) - reply.code(204) - return undefined - }, - ) - - app.get<{ Params: { id: string; processId: string } }>( - "/workspaces/:id/plugin/background-processes/:processId/output", - async (request, reply) => { - const query = OutputQuerySchema.parse(request.query ?? {}) - const method = query.method ?? query.mode - if (method === "grep" && !query.pattern) { - reply.code(400) - return { error: "Pattern is required for grep output" } - } - try { - return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, { - method, - pattern: query.pattern, - lines: query.lines, - maxBytes: query.maxBytes, - }) - } catch (error) { - reply.code(400) - return { error: error instanceof Error ? error.message : "Invalid output request" } - } - }, - ) - - app.get<{ Params: { id: string; processId: string } }>( - "/workspaces/:id/plugin/background-processes/:processId/stream", - async (request, reply) => { - await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply) - }, - ) -} diff --git a/packages/server/src/server/routes/events.test.ts b/packages/server/src/server/routes/events.test.ts new file mode 100644 index 000000000..0fd44f659 --- /dev/null +++ b/packages/server/src/server/routes/events.test.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import cors from "@fastify/cors" +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; corsOrigin?: false | string } = {}) { + const app = Fastify() + if (options.corsOrigin !== undefined) app.register(cors, { origin: options.corsOrigin, credentials: true }) + let listener: ((event: any) => void) | undefined + let closeClient: (() => void) | undefined + let raw: NodeJS.EventEmitter | undefined + let unsubscribed = 0 + let unregistered = 0 + const eventBus = { + onEvent(next: (event: any) => void) { + listener = next + return () => { unsubscribed += 1 } + }, + } as EventBus + app.addHook("onRequest", (_request, reply, done) => { + raw = reply.raw + const originalWrite = reply.raw.write.bind(reply.raw) + let first = true + reply.raw.write = ((...args: Parameters) => { + const result = originalWrite(...args) + if (first) { + first = false + return false + } + return result + }) as typeof reply.raw.write + done() + }) + registerEventRoutes(app, { + eventBus, + registerClient: (close) => { + closeClient = close + return () => { unregistered += 1 } + }, + connectionManager: { + register: () => () => { unregistered += 1 }, + pong: () => false, + } as never, + logger, + backpressureLimitBytes: options.limit, + backpressureTimeoutMs: options.timeout, + }) + return { + app, + emit(event: any) { assert.ok(listener); listener(event) }, + drain() { assert.ok(raw); raw.emit("drain") }, + close() { assert.ok(closeClient); closeClient() }, + ready: () => Boolean(listener && closeClient), + counts: () => ({ unsubscribed, unregistered }), + } +} + +async function waitFor(check: () => boolean): Promise { + const deadline = Date.now() + 1_000 + while (!check()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for SSE route") + await new Promise((resolve) => setImmediate(resolve)) + } +} + +describe("SSE backpressure", () => { + it("does not override the central CORS policy", async () => { + const test = harness({ corsOrigin: false }) + try { + const response = test.app.inject({ + method: "GET", + url: "/api/events?clientId=client&connectionId=connection", + headers: { origin: "https://untrusted.example" }, + }) + await waitFor(test.ready) + test.close() + const result = await response + assert.equal(result.headers["access-control-allow-origin"], undefined) + assert.equal(result.headers["access-control-allow-credentials"], undefined) + } finally { + await test.app.close() + } + }) + + it("preserves headers from an allowed central CORS policy", async () => { + const origin = "https://trusted.example" + const test = harness({ corsOrigin: origin }) + try { + const response = test.app.inject({ + method: "GET", + url: "/api/events?clientId=client&connectionId=connection", + headers: { origin }, + }) + await waitFor(test.ready) + test.close() + const result = await response + assert.equal(result.headers["access-control-allow-origin"], origin) + assert.equal(result.headers["access-control-allow-credentials"], "true") + } finally { + await test.app.close() + } + }) + + it("queues after write(false), flushes on drain, and remains connected", async () => { + const test = harness() + try { + const response = test.app.inject({ method: "GET", url: "/api/events?clientId=client&connectionId=connection" }) + await waitFor(test.ready) + test.emit({ type: "workspace.stopped", workspaceId: "first", reason: "deleted" }) + test.emit({ type: "workspace.stopped", workspaceId: "second", reason: "deleted" }) + assert.deepEqual(test.counts(), { unsubscribed: 0, unregistered: 0 }) + test.drain() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(test.counts(), { unsubscribed: 0, unregistered: 0 }) + test.close() + const result = await response + assert.match(result.body, /"workspaceId":"first"/) + assert.match(result.body, /"workspaceId":"second"/) + assert.deepEqual(test.counts(), { unsubscribed: 1, unregistered: 0 }) + } finally { + await test.app.close() + } + }) + + it("disconnects deterministically when drain times out", async () => { + const test = harness({ timeout: 10 }) + try { + const response = test.app.inject({ method: "GET", url: "/api/events?clientId=client&connectionId=connection" }) + await waitFor(test.ready) + test.emit({ type: "workspace.stopped", workspaceId: "first", reason: "deleted" }) + await response.catch(() => undefined) + assert.deepEqual(test.counts(), { unsubscribed: 1, unregistered: 2 }) + } finally { + await test.app.close() + } + }) + + it("disconnects when the bounded pending buffer is exceeded", async () => { + const test = harness({ limit: 256, timeout: 1_000 }) + try { + const response = test.app.inject({ method: "GET", url: "/api/events?clientId=client&connectionId=connection" }) + await waitFor(test.ready) + test.emit({ type: "workspace.stopped", workspaceId: "first", reason: "deleted" }) + test.emit({ type: "workspace.stopped", workspaceId: "x".repeat(512), reason: "deleted" }) + await response.catch(() => undefined) + assert.deepEqual(test.counts(), { unsubscribed: 1, unregistered: 2 }) + } finally { + await test.app.close() + } + }) +}) diff --git a/packages/server/src/server/routes/events.ts b/packages/server/src/server/routes/events.ts index 158266e12..1f0ef63b9 100644 --- a/packages/server/src/server/routes/events.ts +++ b/packages/server/src/server/routes/events.ts @@ -10,6 +10,8 @@ interface RouteDeps { registerClient: (cleanup: () => void) => () => void logger: Logger connectionManager: ClientConnectionManager + backpressureLimitBytes?: number + backpressureTimeoutMs?: number } let nextClientId = 0 @@ -29,53 +31,117 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { const connection = ConnectionQuerySchema.parse(request.query ?? {}) deps.logger.debug({ clientId }, "SSE client connected") - const origin = request.headers.origin ?? "*" - reply.raw.setHeader("Access-Control-Allow-Origin", origin) - reply.raw.setHeader("Access-Control-Allow-Credentials", "true") + for (const [name, value] of Object.entries(reply.getHeaders())) { + if ((name === "vary" || name.startsWith("access-control-")) && value !== undefined) { + reply.raw.setHeader(name, value) + } + } reply.raw.setHeader("Content-Type", "text/event-stream") reply.raw.setHeader("Cache-Control", "no-cache") reply.raw.setHeader("Connection", "keep-alive") reply.raw.flushHeaders?.() reply.hijack() + let unsubscribe = () => {} + let unregister = () => {} + let unregisterConnection = () => {} + let heartbeat: ReturnType | undefined + let drainTimeout: ReturnType | undefined + let closed = false + let cleaned = false + let backpressured = false + let bufferedBytes = 0 + const pending: string[] = [] + const backpressureLimitBytes = Math.max(1, deps.backpressureLimitBytes ?? 1024 * 1024) + const backpressureTimeoutMs = Math.max(1, deps.backpressureTimeoutMs ?? 10_000) + const clearDrain = () => { + reply.raw.off("drain", handleDrain) + if (drainTimeout) clearTimeout(drainTimeout) + drainTimeout = undefined + } + const close = (force = false) => { + if (closed) return + closed = true + if (heartbeat) clearInterval(heartbeat) + clearDrain() + pending.length = 0 + bufferedBytes = 0 + unsubscribe() + if (force) reply.raw.destroy() + else reply.raw.end?.() + deps.logger.debug({ clientId }, "SSE client disconnected") + } + const handleClose = (force = false) => { + if (cleaned) return + cleaned = true + close(force) + unregister() + unregisterConnection() + } + const waitForDrain = () => { + backpressured = true + reply.raw.once("drain", handleDrain) + drainTimeout = setTimeout(() => handleClose(true), backpressureTimeoutMs) + } + function handleDrain() { + if (closed) return + clearDrain() + backpressured = false + bufferedBytes = pending.reduce((total, payload) => total + Buffer.byteLength(payload), 0) + while (pending.length) { + const payload = pending.shift()! + bufferedBytes -= Buffer.byteLength(payload) + if (!reply.raw.write(payload)) { + bufferedBytes += Buffer.byteLength(payload) + waitForDrain() + return + } + } + bufferedBytes = 0 + } + const write = (payload: string) => { + if (closed) return + const bytes = Buffer.byteLength(payload) + if (bytes > backpressureLimitBytes || bufferedBytes + bytes > backpressureLimitBytes) { + handleClose(true) + return + } + if (backpressured) { + pending.push(payload) + bufferedBytes += bytes + return + } + if (!reply.raw.write(payload)) { + bufferedBytes = bytes + waitForDrain() + } + } const send = (event: WorkspaceEventPayload) => { deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched") if (deps.logger.isLevelEnabled("trace")) { deps.logger.trace({ clientId, event }, "SSE event payload") } - reply.raw.write(`data: ${JSON.stringify(event)}\n\n`) + write(`data: ${JSON.stringify(event)}\n\n`) } - const unsubscribe = deps.eventBus.onEvent(send) - const heartbeat = setInterval(() => { - const ping = { ts: Date.now() } - reply.raw.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 = deps.eventBus.onEvent(send) + if (closed) { unsubscribe() - reply.raw.end?.() - deps.logger.debug({ clientId }, "SSE client disconnected") + return } + heartbeat = setInterval(() => { + const ping = { ts: Date.now() } + write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`) + }, 15000) - const unregister = deps.registerClient(close) - const unregisterConnection = deps.connectionManager.register({ + unregister = deps.registerClient(close) + unregisterConnection = deps.connectionManager.register({ ...connection, close, }) - const handleClose = () => { - close() - unregister() - unregisterConnection() - } - - request.raw.on("close", handleClose) - request.raw.on("error", handleClose) + request.raw.on("close", () => handleClose()) + request.raw.on("error", () => handleClose()) }) app.post("/api/client-connections/pong", (request, reply) => { diff --git a/packages/server/src/server/routes/opencode-update.ts b/packages/server/src/server/routes/opencode-update.ts index 58e328d0c..54ef593bf 100644 --- a/packages/server/src/server/routes/opencode-update.ts +++ b/packages/server/src/server/routes/opencode-update.ts @@ -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 } diff --git a/packages/server/src/server/routes/plugin.ts b/packages/server/src/server/routes/plugin.ts deleted file mode 100644 index aef570072..000000000 --- a/packages/server/src/server/routes/plugin.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { FastifyInstance } from "fastify" -import { z } from "zod" -import type { VoiceModeStateResponse } from "../../api-types" -import type { WorkspaceManager } from "../../workspaces/manager" -import type { EventBus } from "../../events/bus" -import type { Logger } from "../../logger" -import { PluginChannelManager } from "../../plugins/channel" -import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers" -import { VoiceModeManager } from "../../plugins/voice-mode" - -interface RouteDeps { - workspaceManager: WorkspaceManager - eventBus: EventBus - logger: Logger - channel: PluginChannelManager - voiceModeManager: VoiceModeManager -} - -const PluginEventSchema = z.object({ - type: z.string().min(1), - properties: z.record(z.unknown()).optional(), -}) - -const VoiceModeStateSchema = z.object({ - enabled: z.boolean(), - clientId: z.string().trim().min(1), - connectionId: z.string().trim().min(1), -}) - -export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { - const workspace = deps.workspaceManager.get(request.params.id) - if (!workspace) { - reply.code(404).send({ error: "Workspace not found" }) - return - } - - reply.raw.setHeader("Content-Type", "text/event-stream") - reply.raw.setHeader("Cache-Control", "no-cache") - reply.raw.setHeader("Connection", "keep-alive") - reply.raw.flushHeaders?.() - reply.hijack() - - const registration = deps.channel.register(request.params.id, reply) - deps.voiceModeManager.syncInstance(request.params.id) - - const heartbeat = setInterval(() => { - deps.channel.send(request.params.id, buildPingEvent()) - }, 15000) - - const close = () => { - clearInterval(heartbeat) - registration.close() - reply.raw.end?.() - } - - request.raw.on("close", close) - request.raw.on("error", close) - }) - - app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => { - const workspace = deps.workspaceManager.get(request.params.id) - if (!workspace) { - reply.code(404).send({ error: "Workspace not found" }) - return - } - - const payload = VoiceModeStateSchema.parse(request.body ?? {}) - const applied = deps.voiceModeManager.setEnabled( - request.params.id, - { clientId: payload.clientId, connectionId: payload.connectionId }, - payload.enabled, - ) - - if (payload.enabled && !applied) { - reply.code(409).send({ error: "Client connection not active for voice mode enable" }) - return - } - - return { enabled: payload.enabled } - }) - - const handleWildcard = async (request: any, reply: any) => { - const workspaceId = request.params.id as string - const workspace = deps.workspaceManager.get(workspaceId) - if (!workspace) { - reply.code(404).send({ error: "Workspace not found" }) - return - } - - const suffix = (request.params["*"] as string | undefined) ?? "" - const normalized = suffix.replace(/^\/+/, "") - - if (normalized === "event" && request.method === "POST") { - const parsed = PluginEventSchema.parse(request.body ?? {}) - handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger }) - reply.code(204).send() - return - } - - reply.code(404).send({ error: "Unknown plugin endpoint" }) - } - - app.all("/workspaces/:id/plugin/*", handleWildcard) - app.all("/workspaces/:id/plugin", handleWildcard) -} diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts index e115c5b7a..2230949a7 100644 --- a/packages/server/src/server/routes/workspaces.test.ts +++ b/packages/server/src/server/routes/workspaces.test.ts @@ -7,7 +7,7 @@ import type { WorkspaceManager } from "../../workspaces/manager" import { registerWorkspaceRoutes } from "./workspaces" describe("workspace routes", () => { - it("forwards a validated explicit binary path when creating a workspace", async () => { + it("forwards workspace creation options without per-workspace binary settings", async () => { const calls: unknown[][] = [] const app = Fastify({ logger: false }) const descriptor: WorkspaceDescriptor = { @@ -39,17 +39,14 @@ describe("workspace routes", () => { payload: { path: "C:/work", name: "Work", - binaryPath: " C:/tools/opencode.exe ", + binaryPath: "C:/tools/ignored-opencode.exe", requestId: " restore-request ", - forceNew: true, }, }) assert.equal(response.statusCode, 201) assert.deepEqual(calls, [["C:/work", "Work", { - binaryPath: "C:/tools/opencode.exe", requestId: "restore-request", - forceNew: true, }]]) const released = await app.inject({ @@ -72,15 +69,8 @@ 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"]) - const invalid = await app.inject({ - method: "POST", - url: "/api/workspaces", - payload: { path: "C:/work", binaryPath: "x".repeat(4097) }, - }) - assert.equal(invalid.statusCode, 400) - assert.equal(calls.length, 2) await app.close() }) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index e7f052136..3fa711a55 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" -import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status" +import { getWorktreeGitDiff, getWorktreeGitStatus, invalidateWorktreeGitStatus } from "../../workspaces/git-status" import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations" import { cloneGitRepository, isGitCloneError } from "../../workspaces/git-clone" import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees" @@ -14,9 +14,7 @@ interface RouteDeps { const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), - binaryPath: z.string().trim().min(1).max(4096).optional(), requestId: z.string().trim().min(1).max(128).optional(), - forceNew: z.boolean().optional(), }) const WorkspaceCloneSchema = z.object({ @@ -76,9 +74,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) const result = await deps.workspaceManager.create(body.path, body.name, { - binaryPath: body.binaryPath, requestId: body.requestId, - forceNew: body.forceNew, }) reply.code(201) return result.created ? result.workspace : { ...result.workspace, reused: true as const } @@ -247,6 +243,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { if (!directory) return await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + await invalidateWorktreeGitStatus(directory) return { ok: true as const } } catch (error) { return handleWorkspaceError(error, reply) @@ -263,6 +260,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { if (!directory) return await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + await invalidateWorktreeGitStatus(directory) return { ok: true as const } } catch (error) { return handleWorkspaceError(error, reply) @@ -279,6 +277,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { if (!directory) return const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message }) + await invalidateWorktreeGitStatus(directory) return { ok: true as const, ...result } } catch (error) { return handleWorkspaceError(error, reply) diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts new file mode 100644 index 000000000..784af16e5 --- /dev/null +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import Fastify from "fastify" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { WorkspaceDescriptor } from "../../api-types" +import type { WorkspaceManager } from "../../workspaces/manager" +import { registerWorktreeRoutes } from "./worktrees" +import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" + +describe("worktree routes", () => { + it("fails a direct delete call closed when session evacuation fails", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-")) + const target = path.join(temp, "doomed") + const app = Fastify({ logger: false }) + try { + execFileSync("git", ["init", "--initial-branch=main", temp]) + writeFileSync(path.join(temp, "README.md"), "test\n") + execFileSync("git", ["-C", temp, "add", "README.md"]) + execFileSync("git", ["-C", temp, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) + execFileSync("git", ["-C", temp, "worktree", "add", "-b", "doomed", target]) + + const workspace = { id: "workspace", path: temp, status: "ready" } as WorkspaceDescriptor + const nativeSession = { id: "unloaded", projectID: "project", location: { directory: target }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo + const client = { + project: { + list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }], + }, + session: { + list: async () => ({ data: [nativeSession], cursor: {} }), + active: async () => ({}), + move: async (input: { directory: string }) => { + if (input.directory === temp) throw new Error("native move failed") + }, + }, + } as unknown as OpenCodeClient + const manager = { + get: () => workspace, + getSharedServiceClient: async () => client, + getServiceDirectory: () => temp, + getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + getWorktreeIdentityForPath: async () => "workspace:doomed", + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) + + const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) + + assert.equal(response.statusCode, 400) + assert.match(response.json().error, /native move failed/) + assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index d48d9ddb1..dde07fce8 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -8,54 +8,23 @@ import { createManagedWorktree, removeWorktree, } from "../../workspaces/git-worktrees" -import type { WorktreeListResponse, WorktreeMap } from "../../api-types" -import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata" -import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map" +import { invalidateWorktreeCache } from "../../workspaces/worktree-directory" +import type { WorktreeListResponse } from "../../api-types" +import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" +import { createInstanceClient } from "../../workspaces/instance-client" +import { evacuateWorktreeSessions, type WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" interface RouteDeps { workspaceManager: WorkspaceManager - sessionMetadataPersistence: OpencodeYoloPersistence + worktreeDeletionFence: WorktreeDeletionFence } -const WorktreeMapSchema = z.object({ - version: z.literal(1), - defaultWorktreeSlug: z.string().min(1).default("root"), - parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}), -}) - const WorktreeCreateSchema = z.object({ slug: z.string().trim().min(1), branch: z.string().trim().min(1).optional(), }) -const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) }) - export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { - app.put<{ Params: { id: string; sessionId: string }; Body: unknown }>( - "/api/workspaces/:id/worktrees/sessions/:sessionId", - async (request, reply) => { - if (!deps.workspaceManager.get(request.params.id)) { - reply.code(404) - return { error: "Workspace not found" } - } - try { - const body = WorktreeSessionSchema.parse(request.body) - if (!await deps.sessionMetadataPersistence.hasProjectSession(request.params.id, request.params.sessionId)) { - reply.code(404) - return { error: "Session not found" } - } - const metadata = await deps.sessionMetadataPersistence.setWorktreeSlug( - request.params.id, - request.params.sessionId, - body.worktreeSlug, - ) - return { metadata } - } catch (error) { - return handleError(error, reply) - } - }, - ) - app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) if (!workspace) { @@ -108,6 +77,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { slug, logger: request.log, }) + invalidateWorktreeCache(workspace.id) reply.code(201) return created @@ -147,33 +117,27 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { return { error: "Worktree not found" } } - await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }) - - // Best-effort: prune any mappings that point at the deleted worktree. - const current = await readWorktreeMap(workspace.path, request.log) - let changed = false - const nextMapping: Record = { ...(current.parentSessionWorktreeSlug ?? {}) } - for (const [sessionId, mapped] of Object.entries(nextMapping)) { - if (mapped === slug) { - delete nextMapping[sessionId] - changed = true - } - } - const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug - if (nextDefault !== current.defaultWorktreeSlug) { - changed = true - } - if (changed) { - await writeWorktreeMap( - workspace.path, - { - version: 1, - defaultWorktreeSlug: nextDefault, - parentSessionWorktreeSlug: nextMapping, - }, - request.log, - ) + const [client, targetDirectory] = await Promise.all([ + createInstanceClient(deps.workspaceManager, workspace.id), + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), + ]) + const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) + if (!client || !projectDirectory || !targetDirectory) { + throw new Error("Unable to inventory sessions before deleting worktree") } + const worktreeIdentity = await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, match.directory) + if (!worktreeIdentity) throw new Error("Unable to identify worktree before deletion") + await deps.worktreeDeletionFence.run(worktreeIdentity, [worktreeIdentity], () => ( + evacuateWorktreeSessions({ + client, + projectDirectory, + targetDirectory, + rootDirectory: projectDirectory, + resolveDirectoryIdentity: (directory) => deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory), + remove: () => removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }), + }) + )) + invalidateWorktreeCache(workspace.id) reply.code(204) } catch (error) { @@ -181,41 +145,6 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } }, ) - - app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => { - const workspace = deps.workspaceManager.get(request.params.id) - if (!workspace) { - reply.code(404) - return { error: "Workspace not found" } - } - return await readWorktreeMap(workspace.path, request.log) - }) - - app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => { - const workspace = deps.workspaceManager.get(request.params.id) - if (!workspace) { - reply.code(404) - return { error: "Workspace not found" } - } - - try { - const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap - if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) { - reply.code(400) - return { error: "Invalid defaultWorktreeSlug" } - } - for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) { - if (!isValidWorktreeSlug(slug)) { - reply.code(400) - return { error: "Invalid worktree slug in mapping" } - } - } - await writeWorktreeMap(workspace.path, parsed, request.log) - reply.code(204) - } catch (error) { - return handleError(error, reply) - } - }) } function handleError(error: unknown, reply: FastifyReply) { diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts index 5c3fc86d0..f8260d5c6 100644 --- a/packages/server/src/settings/binaries.test.ts +++ b/packages/server/src/settings/binaries.test.ts @@ -5,16 +5,35 @@ import { BinaryResolver } from "./binaries" import type { SettingsService } from "./service" describe("BinaryResolver", () => { - it("uses an explicit workspace binary without changing the configured default", () => { + it("uses the configured global binary", () => { const settings = { getOwner(scope: string, owner: string) { if (scope === "config" && owner === "server") return { opencodeBinary: "default-opencode" } - if (scope === "state" && owner === "ui") return { opencodeBinaries: [{ path: "saved-opencode", label: "Saved", version: "1.2.3" }] } + if (scope === "state" && owner === "ui") return { opencodeBinaries: [{ path: "default-opencode", label: "Custom", version: "1.2.3" }] } return {} }, } as unknown as SettingsService const resolver = new BinaryResolver(settings) - assert.deepEqual(resolver.resolve("saved-opencode"), { path: "saved-opencode", label: "Saved", version: "1.2.3" }) - assert.equal(resolver.resolveDefault().path, "default-opencode") + assert.deepEqual(resolver.resolveDefault(), { path: "default-opencode", label: "Custom", version: "1.2.3" }) + }) + + it("defaults to opencode2", () => { + const settings = { + getOwner: (scope: string, owner: string) => scope === "state" && owner === "ui" + ? { opencodeBinaries: [{ path: "listed-but-not-global" }] } + : {}, + } 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") }) }) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index d637ac95d..ce76b6ae5 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -40,14 +40,9 @@ export class BinaryResolver { } resolveDefault(): ResolvedBinary { - return this.resolve() - } - - resolve(explicitPath?: string): ResolvedBinary { const binaries = this.list() const configuredDefault = readDefaultBinaryPath(this.settings) - const fallback = binaries[0]?.path - const path = explicitPath?.trim() || configuredDefault || fallback || "opencode" + const path = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault const entry = binaries.find((b) => b.path === path) return { diff --git a/packages/server/src/settings/migrate.test.ts b/packages/server/src/settings/migrate.test.ts new file mode 100644 index 000000000..c1f3d37bc --- /dev/null +++ b/packages/server/src/settings/migrate.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import { parse as parseYaml } from "yaml" +import type { Logger } from "../logger" +import { resolveConfigLocation } from "../config/location" +import { migrateSettingsLayout } from "./migrate" + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +describe("settings migration", () => { + it("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" }) + }) +}) diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index d734ea3ef..5220805c2 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -101,7 +101,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co // Server-owned stable keys const envVars = preferences.environmentVariables if (isPlainObject(envVars)) { - serverConfig.environmentVariables = envVars + serverConfig.environmentVariables = { ...envVars } } const listeningMode = preferences.listeningMode if (typeof listeningMode === "string") { diff --git a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts index bc6382a1c..55e174321 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" @@ -8,30 +9,15 @@ import { listWorktrees } from "../git-worktrees" describe("listWorktrees", () => { it("uses the selected workspace folder for the root worktree directory", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) - const binDir = path.join(temp, "bin") const repoRoot = path.join(temp, "repo") const workspaceFolder = path.join(repoRoot, "proj-1") - const originalPath = process.env.PATH try { - mkdirSync(binDir, { recursive: true }) + execFileSync("git", ["init", "--initial-branch=main", repoRoot]) mkdirSync(workspaceFolder, { recursive: true }) - - const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git") - const porcelain = [ - `worktree ${repoRoot}`, - "HEAD 1111111", - "branch refs/heads/main", - "", - ].join("\n") - - if (process.platform === "win32") { - writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`) - } else { - writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 }) - } - - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}` + 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 }) @@ -41,7 +27,6 @@ describe("listWorktrees", () => { assert.equal(worktrees[0]?.branch, "main") assert.notEqual(worktrees[0]?.directory, repoRoot) } finally { - process.env.PATH = originalPath rmSync(temp, { recursive: true, force: true }) } }) diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index d11d8a66d..8600f0677 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -1,10 +1,18 @@ import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import { 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", () => { @@ -50,16 +58,6 @@ describe("resolveWslWorkingDirectory", () => { }) describe("buildWindowsSpawnSpec", () => { - it("classifies native executables separately from script and shell wrappers", () => { - assert.equal(buildWindowsSpawnSpec("opencode.exe", []).processKind, "windows-direct") - assert.equal(buildWindowsSpawnSpec("opencode.cmd", []).processKind, "windows-wrapper") - 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") @@ -76,7 +74,6 @@ describe("buildWindowsSpawnSpec", () => { }) assert.equal(spec.command, "test-cmd.exe") - assert.equal(spec.processKind, "windows-wrapper") assert.equal(spec.options.windowsVerbatimArguments, true) assert.match(spec.args[3] ?? "", new RegExp(escapeRegex(path.win32.resolve(shim)), "i")) } finally { @@ -96,7 +93,6 @@ describe("buildWindowsSpawnSpec", () => { }) assert.equal(spec.command.toLowerCase(), path.win32.resolve(root, "opencode.exe").toLowerCase()) - assert.equal(spec.processKind, "windows-direct") } finally { rmSync(root, { recursive: true, force: true }) } @@ -110,23 +106,15 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(spec.command, "missing-opencode") assert.deepEqual(spec.args, ["serve"]) - assert.equal(spec.processKind, "windows-wrapper") assert.equal(spec.options.windowsVerbatimArguments, undefined) }) - it("wraps WSL binaries with wsl.exe and propagates required env vars", () => { + it("wraps WSL binaries with wsl.exe", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve", "--port", "0"], { cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), - CODENOMAD_INSTANCE_ID: "workspace-123", - OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/instance", - OPENCODE_SERVER_PASSWORD: "secret", - }, - propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"], }, ) @@ -143,61 +131,6 @@ describe("buildWindowsSpawnSpec", () => { "0", ]) assert.equal(spec.cwd, undefined) - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") - }) - - it("preserves non-path OPENCODE_CONFIG_CONTENT WSLENV entries", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), - WSLENV: "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u", - }, - propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID"], - }, - ) - - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u") - }) - - it("rewrites packaged plugin paths for WSL before launching", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify({ - plugin: [ - "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz", - ], - }), - }, - propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT"], - }, - ) - - assert.equal(spec.command, "wsl.exe") - assert.equal(spec.env?.CODENOMAD_OPENCODE_PLUGIN_WSL_PATH, String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\codenomad-opencode-plugin.tgz`) - assert.match(spec.env?.OPENCODE_CONFIG_CONTENT ?? "", /__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__/) - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/p") - assert.deepEqual(spec.args.slice(0, 4), ["--distribution", "Ubuntu", "--exec", "sh"]) - assert.match(spec.args[5] ?? "", /CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/) - }) - - it("propagates inherited known path variables even when they are not explicitly requested", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { - env: { - NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`, - }, - }, - ) - - assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p") }) it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => { @@ -250,32 +183,81 @@ describe("buildWindowsSpawnSpec", () => { ]) }) - it("can wrap WSL launches to emit the Linux PID marker", () => { - const spec = buildWindowsSpawnSpec( - String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, - ["serve"], - { - cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, - wslPidMarker: "__CODENOMAD_WSL_PID__:", - }, - ) +}) - assert.equal(spec.command, "wsl.exe") - assert.deepEqual(spec.args, [ - "--distribution", - "Ubuntu", - "--exec", - "sh", - "-lc", - `codenomad_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]'); codenomad_start=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null); codenomad_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null); test -n "$codenomad_pgid" && test -n "$codenomad_start" && test -n "$codenomad_boot" && printf '%s%s:%s:%s:%s\\n' '__CODENOMAD_WSL_PID__:' "$$" "$codenomad_pgid" "$codenomad_start" "$codenomad_boot" && cd "$1" && shift && exec "$@"`, - "codenomad-wsl-launch", +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", - "/home/dev/.opencode/bin/opencode", - "serve", - ]) - assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:") + ) + }) + + 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 the configured host binary or discriminated WSL binary", () => { + assert.deepEqual( + buildServiceLaunchSpec("opencode.exe", { platform: "win32" }), + { kind: "host", binary: "opencode.exe", platform: "win32" }, + ) + assert.deepEqual( + buildServiceLaunchSpec("opencode.ps1", { platform: "win32" }), + { kind: "host", binary: "opencode.ps1", platform: "win32" }, + ) + assert.deepEqual( + buildServiceLaunchSpec(String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, { platform: "win32" }), + { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" }, + ) + }) + + it("never converts a WSL service binary into a host lifecycle", () => { + const launch = buildServiceLaunchSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, + { platform: "win32" }, + ) + + assert.deepEqual(launch, { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" }) + assert.equal("binary" in launch, true) + }) }) function escapeRegex(value: string): string { diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index ccf8e6ca4..13b8a46b0 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -7,21 +7,8 @@ import pino from "pino" import { EventBus } from "../../events/bus" import { WorkspaceManager } from "../manager" -import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity" const temporaryDirectories: string[] = [] -const runtimeResult = (pid = 123) => ({ - pid, - port: 4321, - exitPromise: new Promise(() => undefined), - getLastOutput: () => "", -}) - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) - return { promise, resolve } -} afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) @@ -39,148 +26,38 @@ async function createLinkedWorkspace() { function createManager(rootDir: string) { const logger = pino({ level: "silent" }) + const sharedService = { + endpoint: async () => ({ url: "http://127.0.0.1:4321" }), + client: async () => ({}), + headers: async () => undefined, + validateLocation: async ({ directory }: { directory: string }) => ({ + directory, + project: { id: directory, directory, canonical: directory }, + }), + evictLocation: async () => undefined, + subscribe: async () => ({ async *[Symbol.asyncIterator]() {} }), + shutdown: async () => undefined, + } const manager = new WorkspaceManager({ rootDir, settings: { getOwner: () => ({ environmentVariables: {} }) }, - binaryResolver: { resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }) }, + 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[0]) - ;(manager as any).runtime.launch = async () => runtimeResult() - ;(manager as any).runtime.stop = async () => undefined - ;(manager as any).waitForWorkspaceReadiness = async () => undefined return manager } -async function waitForOwners(manager: WorkspaceManager, count: number) { - while ([...(manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== count) { - await new Promise((resolve) => setImmediate(resolve)) - } -} - -async function createSharedLaunch() { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - let launches = 0 - ;(manager as any).runtime.launch = async () => { - launches += 1 - await launchGate.promise - return runtimeResult() - } - const leader = manager.create(target, undefined, { requestId: "leader" }) - const follower = manager.create(link, undefined, { requestId: "follower" }) - await waitForOwners(manager, 2) - return { manager, launchGate, leader, follower, launches: () => launches } -} - describe("workspace identity", () => { - it("normalizes Windows paths without affecting POSIX case", () => { - assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\") - assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`) - assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/") - }) - - it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { - const { root, target, link } = await createLinkedWorkspace() - const [targetResult, linkResult, missing] = await Promise.all([ - resolveWorkspaceIdentity(target, root), - resolveWorkspaceIdentity(link, root), - resolveWorkspaceIdentity("missing", root), - ]) - const expectedMissing = path.resolve(root, "missing") - - assert.equal(linkResult.identityKey, targetResult.identityKey) - assert.equal(linkResult.workspacePath, targetResult.workspacePath) - assert.equal(missing.workspacePath, expectedMissing) - assert.equal(missing.identityKey, normalizeWorkspaceIdentityPath(expectedMissing)) - }) - - it("deduplicates active canonical aliases", async () => { + it("creates separate workspaces for canonical aliases", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) const [first, second] = await Promise.all([manager.create(target), manager.create(link)]) - assert.equal(Number(first.created) + Number(second.created), 1) - assert.equal(first.workspace.id, second.workspace.id) - assert.equal(manager.list().length, 1) - }) - - it("shares one in-flight launch between canonical aliases", async () => { - const shared = await createSharedLaunch() - shared.launchGate.resolve() - const [leader, follower] = await Promise.all([shared.leader, shared.follower]) - - assert.equal(shared.launches(), 1) - assert.equal(leader.workspace.id, follower.workspace.id) - assert.equal(Number(leader.created) + Number(follower.created), 1) - assert.equal(leader.workspace.status, "ready") - }) - - for (const cancelledRole of ["leader", "follower"] as const) { - it(`detaches a cancelled ${cancelledRole} without stopping its shared owner`, async () => { - const shared = await createSharedLaunch() - await shared.manager.cancelCreationRequest(cancelledRole) - shared.launchGate.resolve() - const cancelled = shared[cancelledRole] - const survivor = shared[cancelledRole === "leader" ? "follower" : "leader"] - - await assert.rejects(cancelled, new RegExp(`creation request ${cancelledRole} was cancelled`)) - const result = await survivor - assert.equal(shared.launches(), 1) - assert.equal(result.workspace.requestId, cancelledRole === "leader" ? "follower" : "leader") - assert.equal(shared.manager.list().length, 1) - }) - } - - for (const releasedRole of ["leader", "follower"] as const) { - it(`retains shared ownership when the ${releasedRole} releases and the other owner cancels`, async () => { - const shared = await createSharedLaunch() - shared.launchGate.resolve() - const [leader, follower] = await Promise.all([shared.leader, shared.follower]) - assert.equal(leader.workspace.id, follower.workspace.id) - - assert.equal(shared.manager.releaseCreationRequest(leader.workspace.id, releasedRole), true) - await shared.manager.cancelCreationRequest(releasedRole === "leader" ? "follower" : "leader") - assert.equal(shared.manager.list().length, 1) - assert.equal(shared.manager.get(leader.workspace.id)?.requestId, undefined) - }) - } - - it("releases a failed canonical reservation for retry", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - let launches = 0 - ;(manager as any).runtime.launch = async () => { - launches += 1 - await launchGate.promise - throw new Error("launch failed") - } - const failures = [ - manager.create(target, undefined, { requestId: "first" }), - manager.create(link, undefined, { requestId: "second" }), - ] - await waitForOwners(manager, 2) - launchGate.resolve() - assert.deepEqual((await Promise.allSettled(failures)).map((result) => result.status), ["rejected", "rejected"]) - assert.equal(launches, 1) - - ;(manager as any).runtime.launch = async () => runtimeResult(456) - assert.equal((await manager.create(target)).created, true) - }) - - it("allows forced canonical duplicates without replacing the reusable workspace", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const normal = await manager.create(target) - const forced = await manager.create(link, undefined, { forceNew: true }) - assert.notEqual(normal.workspace.id, forced.workspace.id) - - await manager.delete(forced.workspace.id) - const reused = await manager.create(link) - assert.equal(reused.created, false) - assert.equal(reused.workspace.id, normal.workspace.id) + assert.notEqual(first.workspace.id, second.workspace.id) + assert.equal(first.workspace.path, second.workspace.path) + assert.equal(first.created && second.created, true) + assert.equal(manager.list().length, 2) }) }) diff --git a/packages/server/src/workspaces/git-status.test.ts b/packages/server/src/workspaces/git-status.test.ts new file mode 100644 index 000000000..1d45504b9 --- /dev/null +++ b/packages/server/src/workspaces/git-status.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { getWorktreeGitStatus, invalidateWorktreeGitStatus } from "./git-status" + +describe("worktree git status singleflight", () => { + it("coalesces concurrent requests and runs again after settlement", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codenomad-git-status-")) + let calls = 0 + let release!: () => void + const blocked = new Promise((resolve) => { release = resolve }) + const run = async () => { + calls += 1 + await blocked + return { ok: true as const, stdout: "" } + } + + try { + const first = getWorktreeGitStatus({ workspaceFolder: directory }, run) + const second = getWorktreeGitStatus({ workspaceFolder: path.join(directory, ".") }, run) + const deadline = Date.now() + 1_000 + while (calls === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)) + } + assert.equal(calls, 5) + release() + assert.deepEqual(await Promise.all([first, second]), [[], []]) + assert.equal(calls, 5) + + await getWorktreeGitStatus({ workspaceFolder: directory }, run) + assert.equal(calls, 10) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it("starts a new generation after a successful mutation while an older read is blocked", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codenomad-git-status-")) + let calls = 0 + let releaseOld!: () => void + const oldBlocked = new Promise((resolve) => { releaseOld = resolve }) + const run = async () => { + calls += 1 + if (calls <= 5) await oldBlocked + return { ok: true as const, stdout: "" } + } + + try { + const oldRead = getWorktreeGitStatus({ workspaceFolder: directory }, run) + while (calls < 5) await new Promise((resolve) => setImmediate(resolve)) + await invalidateWorktreeGitStatus(path.join(directory, ".")) + const newRead = getWorktreeGitStatus({ workspaceFolder: directory }, run) + assert.deepEqual(await newRead, []) + assert.equal(calls, 10) + releaseOld() + assert.deepEqual(await oldRead, []) + + await getWorktreeGitStatus({ workspaceFolder: directory }, run) + assert.equal(calls, 15) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it("cleans up a rejected flight so the next read can retry", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "codenomad-git-status-")) + let fail = true + let calls = 0 + const run = async () => { + calls += 1 + return fail + ? { ok: false as const, error: new Error("status failed") } + : { ok: true as const, stdout: "" } + } + + try { + await assert.rejects(getWorktreeGitStatus({ workspaceFolder: directory }, run), /status failed/) + fail = false + assert.deepEqual(await getWorktreeGitStatus({ workspaceFolder: directory }, run), []) + assert.equal(calls, 10) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/workspaces/git-status.ts b/packages/server/src/workspaces/git-status.ts index 57d7cfb24..d324c7152 100644 --- a/packages/server/src/workspaces/git-status.ts +++ b/packages/server/src/workspaces/git-status.ts @@ -1,5 +1,5 @@ import { spawn } from "child_process" -import { readFile } from "fs/promises" +import { readFile, realpath } from "fs/promises" import path from "path" import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types" @@ -8,6 +8,9 @@ import { normalizeGitWorktreeRelativePath } from "./git-mutations" type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } type GitSuccessResult = Extract +type RunGit = typeof runGit +const gitStatusGenerations = new Map() +const gitStatusRequests = new Map }>() async function readFileAsDiffText(filePath: string): Promise { return readFile(filePath, "utf-8") @@ -204,14 +207,36 @@ function applyNumstatOutput( export async function getWorktreeGitStatus(params: { workspaceFolder: string logger?: LogLike -}): Promise { +}, run: RunGit = runGit): Promise { + const workspaceFolder = await realpath(params.workspaceFolder) + const generation = gitStatusGenerations.get(workspaceFolder) ?? 0 + const pending = gitStatusRequests.get(workspaceFolder) + if (pending?.generation === generation) return pending.request + + const request = readWorktreeGitStatus({ ...params, workspaceFolder }, run).finally(() => { + if (gitStatusRequests.get(workspaceFolder)?.request === request) gitStatusRequests.delete(workspaceFolder) + if (!gitStatusRequests.has(workspaceFolder)) gitStatusGenerations.delete(workspaceFolder) + }) + gitStatusRequests.set(workspaceFolder, { generation, request }) + return request +} + +export async function invalidateWorktreeGitStatus(workspaceFolder: string): Promise { + const canonicalDirectory = await realpath(workspaceFolder) + gitStatusGenerations.set(canonicalDirectory, (gitStatusGenerations.get(canonicalDirectory) ?? 0) + 1) +} + +async function readWorktreeGitStatus(params: { + workspaceFolder: string + logger?: LogLike +}, run: RunGit): Promise { const { workspaceFolder, logger } = params const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([ - runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), - runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder), - runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder), - runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), - runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder), + run(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + run(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder), + run(["ls-files", "--others", "--exclude-standard"], workspaceFolder), + run(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + run(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder), ]) for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) { diff --git a/packages/server/src/workspaces/host-opencode-service.test.ts b/packages/server/src/workspaces/host-opencode-service.test.ts new file mode 100644 index 000000000..21758b44f --- /dev/null +++ b/packages/server/src/workspaces/host-opencode-service.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { HostOpenCodeService, hostOpenCodeServiceIdentity } from "./host-opencode-service" +import type { OpenCodeCliServiceDependencies, ServiceExecOptions } from "./opencode-cli-service" + +const url = "http://127.0.0.1:4321" + +describe("HostOpenCodeService", () => { + it("uses status, start, and password through buildSpawnSpec without a shell", async () => { + const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = [] + const service = createService(calls, { PROVIDER_TOKEN: "secret", NODE_EXTRA_CA_CERTS: "/ca.pem" }) + + assert.equal(await service.discover(), undefined) + assert.deepEqual(await service.ensure(), { + url, + auth: { type: "basic", username: "opencode", password: "password" }, + }) + + assert.deepEqual(calls.map(({ args }) => args), [ + ["service", "status"], + ["service", "start"], + ["service", "get", "password"], + ]) + assert.equal(calls.every(({ options }) => options.shell === false), true) + assert.equal(calls[0]?.options.env, undefined) + assert.equal(calls[2]?.options.env, undefined) + assert.equal(calls[1]?.options.env?.PROVIDER_TOKEN, "secret") + assert.equal(calls[1]?.options.env?.NODE_EXTRA_CA_CERTS, "/ca.pem") + }) + + it("authenticates strict bounded health and rejects malformed output", async () => { + let authorization: string | null = null + const calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }> = [] + const service = createService(calls, {}, { + fetch: async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization") + return Response.json({ healthy: true, version: "2.0.0", pid: 1 }) + }, + execFile: async (file, args, options) => { + calls.push({ file, args, options }) + return { stdout: args[args.length - 1] === "password" ? "password\n" : `${url}\n`, stderr: "" } + }, + }) + await service.discover() + assert.equal(authorization, `Basic ${Buffer.from("opencode:password").toString("base64")}`) + + const malformed = createService([], {}, { + execFile: async () => ({ stdout: `${url}\nhttp://127.0.0.1:4322\n`, stderr: "" }), + }) + await assert.rejects(malformed.discover(), /multiline/) + }) + + it("connects to wildcard services through loopback", async () => { + let healthUrl = "" + const service = createService([], {}, { + execFile: async (_file, args) => ({ + stdout: args[args.length - 1] === "password" ? "password\n" : "http://0.0.0.0:4321\n", + stderr: "", + }), + fetch: async (input) => { + healthUrl = String(input) + return Response.json({ healthy: true, version: "2.0.0", pid: 1 }) + }, + }) + + assert.equal((await service.discover())?.url, "http://127.0.0.1:4321/") + assert.equal(healthUrl, "http://127.0.0.1:4321/api/health") + }) + + it("redacts startup environment values from failures and hashes identity", async () => { + const secret = "DO_NOT_LEAK" + const service = createService([], { TOKEN: secret }, { + execFile: async (_file, args) => { + if (args[args.length - 1] === "status") return { stdout: "stopped\n", stderr: "" } + throw Object.assign(new Error(secret), { code: 7, stdout: secret, stderr: secret }) + }, + }) + await service.discover() + await assert.rejects(service.ensure(), (error: Error) => { + assert.match(error.message, /start failed \(exit code 7\)/) + assert.equal(error.message.includes(secret), false) + return true + }) + + const identity = hostOpenCodeServiceIdentity({ + binary: process.platform === "win32" ? String.raw`C:\tools\..\opencode.exe` : "/opt/../opencode", + startupEnvironment: { TOKEN: secret }, + }) + assert.match(identity, /:env:[a-f0-9]{64}$/) + assert.equal(identity.includes(secret), false) + }) +}) + +function createService( + calls: Array<{ file: string; args: string[]; options: ServiceExecOptions }>, + startupEnvironment: NodeJS.ProcessEnv, + overrides: Partial = {}, +) { + return new HostOpenCodeService({ + binary: process.execPath, + startupEnvironment, + timeoutMs: 500, + }, { + execFile: async (file, args, options) => { + calls.push({ file, args, options }) + const command = args.join(" ") + if (command === "service status") return { stdout: "stopped\n", stderr: "" } + if (command === "service start") return { stdout: `${url}\n`, stderr: "" } + return { stdout: "password\n", stderr: "" } + }, + fetch: async () => Response.json({ healthy: true, version: "2.0.0", pid: 123 }), + ...overrides, + }) +} diff --git a/packages/server/src/workspaces/host-opencode-service.ts b/packages/server/src/workspaces/host-opencode-service.ts new file mode 100644 index 000000000..1ce1235ef --- /dev/null +++ b/packages/server/src/workspaces/host-opencode-service.ts @@ -0,0 +1,58 @@ +import { createHash } from "node:crypto" +import path from "node:path" + +import { OpenCodeCliService, type OpenCodeCliServiceDependencies } from "./opencode-cli-service" +import { buildSpawnSpec } from "./spawn" + +const DEFAULT_TIMEOUT_MS = 30_000 + +export interface HostOpenCodeServiceOptions { + binary: string + platform?: NodeJS.Platform + startupEnvironment?: NodeJS.ProcessEnv + timeoutMs?: number +} + +export class HostOpenCodeService extends OpenCodeCliService { + constructor( + options: HostOpenCodeServiceOptions, + dependencies: Partial = {}, + ) { + const platform = options.platform ?? process.platform + const startupEnvironment = daemonProcessEnvironment(options.startupEnvironment) + super({ + label: "Host", + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + command: (args, start) => buildSpawnSpec(options.binary, args, { + platform, + ...(start ? { env: startupEnvironment } : {}), + }), + }, dependencies) + } +} + +export function daemonProcessEnvironment(overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const environment = { ...process.env, ...overrides } + for (const key of Object.keys(environment)) { + if (["OPENCODE_DB", "XDG_STATE_HOME"].includes(key.toUpperCase())) delete environment[key] + } + return environment +} + +export function hostOpenCodeServiceIdentity(options: HostOpenCodeServiceOptions): string { + const platform = options.platform ?? process.platform + const binary = platform === "win32" + ? path.win32.normalize(options.binary).toLowerCase() + : path.normalize(options.binary) + return `host:${platform}:${binary}:env:${startupEnvironmentHash(options.startupEnvironment, platform)}` +} + +export function startupEnvironmentHash( + startupEnvironment: NodeJS.ProcessEnv | undefined, + platform: NodeJS.Platform = process.platform, +): string { + const environment = Object.entries(startupEnvironment ?? {}) + .map(([key, value]) => [platform === "win32" ? key.toUpperCase() : key, value ?? ""] as const) + .sort(([left], [right]) => left.localeCompare(right)) + return createHash("sha256").update(JSON.stringify(environment)).digest("hex") +} diff --git a/packages/server/src/workspaces/instance-client.test.ts b/packages/server/src/workspaces/instance-client.test.ts deleted file mode 100644 index 636bc9f1a..000000000 --- a/packages/server/src/workspaces/instance-client.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { createInstanceClient } from "./instance-client" -import type { WorkspaceManager } from "./manager" - -/** - * Minimal stand-in for the parts of {@link WorkspaceManager} the factory reads. - * The factory only touches three members, so a structural stub is enough and - * keeps the test free of the full manager's heavy dependencies. - */ -interface StubWorkspaceManager { - getInstancePort: (id: string) => number | undefined - getInstanceAuthorizationHeader: (id: string) => string | undefined - get: (id: string) => { path: string } | undefined -} - -function makeManager(overrides: Partial = {}): StubWorkspaceManager { - return { - getInstancePort: overrides.getInstancePort ?? (() => undefined), - getInstanceAuthorizationHeader: overrides.getInstanceAuthorizationHeader ?? (() => undefined), - get: overrides.get ?? (() => undefined), - } -} - -interface CapturedRequest { - url: string - headers: Headers -} - -/** - * Installs a global `fetch` stub that records every outgoing request and - * answers a minimal healthy JSON body. Returns the capture buffer and a - * restore function. The stub tolerates both `fetch(url, init)` and - * `fetch(Request)` invocation styles so it is independent of the SDK's - * internal call convention. - */ -function installRecordingFetch(): { requests: CapturedRequest[]; restore: () => void } { - const requests: CapturedRequest[] = [] - const original = globalThis.fetch - - globalThis.fetch = (async (input: any, init: any) => { - if (input instanceof Request) { - requests.push({ url: input.url, headers: new Headers(init?.headers ?? input.headers) }) - } else { - requests.push({ url: String(input), headers: new Headers(init?.headers) }) - } - return new Response(JSON.stringify({ healthy: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }) - }) as typeof fetch - - return { requests, restore: () => { globalThis.fetch = original } } -} - -describe("createInstanceClient", () => { - it("returns null when the instance has no open port", () => { - const manager = makeManager({ getInstancePort: () => undefined }) - assert.equal(createInstanceClient(manager as unknown as WorkspaceManager, "ws-1"), null) - }) - - it("targets the loopback host and port on outgoing requests", async () => { - const { requests, restore } = installRecordingFetch() - try { - const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") - assert.ok(client, "expected a client when the instance has a port") - - await client!.global.health() - const parsed = new URL(requests[0].url) - assert.equal(parsed.hostname, "127.0.0.1") - assert.equal(parsed.port, "4321") - } finally { - restore() - } - }) - - it("attaches the authorization header when one is configured", async () => { - const { requests, restore } = installRecordingFetch() - try { - const manager = makeManager({ - getInstancePort: () => 4321, - getInstanceAuthorizationHeader: () => "Basic abc", - get: () => ({ path: "/repo" }), - }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") - - await client!.global.health() - assert.equal(requests[0].headers.get("authorization"), "Basic abc") - } finally { - restore() - } - }) - - it("omits the authorization header when none is configured", async () => { - const { requests, restore } = installRecordingFetch() - try { - const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") - - await client!.global.health() - assert.equal(requests[0].headers.get("authorization"), null) - } finally { - restore() - } - }) - - it("scopes requests to the workspace directory", async () => { - const { requests, restore } = installRecordingFetch() - try { - const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") - - await client!.global.health() - // GET requests carry directory as a query parameter (see SDK rewrite). - assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/repo") - } finally { - restore() - } - }) - - it("does not scope requests when the workspace has no path", async () => { - const { requests, restore } = installRecordingFetch() - try { - const manager = makeManager({ getInstancePort: () => 4321, get: () => undefined }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") - - await client!.global.health() - assert.equal(new URL(requests[0].url).searchParams.get("directory"), null) - } finally { - restore() - } - }) - - it("honours an explicit directory override over the workspace root", async () => { - const { requests, restore } = installRecordingFetch() - try { - const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/workspace-root" }) }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { - directory: "/explicit/session-dir", - }) - - await client!.global.health() - assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/explicit/session-dir") - } finally { - restore() - } - }) - - it("applies the loopback timeout and aborts a stuck instance", async () => { - const original = globalThis.fetch - // Never resolves on its own; only settles when the passed signal aborts, - // mirroring how a real fetch honours an AbortSignal. Without the factory - // timeout this call would hang forever and time the test out. - globalThis.fetch = (async (_input: any, init: any) => { - return new Promise((_resolve, reject) => { - const signal = (init as RequestInit | undefined)?.signal - if (!signal) return - if (signal.aborted) reject((signal as AbortSignal).reason ?? new Error("aborted")) - else signal.addEventListener("abort", () => reject((signal as AbortSignal).reason ?? new Error("aborted"))) - }) - }) as typeof fetch - try { - const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) - const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 }) - - // SDK methods resolve with { error } rather than throwing by default. - const result = await client!.global.health() - assert.ok(result.error, "expected the stuck-instance call to surface an error") - } finally { - globalThis.fetch = original - } - }) -}) diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts index 5ee323f04..eb7c2d23c 100644 --- a/packages/server/src/workspaces/instance-client.ts +++ b/packages/server/src/workspaces/instance-client.ts @@ -1,63 +1,14 @@ -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { OpenCodeClient } from "@opencode-ai/client" import type { WorkspaceManager } from "./manager" -import { LOOPBACK_HOST } from "./loopback" - -const LOOPBACK_TIMEOUT_MS = 10_000 - -interface InstanceClientOptions { - timeoutMs?: number - /** - * Directory the instance should scope the call to. Defaults to the - * workspace root; pass an explicit path when targeting a session that - * lives elsewhere (e.g. a worktree) so OpenCode resolves the right - * project context. - */ - directory?: string -} /** - * Creates an OpenCode SDK client for direct loopback communication with a - * running workspace instance. - * - * Routes and body shapes come from the auto-generated SDK contract - * (`@opencode-ai/sdk`), eliminating handwritten URL construction that can - * drift between SDK versions. Other server modules that need to call the - * OpenCode instance directly should use this factory rather than building - * `http://127.0.0.1:{port}/...` URLs by hand. - * - * Requests carry a 10-second timeout (configurable via `timeoutMs`) — - * loopback calls should be near-instant; a hang indicates a stuck instance. - * - * The client is cheap to create (object only, no connection); create one per - * call or cache per instance as needed. Returns `null` when the instance has - * no open port yet. + * Returns the shared native OpenCode client when the logical workspace is ready. + * Session APIs resolve their location from the session itself. */ -export function createInstanceClient( +export async function createInstanceClient( workspaceManager: WorkspaceManager, instanceId: string, - options: InstanceClientOptions = {}, -): OpencodeClient | null { - const port = workspaceManager.getInstancePort(instanceId) - if (!port) return null - - const headers: Record = {} - const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId) - if (authorization) { - headers.authorization = authorization - } - - const workspace = workspaceManager.get(instanceId) - const timeoutMs = options.timeoutMs ?? LOOPBACK_TIMEOUT_MS - const directory = options.directory ?? workspace?.path - - return createOpencodeClient({ - baseUrl: `http://${LOOPBACK_HOST}:${port}/`, - headers, - fetch: (url, init) => - fetch(url, { - ...(init as RequestInit), - signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(timeoutMs), - }), - ...(directory ? { directory } : {}), - }) +): Promise { + if (!workspaceManager.get(instanceId)) return null + return workspaceManager.getSharedServiceClient() } diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts new file mode 100644 index 000000000..1c81df8d6 --- /dev/null +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -0,0 +1,465 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeEvent } from "@opencode-ai/client" +import { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import { InstanceEventBridge } from "./instance-events" +import type { WorkspaceManager } from "./manager" + +const logger = { + debug() {}, + warn() {}, +} as unknown as Logger + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +function waitFor(check: () => boolean): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for event")), 2000) + const poll = () => { + if (check()) { + clearTimeout(timeout) + resolve() + } else { + setTimeout(poll, 0) + } + } + poll() + }) +} + +function serverConnected(): OpenCodeEvent { + return { type: "server.connected", data: {} } as OpenCodeEvent +} + +function locationlessManager( + events: OpenCodeEvent[], + sessionLocations: Record, + 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 serverConnected() + yield* events + await new Promise((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() + const manager = { + list: () => [{ id: "a", path: "/repo-a" }], + ownsDirectory: async () => true, + subscribeToSharedService: async (signal?: AbortSignal) => (async function* () { + await gate.promise + yield serverConnected() + await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })(), + } as unknown as WorkspaceManager + const bus = new EventBus() + const statuses: string[] = [] + bus.on("instance.eventStatus", (event) => statuses.push(event.status)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => statuses.includes("connecting")) + assert.equal(statuses.includes("connected"), false) + gate.resolve() + await waitFor(() => statuses.includes("connected")) + } finally { + bridge.shutdown() + } + }) + + it("rejects a stream whose first event is not server.connected and reconnects", async () => { + let subscriptions = 0 + const manager = { + list: () => [{ id: "a", path: "/repo-a" }], + ownsDirectory: async () => true, + subscribeToSharedService: async (signal?: AbortSignal) => { + subscriptions += 1 + return (async function* () { + if (subscriptions === 1) { + yield { type: "permission.asked", location: { directory: "/repo-a" }, data: { id: "p1" } } as OpenCodeEvent + return + } + yield serverConnected() + await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })() + }, + } as unknown as WorkspaceManager + const bus = new EventBus() + const statuses: Array<{ status: string; reason?: string }> = [] + const received: OpenCodeEvent[] = [] + bus.on("instance.eventStatus", (event) => statuses.push(event)) + bus.on("instance.event", (event) => received.push(event.event)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => statuses.some((event) => event.status === "error")) + assert.match(statuses.find((event) => event.status === "error")?.reason ?? "", /expected server\.connected/) + assert.deepEqual(received, []) + await waitFor(() => statuses.some((event) => event.status === "connected")) + assert.equal(subscriptions, 2) + } 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 serverConnected() + yield event + if (current > 1) await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })() + }, + } as unknown as WorkspaceManager + const bus = new EventBus() + const received: unknown[] = [] + bus.on("instance.event", (value) => { + if (value.event.type !== "server.connected") 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: "0", created: 0, type: "server.connected", data: {} }, + { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo-a" }, data: { id: "p1" } }, + { + id: "2", + created: 2, + type: "session.created", + durable: { aggregateID: "session-1", seq: 1, version: 1 }, + location: { directory: "/repo-a" }, + data: { + sessionID: "session-1", + projectID: "project-1", + location: { directory: "/repo-a" }, + slug: "session", + version: "1", + }, + }, + { id: "3", created: 3, type: "permission.asked", location: { directory: "/other" }, data: { id: "p2" } }, + { + id: "5", + created: 5, + type: "session.text.delta", + location: { directory: "/repo-a/.worktrees/feature" }, + data: { sessionID: "session-2", assistantMessageID: "message-1", ordinal: 0, delta: "hello" }, + }, + { + id: "6", + created: 6, + type: "session.text.delta", + location: { directory: "/repo-a/.worktrees/feature" }, + data: { sessionID: "session-2", assistantMessageID: "message-1", ordinal: 1, delta: " again" }, + }, + ] as OpenCodeEvent[] + const ownerLookups = new Map() + const manager = { + list: () => [ + { id: "a", path: "/repo-a" }, + { id: "b", path: "/repo-b" }, + ], + ownsDirectory: async (workspaceId: string, directory: string) => { + ownerLookups.set(directory, (ownerLookups.get(directory) ?? 0) + 1) + await Promise.resolve() + return workspaceId === "a" && (directory === "/repo-a" || directory === "/repo-a/.worktrees/feature") + }, + subscribeToSharedService: async (signal?: AbortSignal) => (async function* () { + yield* events + await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })(), + } as unknown as WorkspaceManager + 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 === 6) + assert.deepEqual(received.slice(0, 2).map((event) => [event.instanceId, event.event.type]), [ + ["a", "server.connected"], + ["b", "server.connected"], + ]) + assert.equal(received[2].instanceId, "a") + assert.deepEqual(received[2].event.location, { directory: "/repo-a" }) + assert.deepEqual(received[2].event.data, { id: "p1" }) + assert.equal(received[2].event.properties, undefined) + assert.equal(received[3].event.data.sessionID, "session-1") + assert.equal(received[3].event.properties, undefined) + assert.equal(received[4].instanceId, "a") + assert.deepEqual(received[4].event.data, { + sessionID: "session-2", + assistantMessageID: "message-1", + ordinal: 0, + delta: "hello", + }) + assert.equal(received[5].instanceId, "a") + assert.equal(received[5].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 serverConnected() + yield { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo" }, data: { id: "p1" } } as OpenCodeEvent + await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })(), + } as unknown as WorkspaceManager + const bus = new EventBus() + const received: string[] = [] + bus.on("instance.event", (event) => { + if (event.event.type !== "server.connected") 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) => { + if (event.event.type !== "server.connected") 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) => { + if (event.event.type !== "server.connected") 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) => { + if (event.event.type !== "server.connected") 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("routes locationless shell events by cwd without broadcasting ownership", async () => { + const events = [ + { type: "shell.created", data: { info: { id: "shell-1", command: "npm run dev", cwd: "/repo-b", shell: "sh", file: "/tmp/output", status: "running", metadata: {}, time: { started: 1 } } } }, + { type: "shell.exited", data: { id: "shell-1", status: "exited", exit: 0 } }, + { type: "shell.deleted", data: { id: "shell-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) => { + if (event.event.type !== "server.connected") 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), ["shell.created", "shell.exited", "shell.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) => { + if (event.event.type !== "server.connected") 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) => { + if (event.event.type !== "server.connected") 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) => { + if (event.event.type !== "server.connected") 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) => { + if (event.event.type !== "server.connected") received.push(event) + }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === events.length * workspaces.length) + assert.equal(sessionGets(), 0) + assert.deepEqual(received.map((event) => event.event.type), events.flatMap((event) => [event.type, event.type])) + assert.deepEqual(received.map((event) => event.instanceId), events.flatMap(() => ["a", "b"])) + } finally { + bridge.shutdown() + } + }) +}) diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 5be037007..5b9bc7096 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -1,13 +1,26 @@ -import { Agent, fetch } from "undici" -import { Agent as UndiciAgent } from "undici" +import type { OpenCodeEvent } from "@opencode-ai/client" import { EventBus } from "../events/bus" import { Logger } from "../logger" import { WorkspaceManager } from "./manager" -import { LOOPBACK_HOST } from "./loopback" -import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types" +import { InstanceStreamStatus } from "../api-types" -const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 }) 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", + "server.connected", +]) interface InstanceEventBridgeOptions { workspaceManager: WorkspaceManager @@ -15,212 +28,224 @@ interface InstanceEventBridgeOptions { logger: Logger } -interface ActiveStream { - controller: AbortController - task: Promise -} - export class InstanceEventBridge { - private readonly streams = new Map() + private readonly controller = new AbortController() + private status: InstanceStreamStatus = "connecting" + private generation = 0 + private task?: Promise + private readonly directoryOwners = new Map }>() + private readonly sessionDirectories = new Map }>() + private readonly ptyDirectories = new Map() + private readonly shellDirectories = new Map() + private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { + this.clearLocationCaches() + if (!this.task) this.task = this.run() + else this.publishStatus(event.workspace.id, this.status) + } + private readonly onWorkspaceStopped = (event: { workspaceId: string }) => { + this.clearLocationCaches() + this.publishStatus(event.workspaceId, "disconnected", "workspace stopped") + } + private readonly onWorkspaceError = (event: { workspace: { id: string } }) => { + this.clearLocationCaches() + this.publishStatus(event.workspace.id, "disconnected", "workspace error") + } constructor(private readonly options: InstanceEventBridgeOptions) { const bus = this.options.eventBus - bus.on("workspace.started", (event) => this.startStream(event.workspace.id)) - bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped")) - bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error")) + bus.on("workspace.started", this.onWorkspaceStarted) + bus.on("workspace.stopped", this.onWorkspaceStopped) + bus.on("workspace.error", this.onWorkspaceError) } shutdown() { - for (const [id, active] of this.streams) { - active.controller.abort() - this.publishStatus(id, "disconnected") + this.controller.abort() + const bus = this.options.eventBus + bus.off("workspace.started", this.onWorkspaceStarted) + bus.off("workspace.stopped", this.onWorkspaceStopped) + bus.off("workspace.error", this.onWorkspaceError) + for (const workspace of this.options.workspaceManager.list()) { + this.publishStatus(workspace.id, "disconnected") } - this.streams.clear() } - private startStream(workspaceId: string) { - if (this.streams.has(workspaceId)) { - return - } - - const controller = new AbortController() - const task = this.runStream(workspaceId, controller.signal) - .catch((error) => { - if (!controller.signal.aborted) { - this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed") - this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error)) - } - }) - .finally(() => { - const active = this.streams.get(workspaceId) - if (active?.controller === controller) { - this.streams.delete(workspaceId) + private async run() { + while (!this.controller.signal.aborted) { + this.generation += 1 + this.clearLocationCaches() + this.updateStatus("connecting") + try { + const events = await this.options.workspaceManager.subscribeToSharedService(this.controller.signal) + let confirmed = false + for await (const event of events) { + if (this.controller.signal.aborted) return + if (!confirmed) { + if (event.type !== "server.connected") { + throw new Error(`Shared OpenCode event stream started with ${event.type}, expected server.connected`) + } + confirmed = true + this.updateStatus("connected") + } + await this.publishEvent(event) } - }) - - this.streams.set(workspaceId, { controller, task }) - } - - private stopStream(workspaceId: string, reason?: string) { - const active = this.streams.get(workspaceId) - if (!active) { - return + if (!this.controller.signal.aborted) throw new Error("Shared OpenCode event stream ended") + } catch (error) { + if (this.controller.signal.aborted) return + this.options.logger.warn({ err: error }, "Shared OpenCode event stream disconnected") + this.updateStatus("error", error instanceof Error ? error.message : String(error)) + await this.delay(RECONNECT_DELAY_MS) + } } - active.controller.abort() - this.streams.delete(workspaceId) - this.publishStatus(workspaceId, "disconnected", reason) } - private async runStream(workspaceId: string, signal: AbortSignal) { - while (!signal.aborted) { - const port = this.options.workspaceManager.getInstancePort(workspaceId) - if (!port) { - await this.delay(RECONNECT_DELAY_MS, signal) - continue + private async publishEvent(event: OpenCodeEvent) { + const sessionId = this.sessionId(event) + const ptyId = this.ptyId(event) + const shellId = this.shellId(event) + if (event.type === "session.moved" && sessionId) this.sessionDirectories.delete(sessionId) + + const directory = event.location?.directory + ?? this.ptyInfoDirectory(event) + ?? (ptyId ? this.ptyDirectories.get(ptyId) : undefined) + ?? this.shellInfoDirectory(event) + ?? (shellId ? this.shellDirectories.get(shellId) : undefined) + ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) + if (!directory) { + if (GLOBAL_EVENT_TYPES.has(event.type)) { + this.broadcastEvent(event) + return } - - this.publishStatus(workspaceId, "connecting") - - try { - await this.consumeStream(workspaceId, port, signal) - } catch (error) { - if (signal.aborted) { - break - } - this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected") - this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error)) - await this.delay(RECONNECT_DELAY_MS, signal) + 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 } - } - - private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) { - const url = `http://${LOOPBACK_HOST}:${port}/global/event` - - const headers: Record = { Accept: "text/event-stream" } - const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId) - if (authHeader) { - headers["Authorization"] = authHeader + if (sessionId) { + this.sessionDirectories.set(sessionId, { + expiresAt: Date.now() + SESSION_DIRECTORY_CACHE_MS, + directory: Promise.resolve(directory), + }) } - - const response = await fetch(url, { - headers, - signal, - dispatcher: STREAM_AGENT, - }) - - if (!response.ok || !response.body) { - throw new Error(`Instance event stream unavailable (${response.status})`) + if (ptyId) this.ptyDirectories.set(ptyId, directory) + if (shellId) this.shellDirectories.set(shellId, 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) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) + return } - this.publishStatus(workspaceId, "connected") - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = "" - - while (!signal.aborted) { - const { done, value } = await reader.read() - if (done || !value) { - break - } - buffer += decoder.decode(value, { stream: true }) - buffer = this.flushEvents(buffer, workspaceId) + 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) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) } - private flushEvents(buffer: string, workspaceId: string) { - let separatorIndex = buffer.indexOf("\n\n") - - while (separatorIndex >= 0) { - const chunk = buffer.slice(0, separatorIndex) - buffer = buffer.slice(separatorIndex + 2) - this.processChunk(chunk, workspaceId) - separatorIndex = buffer.indexOf("\n\n") - } + 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 + } - return buffer + 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 processChunk(chunk: string, workspaceId: string) { - const lines = chunk.split(/\r?\n/) - const dataLines: string[] = [] + 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 + } - for (const line of lines) { - if (line.startsWith(":")) { - continue - } - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()) - } - } + private shellId(event: OpenCodeEvent): string | undefined { + if (!event.type.startsWith("shell.")) 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 + } - if (dataLines.length === 0) { - return - } + private shellInfoDirectory(event: OpenCodeEvent): string | undefined { + if (event.type !== "shell.created") return undefined + const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd + return typeof cwd === "string" && cwd ? cwd : undefined + } - const payload = dataLines.join("\n").trim() - if (!payload) { - return + private broadcastEvent(event: OpenCodeEvent): void { + for (const workspace of this.options.workspaceManager.list()) { + this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event }) } + } - try { - const parsed = JSON.parse(payload) as any - if (!parsed || typeof parsed !== "object") { - this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event") - return - } + private resolveSessionDirectory(sessionId: string): Promise { + const now = Date.now() + const cached = this.sessionDirectories.get(sessionId) + if (cached && cached.expiresAt > now) return cached.directory - // OpenCode SSE payload shapes vary across versions. - // Common variants: - // - { type, properties, ... } - // - { payload: { type, properties, ... }, directory: "/abs/path" } - // - { payload: { type, properties, ... } } - const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed + 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, sessionId }, "Failed to resolve instance event session location") + return undefined + }) + this.sessionDirectories.set(sessionId, { expiresAt: now + SESSION_DIRECTORY_CACHE_MS, directory }) + return directory + } - const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null + private resolveDirectoryOwners(directory: string): Promise { + const now = Date.now() + const cached = this.directoryOwners.get(directory) + if (cached && cached.expiresAt > now) return cached.owners - // Attach directory when available (don't overwrite if already present). - if (event && !(event as any).directory && typeof (parsed as any).directory === "string") { - ;(event as any).directory = (parsed as any).directory - } + 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 + } - if (!event || typeof (event as any).type !== "string") { - this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event") - return - } + private clearLocationCaches(): void { + this.directoryOwners.clear() + this.sessionDirectories.clear() + this.ptyDirectories.clear() + this.shellDirectories.clear() + } - this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received") - if (this.options.logger.isLevelEnabled("trace")) { - this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload") - } - this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event }) - } catch (error) { - this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload") + private updateStatus(status: InstanceStreamStatus, reason?: string) { + this.status = status + for (const workspace of this.options.workspaceManager.list()) { + this.publishStatus(workspace.id, status, reason) } } private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) { - this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated") - this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason }) + this.options.logger.debug({ instanceId, status, reason }, "Instance event status updated") + this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, generation: this.generation, reason }) } - private delay(duration: number, signal: AbortSignal) { - if (duration <= 0) { - return Promise.resolve() - } + private delay(duration: number) { return new Promise((resolve) => { - const timeout = setTimeout(() => { - signal.removeEventListener("abort", onAbort) - resolve() - }, duration) - - const onAbort = () => { + const timeout = setTimeout(resolve, duration) + this.controller.signal.addEventListener("abort", () => { clearTimeout(timeout) resolve() - } - - signal.addEventListener("abort", onAbort, { once: true }) + }, { once: true }) }) } } diff --git a/packages/server/src/workspaces/launch-cleanup.test.ts b/packages/server/src/workspaces/launch-cleanup.test.ts deleted file mode 100644 index 2a2f34f8c..000000000 --- a/packages/server/src/workspaces/launch-cleanup.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from "node:assert/strict" -import { spawnSync, type SpawnSyncReturns } from "node:child_process" -import { describe, it } from "node:test" -import { LAUNCH_CLEANUP_TOKEN_ENV, probeLaunchCleanupToken, signalLaunchCleanupToken } from "./process-identity" - -type Spawn = typeof import("node:child_process").spawnSync -const result = (stdout: string): SpawnSyncReturns => ({ pid: 1, output: [null, stdout, ""], stdout, stderr: "", status: 0, signal: null }) - -describe("launch cleanup token adapter", () => { - it("passes the exact token to the bounded Linux environ probe", () => { - const token = "a".repeat(64), calls: any[] = [] - const run = ((command: string, args: string[], options: object) => { calls.push(command, args, options); return result("CODENOMAD_PROCESS|5000|1|4242|150|boot-a|150\n") }) as unknown as Spawn - const probe = probeLaunchCleanupToken(run, token, 25) - assert.equal(probe.ok && probe.processes.get(5000)?.startOrder, "150") - assert.equal(calls[0], "sh") - assert.deepEqual([calls[2].timeout, calls[1].includes(LAUNCH_CLEANUP_TOKEN_ENV), calls[1].includes(token)], [25, true, true]) - assert.match(calls[1][1], /\/proc\/\$1\/environ/) - assert.doesNotMatch(calls[1][1], /\bseq\b/) - }) - - it("executes a successful empty Linux token probe", { skip: process.platform !== "linux" }, () => { - const probe = probeLaunchCleanupToken(spawnSync, "f".repeat(64), 1_000) - assert.deepEqual(probe, { ok: true, processes: new Map() }) - }) - - it("signals every exact-token target and rejects malformed records", () => { - const rows = "CODENOMAD_TARGET|4242|1|4242|100|boot-a|100\nCODENOMAD_TARGET|5000|1|4242|150|boot-a|150\nCODENOMAD_RESULT|1\n" - const run = ((() => result(rows)) as unknown) as Spawn - const cleanup = signalLaunchCleanupToken(run, "b".repeat(64), "SIGKILL", 25) - assert.deepEqual([cleanup.ok, cleanup.targets.map(({ pid }) => pid)], [true, [4242, 5000]]) - const malformed = ((() => result("5000|1|4242|150|boot-a|150|truncated\n")) as unknown) as Spawn - assert.equal(probeLaunchCleanupToken(malformed, "c".repeat(64), 25).ok, false) - }) -}) diff --git a/packages/server/src/workspaces/loopback.ts b/packages/server/src/workspaces/loopback.ts deleted file mode 100644 index 2038b6ccf..000000000 --- a/packages/server/src/workspaces/loopback.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Loopback host used for direct in-process communication with a running - * OpenCode workspace instance (the server and the instance share a machine). - * - * Shared by the workspace-instance loopback callers so they agree on the host - * instead of each hardcoding their own `127.0.0.1` literal. - */ -export const LOOPBACK_HOST = "127.0.0.1" diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index e7f6061ac..f75eb5327 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -1,20 +1,17 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import type { LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import pino from "pino" import { EventBus } from "../events/bus" import { - WorkspaceWindowsTreeCleanupIncompleteError, - type ProcessExitInfo, - type WorkspaceRuntime, -} from "./runtime" -import { - WorkspaceCleanupTimeoutError, WorkspaceLaunchCancelledError, - WorkspaceLaunchTimeoutError, WorkspaceManager, - WorkspaceShutdownError, + canonicalWorktreeIdentity, + isWindowsHostPath, } from "./manager" +import { startupEnvironmentHash } from "./host-opencode-service" +import type { OpenCodeServiceLifecycle, OpenCodeSharedServiceOptions } from "./opencode-service" function deferred() { let resolve!: (value: T) => void @@ -26,363 +23,703 @@ function deferred() { return { promise, resolve, reject } } -class ControlledRuntime { - readonly launchResult = deferred>>() - readonly launchCalled = deferred() - readonly active = new Set() - stopCalls = 0 - failStops = 0 - onExit?: (info: ProcessExitInfo) => void - - launch: WorkspaceRuntime["launch"] = (options) => { - this.active.add(options.workspaceId) - this.onExit = options.onExit - this.launchCalled.resolve(options.workspaceId) - options.signal?.addEventListener("abort", () => this.launchResult.reject(options.signal?.reason), { once: true }) - return this.launchResult.promise +class ControlledSharedService { + readonly validationStarted = deferred() + validationGate?: ReturnType> + ignoreValidationAbort = false + afterValidation?: () => void + headerFailures = 0 + validationCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions }> = [] + shutdownCalls = 0 + shutdownGate?: ReturnType> + shutdownTimeouts: number[] = [] + evictionCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions; signal?: AbortSignal }> = [] + evictionFailures = 0 + readonly evictionStarted = deferred() + evictionGate?: ReturnType> + + async endpoint() { + return { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "user", password: "pass" } } } - stop: WorkspaceRuntime["stop"] = async (workspaceId) => { - this.stopCalls += 1 - if (this.failStops-- > 0) throw new Error("controlled stop failure") - this.active.delete(workspaceId) + async client() { + return {} as OpenCodeClient } - resolveLaunch(): void { - this.launchResult.resolve({ - pid: 1234, - port: 4321, - exitPromise: new Promise(() => undefined), - getLastOutput: () => "", - }) + async headers() { + if (this.headerFailures > 0) { + this.headerFailures -= 1 + throw new Error("header lookup failed") + } + return { authorization: "Basic token" } + } + + async validateLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeSharedServiceOptions) { + this.validationCalls.push({ location, options }) + this.validationStarted.resolve() + if (this.validationGate) { + if (this.ignoreValidationAbort) await this.validationGate.promise + else { + await Promise.race([ + this.validationGate.promise, + new Promise((_resolve, reject) => { + const cancel = () => reject(requestOptions?.signal?.reason) + requestOptions?.signal?.addEventListener("abort", cancel, { once: true }) + if (requestOptions?.signal?.aborted) cancel() + }), + ]) + } + } + this.afterValidation?.() + return { + directory: location.directory, + workspaceID: location.workspaceID ?? "location-1", + project: { id: "project-1", directory: location.directory, canonical: location.directory }, + } + } + + async evictLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeSharedServiceOptions) { + this.evictionCalls.push({ location, options, signal: requestOptions?.signal }) + this.evictionStarted.resolve() + await this.evictionGate?.promise + if (this.evictionFailures > 0) { + this.evictionFailures -= 1 + throw new Error("eviction failed") + } + } + + async subscribe(): Promise> { + return { async *[Symbol.asyncIterator]() {} } + } + + async shutdown(options?: { timeoutMs?: number }) { + this.shutdownCalls += 1 + if (options?.timeoutMs !== undefined) this.shutdownTimeouts.push(options.timeoutMs) + await this.shutdownGate?.promise } } -function createHarness(options: { - stubReadiness?: boolean - shutdownTimeoutMs?: number - launchTimeoutMs?: number - setTimeout?: (callback: () => void, delayMs: number) => ReturnType - clearTimeout?: (timer: ReturnType) => void -} = {}) { - const { stubReadiness = true, ...managerOptions } = options +function createHarness(service = new ControlledSharedService(), overrides: Record = {}) { const eventBus = new EventBus() - const runtime = new ControlledRuntime() - const readiness = deferred() - 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, - binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, + settings: { getOwner: () => ({ environmentVariables: {} }) } as never, + binaryResolver: { resolveDefault: () => ({ path: process.execPath, label: "OpenCode V2" }) } as never, eventBus, logger: pino({ level: "silent" }), - getServerBaseUrl: () => "http://127.0.0.1:4000", - runtime, - ...managerOptions, + sharedService: service, + ...overrides, }) - if (stubReadiness) { - ;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([ - readiness.promise, - new Promise((_resolve, reject) => { - const cancel = () => reject(signal?.reason) - signal?.addEventListener("abort", cancel, { once: true }) - if (signal?.aborted) cancel() - }), - ]) - } - return { manager, runtime, readiness, started, stopped } + return { manager, service, stopped, eventBus } } -async function createReady(harness: ReturnType) { - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch() - harness.readiness.resolve(undefined) - await creation - return workspaceId -} +describe("workspace manager shared service lifecycle", () => { + it("distinguishes WSL service paths from Windows host paths", () => { + assert.equal(isWindowsHostPath("/mnt/d/repo"), false) + assert.equal(isWindowsHostPath("D:\\repo"), true) + assert.equal(isWindowsHostPath("\\\\wsl.localhost\\Ubuntu\\repo"), true) + assert.equal(isWindowsHostPath("//wsl.localhost/Ubuntu/repo"), true) + assert.equal(canonicalWorktreeIdentity("D:\\Repo", "win32"), canonicalWorktreeIdentity("d:\\repo", "win32")) + assert.notEqual( + canonicalWorktreeIdentity("\\\\wsl.localhost\\Ubuntu\\repo\\Foo", "win32"), + canonicalWorktreeIdentity("\\\\wsl.localhost\\Ubuntu\\repo\\foo", "win32"), + ) + assert.equal( + canonicalWorktreeIdentity("\\\\WSL.LOCALHOST\\ubuntu\\repo\\Foo", "win32"), + canonicalWorktreeIdentity("\\\\wsl.localhost\\Ubuntu\\repo\\Foo", "win32"), + ) + }) -describe("workspace manager lifecycle", () => { - it("rejects a healthy workspace whose OpenCode configuration is invalid", async () => { - const originalFetch = globalThis.fetch - const requests: string[] = [] - const configError = JSON.stringify({ - name: "ConfigInvalidError", - data: { - path: "C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md", - issues: [{ path: ["tools", "bash"], message: 'Expected boolean, got "ask"' }], + it("pins a bounded host CLI lifecycle with binary, platform, and startup environment identity", async () => { + const service = new ControlledSharedService() + let factoryCall: unknown[] | undefined + const { manager } = createHarness(service, { + settings: { getOwner: () => ({ environmentVariables: { PROVIDER_TOKEN: "secret" } }) }, + hostServiceLifecycleFactory: (spec: unknown, timeoutMs: unknown, environment: unknown) => { + factoryCall = [spec, timeoutMs, environment] + return { discover: async () => undefined, ensure: async () => ({ url: "http://127.0.0.1:4321" }) } + }, + }) + await manager.create(process.cwd()) + assert.equal(service.validationCalls.length, 1) + const options = service.validationCalls[0]?.options + assert.equal(options?.kind, "lifecycle") + assert.match(options?.identity ?? "", /^host:/) + assert.equal(options?.identity.includes("secret"), false) + assert.deepEqual(factoryCall?.[0], { kind: "host", binary: process.execPath, platform: process.platform }) + assert.equal(typeof factoryCall?.[1], "number") + assert.deepEqual(factoryCall?.[2], { PROVIDER_TOKEN: "secret" }) + }) + + it("omits legacy storage ownership variables, keeps the current CA, and warns once", async () => { + const service = new ControlledSharedService() + const environments: NodeJS.ProcessEnv[] = [] + const warnings: unknown[][] = [] + const previousCa = process.env.NODE_EXTRA_CA_CERTS + process.env.NODE_EXTRA_CA_CERTS = "/current/ca.pem" + const logger = { + info() {}, debug() {}, error() {}, + warn(...args: unknown[]) { warnings.push(args) }, + } + try { + const { manager } = createHarness(service, { + logger, + settings: { getOwner: () => ({ + environmentVariables: { + KEEP_ME: "yes", + NODE_EXTRA_CA_CERTS: "/configured/ca.pem", + OPENCODE_DB: "/legacy/db", + XDG_STATE_HOME: "/legacy/state", + }, + }) }, + hostServiceLifecycleFactory: (_spec: unknown, _timeoutMs: unknown, environment: NodeJS.ProcessEnv) => { + environments.push(environment) + return { discover: async () => undefined, ensure: async () => ({ url: "http://127.0.0.1:4321" }) } + }, + }) + const first = await manager.create(process.cwd()) + await manager.delete(first.workspace.id) + await manager.create(process.cwd()) + + assert.deepEqual(environments, [ + { KEEP_ME: "yes", NODE_EXTRA_CA_CERTS: "/current/ca.pem" }, + { KEEP_ME: "yes", NODE_EXTRA_CA_CERTS: "/current/ca.pem" }, + ]) + assert.equal(warnings.length, 1) + assert.deepEqual((warnings[0]?.[0] as { variables?: string[] }).variables, ["OPENCODE_DB", "XDG_STATE_HOME"]) + } finally { + if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS + else process.env.NODE_EXTRA_CA_CERTS = previousCa + } + }) + + it("selects the WSL lifecycle and preserves bounded workspace path translation", async () => { + const service = new ControlledSharedService() + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => undefined, + ensure: async () => ({ url: "http://127.0.0.1:4321" }), + } + let factoryCall: unknown[] | undefined + let translationCall: unknown[] | undefined + const { manager } = createHarness(service, { + platform: "win32", + binaryResolver: { + resolveDefault: () => ({ path: String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, label: "OpenCode V2" }), + }, + wslServiceLifecycleFactory: (spec: unknown, timeoutMs: unknown, environment: unknown) => { + factoryCall = [spec, timeoutMs, environment] + return lifecycle + }, + wslServiceDirectoryResolver: (directory: unknown, distro: unknown, timeoutMs: unknown) => { + translationCall = [directory, distro, timeoutMs] + return "/mnt/d/workspace" }, }) - globalThis.fetch = (async (input: URL | RequestInfo) => { - const url = String(input) - requests.push(url) - if (url.includes("/global/health")) { - return new Response(JSON.stringify({ healthy: true, version: "1.18.5" }), { - headers: { "Content-Type": "application/json" }, - }) - } - return new Response(configError, { status: 400, headers: { "Content-Type": "application/json" } }) - }) as typeof fetch + await manager.create(process.cwd()) + + assert.deepEqual(factoryCall?.[0], { kind: "wsl", distro: "Ubuntu", binary: "/home/dev/opencode" }) + assert.equal(typeof factoryCall?.[1], "number") + assert.deepEqual(translationCall?.slice(0, 2), [process.cwd(), "Ubuntu"]) + assert.ok(Number(translationCall?.[2]) > 0) + assert.equal(service.validationCalls[0]?.location.directory, "/mnt/d/workspace") + assert.equal(service.validationCalls[0]?.options?.kind, "lifecycle") + assert.match( + service.validationCalls[0]?.options?.identity ?? "", + /^wsl:ubuntu:\/home\/dev\/opencode:env:[a-f0-9]{64}$/, + ) + assert.equal( + service.validationCalls[0]?.options?.kind === "lifecycle" + ? service.validationCalls[0].options.lifecycle + : undefined, + lifecycle, + ) + const record = [...(manager as any).workspaces.values()][0] + assert.equal(record.wslDistro, "Ubuntu") + }) + + it("translates a case-insensitive Windows CA path before WSL identity and lifecycle construction", async () => { + const previousCa = process.env.NODE_EXTRA_CA_CERTS + delete process.env.NODE_EXTRA_CA_CERTS + const caPath = String.raw`C:\certs\ca.pem` + const environment = { node_extra_ca_certs: caPath, Node_Extra_CA_Certs: caPath } + let lifecycleEnvironment: NodeJS.ProcessEnv | undefined + let now = 1_000 + const translations: Array<{ directory: string; timeoutMs: number }> = [] try { - const harness = createHarness({ stubReadiness: false }) - ;(harness.manager as any).waitForPortAvailability = async () => undefined - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch() - - await assert.rejects(creation, (error: unknown) => { - assert.ok(error instanceof Error) - assert.equal(error.message, configError) - return true + const { manager, service } = createHarness(new ControlledSharedService(), { + platform: "win32", + launchTimeoutMs: 100, + now: () => now, + settings: { getOwner: () => ({ environmentVariables: environment }) }, + binaryResolver: { + resolveDefault: () => ({ path: String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, label: "OpenCode V2" }), + }, + wslServiceDirectoryResolver: (directory: string, _distro: string, timeoutMs: number) => { + translations.push({ directory, timeoutMs }) + if (directory === caPath) { + now += 40 + return "/mnt/c/certs/ca.pem" + } + return "/mnt/d/workspace" + }, + wslServiceLifecycleFactory: (_spec: unknown, _timeoutMs: unknown, startupEnvironment: NodeJS.ProcessEnv) => { + lifecycleEnvironment = startupEnvironment + return { discover: async () => undefined, ensure: async () => ({ url: "http://127.0.0.1:4321" }) } + }, }) - assert.deepEqual(requests.map((url) => new URL(url).pathname), ["/global/health", "/config"]) - assert.equal(new URL(requests[1]).search, "") - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.deepEqual(harness.started, []) - assert.deepEqual(harness.manager.list(), []) + + await manager.create(process.cwd()) + + const effectiveEnvironment = { NODE_EXTRA_CA_CERTS: "/mnt/c/certs/ca.pem" } + assert.deepEqual(lifecycleEnvironment, effectiveEnvironment) + assert.deepEqual(translations, [ + { directory: caPath, timeoutMs: 100 }, + { directory: process.cwd(), timeoutMs: 60 }, + ]) + assert.equal( + service.validationCalls[0]?.options?.identity, + `wsl:ubuntu:/home/dev/opencode:env:${startupEnvironmentHash(effectiveEnvironment, "linux")}`, + ) } finally { - globalThis.fetch = originalFetch + if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS + else process.env.NODE_EXTRA_CA_CERTS = previousCa } }) - for (const boundary of ["launch", "readiness", "shutdown"] as const) { - it(`cancels and cleans a workspace during ${boundary}`, async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - let cleanup: Promise - if (boundary === "readiness") { - harness.runtime.resolveLaunch() - await new Promise((resolve) => setImmediate(resolve)) - cleanup = harness.manager.delete(workspaceId) - } else { - cleanup = boundary === "shutdown" ? harness.manager.shutdown() : harness.manager.delete(workspaceId) - harness.runtime.resolveLaunch() + it("does not translate the WSL workspace after the shared path deadline expires", async () => { + const previousCa = process.env.NODE_EXTRA_CA_CERTS + delete process.env.NODE_EXTRA_CA_CERTS + const caPath = String.raw`C:\certs\ca.pem` + let now = 1_000 + const translations: Array<{ directory: string; timeoutMs: number }> = [] + try { + const { manager, service } = createHarness(new ControlledSharedService(), { + platform: "win32", + launchTimeoutMs: 100, + now: () => now, + settings: { getOwner: () => ({ environmentVariables: { node_extra_ca_certs: caPath } }) }, + binaryResolver: { + resolveDefault: () => ({ path: String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, label: "OpenCode V2" }), + }, + wslServiceDirectoryResolver: (directory: string, _distro: string, timeoutMs: number) => { + translations.push({ directory, timeoutMs }) + now += 100 + return "/mnt/c/certs/ca.pem" + }, + }) + + await assert.rejects( + manager.create(process.cwd()), + /Unable to translate workspace location for WSL distro "Ubuntu"/, + ) + assert.deepEqual(translations, [{ directory: caPath, timeoutMs: 100 }]) + assert.equal(service.validationCalls.length, 0) + } finally { + if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS + else process.env.NODE_EXTRA_CA_CERTS = previousCa + } + }) + + it("preserves POSIX WSL CA paths and reports Windows CA translation failures", async () => { + const previousCa = process.env.NODE_EXTRA_CA_CERTS + delete process.env.NODE_EXTRA_CA_CERTS + try { + let lifecycleEnvironment: NodeJS.ProcessEnv | undefined + const common = { + platform: "win32", + binaryResolver: { + resolveDefault: () => ({ path: String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, label: "OpenCode V2" }), + }, } + const preserved = createHarness(new ControlledSharedService(), { + ...common, + settings: { getOwner: () => ({ environmentVariables: { Node_Extra_CA_Certs: "/etc/ssl/custom.pem" } }) }, + wslServiceDirectoryResolver: () => "/mnt/d/workspace", + wslServiceLifecycleFactory: (_spec: unknown, _timeoutMs: unknown, environment: NodeJS.ProcessEnv) => { + lifecycleEnvironment = environment + return { discover: async () => undefined, ensure: async () => ({ url: "http://127.0.0.1:4321" }) } + }, + }) + await preserved.manager.create(process.cwd()) + assert.deepEqual(lifecycleEnvironment, { NODE_EXTRA_CA_CERTS: "/etc/ssl/custom.pem" }) + + const failed = createHarness(new ControlledSharedService(), { + ...common, + settings: { getOwner: () => ({ environmentVariables: { NODE_EXTRA_CA_CERTS: String.raw`C:\missing\ca.pem` } }) }, + wslServiceDirectoryResolver: () => null, + }) + await assert.rejects( + failed.manager.create(process.cwd()), + /Unable to translate NODE_EXTRA_CA_CERTS for WSL distro "Ubuntu": C:\\missing\\ca\.pem/, + ) + assert.equal(failed.service.validationCalls.length, 0) + } finally { + if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS + else process.env.NODE_EXTRA_CA_CERTS = previousCa + } + }) + it("creates separate workspaces for the same in-flight location", async () => { + const harness = createHarness() + harness.service.validationGate = deferred() + const first = harness.manager.create(process.cwd()) + await harness.service.validationStarted.promise + const second = harness.manager.create(process.cwd()) + while (harness.service.validationCalls.length < 2) await new Promise((resolve) => setImmediate(resolve)) + harness.service.validationGate.resolve() + + const [left, right] = await Promise.all([first, second]) + assert.notEqual(left.workspace.id, right.workspace.id) + assert.equal(left.created && right.created, true) + assert.equal(harness.service.validationCalls.length, 2) + }) - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await cleanup - assert.deepEqual([harness.runtime.active.size, harness.started, harness.manager.list(), harness.stopped], - [0, [], [], boundary === "readiness" ? [workspaceId] : []]) + it("bounds concurrent workspace creations", async () => { + const harness = createHarness() + harness.service.validationGate = deferred() + const creations = Array.from({ length: 32 }, () => harness.manager.create(process.cwd())) + while (harness.service.validationCalls.length < 32) await new Promise((resolve) => setImmediate(resolve)) + + await assert.rejects(harness.manager.create(process.cwd()), /Too many workspace creations/) + harness.service.validationGate.resolve() + await Promise.all(creations) + }) + + it("cancels validation and cleans its logical location", async () => { + const harness = createHarness() + harness.service.validationGate = deferred() + const creation = harness.manager.create(process.cwd()) + await harness.service.validationStarted.promise + const record = [...(harness.manager as any).workspaces.values()][0] + let deletion: Promise | undefined + harness.service.afterValidation = () => { deletion = harness.manager.delete(record.id) } + harness.service.validationGate.resolve() + + await assert.rejects(creation, WorkspaceLaunchCancelledError) + await deletion + assert.deepEqual(harness.manager.list(), []) + assert.equal(harness.service.evictionCalls.length, 1) + assert.equal(harness.service.evictionCalls[0]?.location.workspaceID, "location-1") + assert.equal(harness.service.evictionCalls[0]?.signal, undefined) + }) + + it("evicts a ready location on explicit final deletion without stopping the daemon", async () => { + const harness = createHarness() + const created = await harness.manager.create(process.cwd()) + await harness.manager.delete(created.workspace.id) + + assert.equal(harness.service.evictionCalls.length, 1) + assert.deepEqual(harness.service.evictionCalls[0]?.location, { + directory: process.cwd(), + workspaceID: "location-1", }) - } + assert.equal(harness.service.shutdownCalls, 0) + }) - it("shares failed cleanup and allows a later delete retry", async () => { + it("evicts a shared location only after its last workspace is deleted", async () => { const harness = createHarness() - const workspaceId = await createReady(harness) - harness.runtime.failStops = 2 - - const first = harness.manager.delete(workspaceId) - const concurrent = harness.manager.delete(workspaceId) - assert.strictEqual(first, concurrent) - const failures = await Promise.allSettled([first, concurrent]) - assert.deepEqual(failures.map((result) => result.status), ["rejected", "rejected"]) - assert.equal(harness.runtime.active.has(workspaceId), true) - - await harness.manager.delete(workspaceId) - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.equal(harness.manager.get(workspaceId), undefined) + const first = await harness.manager.create(process.cwd()) + const second = await harness.manager.create(process.cwd()) + + assert.notEqual(first.workspace.id, second.workspace.id) + await harness.manager.delete(first.workspace.id) + assert.equal(harness.service.evictionCalls.length, 0) + assert.equal(harness.manager.list().length, 1) + await harness.manager.delete(second.workspace.id) + assert.equal(harness.service.evictionCalls.length, 1) + assert.equal(harness.manager.list().length, 0) }) - it("retries cancellation deletion for an already-cancelled request", async () => { + it("finishes a blocked final eviction before validating a new owner", async () => { const harness = createHarness() - const creation = harness.manager.create(process.cwd(), undefined, { requestId: "retry-cancel" }) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch() - harness.readiness.resolve(undefined) - await creation - harness.runtime.failStops = 2 - - await assert.rejects(harness.manager.cancelCreationRequest("retry-cancel"), /controlled stop failure/) - assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) - assert.equal(harness.runtime.active.has(workspaceId), true) - - await harness.manager.cancelCreationRequest("retry-cancel") - assert.equal(harness.manager.get(workspaceId), undefined) - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.deepEqual(harness.stopped, [workspaceId]) + const first = await harness.manager.create(process.cwd()) + harness.service.evictionGate = deferred() + const deletion = harness.manager.delete(first.workspace.id) + await harness.service.evictionStarted.promise + + const creation = harness.manager.create(process.cwd()) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.service.validationCalls.length, 1) + + harness.service.evictionGate.resolve() + await deletion + const second = await creation + assert.equal(harness.service.evictionCalls.length, 1) + assert.deepEqual(harness.manager.list().map(({ id }) => id), [second.workspace.id]) + }) + + it("keeps a timed-out eviction between the old and new owner", async () => { + const harness = createHarness(new ControlledSharedService(), { launchSettlementTimeoutMs: 20 }) + const first = await harness.manager.create(process.cwd()) + harness.service.evictionGate = deferred() + const deletion = assert.rejects(harness.manager.delete(first.workspace.id), /cleanup did not finish/) + await harness.service.evictionStarted.promise + await deletion + + const creation = harness.manager.create(process.cwd()) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.service.validationCalls.length, 1) + + harness.service.evictionGate.resolve() + const second = await creation + assert.equal(harness.service.evictionCalls.length, 1) + assert.deepEqual(harness.manager.list().map(({ id }) => id), [second.workspace.id]) }) - it("gives release and cancellation one terminal winner", async () => { - const cancelled = createHarness() - const cancelledCreation = cancelled.manager.create(process.cwd(), undefined, { requestId: "cancel-wins" }) - const cancelledId = await cancelled.runtime.launchCalled.promise - cancelled.runtime.resolveLaunch() - cancelled.readiness.resolve(undefined) - await cancelledCreation - const stopStarted = deferred() - const finishStop = deferred() - const originalStop = cancelled.runtime.stop - cancelled.runtime.stop = async (workspaceId) => { - stopStarted.resolve() - await finishStop.promise - await originalStop(workspaceId) + it("evicts once when duplicate workspaces are deleted concurrently", async () => { + const harness = createHarness() + const first = await harness.manager.create(process.cwd()) + const second = await harness.manager.create(process.cwd()) + harness.service.evictionGate = deferred() + + const deletions = Promise.all([ + harness.manager.delete(first.workspace.id), + harness.manager.delete(second.workspace.id), + ]) + await harness.service.evictionStarted.promise + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.service.evictionCalls.length, 1) + + harness.service.evictionGate.resolve() + await deletions + assert.equal(harness.service.evictionCalls.length, 1) + assert.deepEqual(harness.manager.list(), []) + }) + + it("retries cleanup after an eviction failure", async () => { + const harness = createHarness() + const created = await harness.manager.create(process.cwd()) + harness.service.evictionFailures = 1 + + await assert.rejects(harness.manager.delete(created.workspace.id), /eviction failed/) + await harness.manager.delete(created.workspace.id) + + assert.equal(harness.service.evictionCalls.length, 2) + assert.deepEqual(harness.manager.list(), []) + }) + + it("does not launch an eviction queued before shutdown", async () => { + const harness = createHarness() + const first = await harness.manager.create(process.cwd()) + harness.service.validationGate = deferred() + const creation = harness.manager.create(process.cwd()) + const creationFailure = assert.rejects(creation, WorkspaceLaunchCancelledError) + while (harness.service.validationCalls.length < 2) await new Promise((resolve) => setImmediate(resolve)) + + const deletion = harness.manager.delete(first.workspace.id) + while ((harness.manager as any).pendingLocationEvictions < 1) { + await new Promise((resolve) => setImmediate(resolve)) } + await harness.manager.shutdown() + await creationFailure + await deletion + + assert.equal(harness.service.evictionCalls.length, 0) + assert.equal(harness.service.shutdownCalls, 1) + }) - const cancellation = cancelled.manager.cancelCreationRequest("cancel-wins") - await stopStarted.promise - assert.equal(cancelled.manager.releaseCreationRequest(cancelledId, "cancel-wins"), false) - assert.equal(cancelled.manager.get(cancelledId)?.id, cancelledId) - finishStop.resolve() - await cancellation - assert.equal(cancelled.manager.get(cancelledId), undefined) - - const released = createHarness() - const releasedCreation = released.manager.create(process.cwd(), undefined, { requestId: "release-wins" }) - const releasedId = await released.runtime.launchCalled.promise - released.runtime.resolveLaunch() - released.readiness.resolve(undefined) - await releasedCreation - - assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true) - await released.manager.cancelCreationRequest("release-wins") - assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true) - assert.equal(released.manager.get(releasedId)?.id, releasedId) - assert.equal(released.runtime.active.has(releasedId), true) + it("bounds deletion while an aborted creation ignores cancellation", async () => { + const harness = createHarness(new ControlledSharedService(), { launchSettlementTimeoutMs: 20 }) + harness.service.validationGate = deferred() + harness.service.ignoreValidationAbort = true + const creation = harness.manager.create(process.cwd()) + const creationFailure = assert.rejects(creation, WorkspaceLaunchCancelledError) + await harness.service.validationStarted.promise + const record = [...(harness.manager as any).workspaces.values()][0] + const startedAt = Date.now() + + await assert.rejects(harness.manager.delete(record.id), /cleanup did not finish/) + assert.ok(Date.now() - startedAt < 1000) + + harness.service.validationGate.resolve() + await creationFailure + while ((harness.manager as any).workspaces.size) await new Promise((resolve) => setImmediate(resolve)) }) - it("retains unresolved pre-creation cancellation until its delayed create", async () => { + it("does not let a timed-out validation mutate canonical location or authorization", async () => { + const service = new ControlledSharedService() + service.validationGate = deferred() + service.ignoreValidationAbort = true + const validationFinished = deferred() + service.afterValidation = validationFinished.resolve + const harness = createHarness(service, { launchTimeoutMs: 20 }) + const creation = harness.manager.create(process.cwd()) + await service.validationStarted.promise + const record = [...(harness.manager as any).workspaces.values()][0] + + await assert.rejects(creation, /did not finish launching/) + service.validationGate.resolve() + await validationFinished.promise + while ((harness.manager as any).workspaces.size) await new Promise((resolve) => setImmediate(resolve)) + + assert.deepEqual(record.location, { directory: process.cwd() }) + assert.equal(record[Object.getOwnPropertySymbols(record)[0]].locationOwned, false) + assert.equal((harness.manager as any).serviceAuthorization, undefined) + }) + + it("tracks validation until it settles when the header request fails first", async () => { const harness = createHarness() - const requestIds = Array.from({ length: 1_025 }, (_, index) => `pending-cancel-${index}`) - await Promise.all(requestIds.map((requestId) => harness.manager.cancelCreationRequest(requestId))) + const ready = await harness.manager.create(process.cwd()) + harness.service.validationGate = deferred() + harness.service.headerFailures = 1 + const creation = harness.manager.create(process.cwd()) + const creationFailure = assert.rejects(creation, /header lookup failed/) + while (harness.service.validationCalls.length < 2) await new Promise((resolve) => setImmediate(resolve)) + + const deletion = harness.manager.delete(ready.workspace.id) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.service.evictionCalls.length, 0) + + harness.service.validationGate.resolve() + await creationFailure + await deletion + assert.equal(harness.service.evictionCalls.length, 1) + }) - assert.equal((harness.manager as any).cancelledCreationRequests.size, requestIds.length) - await assert.rejects( - harness.manager.create(process.cwd(), undefined, { requestId: requestIds[0] }), - /was cancelled/, - ) - assert.equal((harness.manager as any).cancelledCreationRequests.has(requestIds[0]), false) - assert.equal((harness.manager as any).cancelledCreationRequests.size, requestIds.length - 1) + it("evicts an isolated validated location when header lookup fails", async () => { + const harness = createHarness() + harness.service.headerFailures = 1 + + await assert.rejects(harness.manager.create(process.cwd()), /header lookup failed/) + + assert.deepEqual(harness.service.evictionCalls.map(({ location }) => location), [{ + directory: process.cwd(), + workspaceID: "location-1", + }]) + assert.equal((harness.manager as any).workspaces.size, 0) }) - it("returns scoped correlation while an ordinary shared launch remains retained", async () => { + it("drops an unpublished owner when header lookup and eviction both fail", async () => { const harness = createHarness() - const ordinary = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - const scoped = harness.manager.create(process.cwd(), undefined, { requestId: "restore-shared" }) - harness.runtime.resolveLaunch() - harness.readiness.resolve(undefined) - - const [ordinaryResult, scopedResult] = await Promise.all([ordinary, scoped]) - assert.equal(ordinaryResult.created, true) - assert.equal(ordinaryResult.workspace.requestId, undefined) - assert.equal(scopedResult.created, false) - assert.equal(scopedResult.workspace.id, workspaceId) - assert.equal(scopedResult.workspace.requestId, "restore-shared") - - assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-shared"), true) - assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) - assert.equal(harness.runtime.active.has(workspaceId), true) - - const reused = await harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }) - assert.equal(reused.workspace.requestId, "restore-reused") - await harness.manager.cancelCreationRequest("restore-reused") - assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) - assert.equal(harness.runtime.active.has(workspaceId), true) - await assert.rejects( - harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }), - /was cancelled/, - ) - assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-reused"), false) + harness.service.headerFailures = 1 + harness.service.evictionFailures = 1 + + await assert.rejects(harness.manager.create(process.cwd()), /header lookup failed/) + assert.equal((harness.manager as any).workspaces.size, 0) + + const replacement = await harness.manager.create(process.cwd()) + await harness.manager.delete(replacement.workspace.id) + assert.equal(harness.service.evictionCalls.length, 2) }) - for (const boundary of ["runtime launch", "health readiness"] as const) { - it(`applies one shared end-to-end deadline during ${boundary} and cleans up`, async () => { - const deadlines: Array<() => void> = [] - const harness = createHarness({ - launchTimeoutMs: 25, - setTimeout: ((callback: () => void) => { - const timer = { active: true } - deadlines.push(() => { if (timer.active) callback() }) - return timer as unknown as ReturnType - }) as typeof setTimeout, - clearTimeout: ((timer: { active: boolean }) => { timer.active = false }) as unknown as typeof clearTimeout, - }) - const first = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-one" }) - const workspaceId = await harness.runtime.launchCalled.promise - const shared = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-two" }) - while ([...(harness.manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== 2) { - await new Promise((resolve) => setImmediate(resolve)) - } - if (boundary === "health readiness") { - harness.runtime.resolveLaunch() - await new Promise((resolve) => setImmediate(resolve)) - } + it("shuts down local workspaces without evicting their OpenCode locations", async () => { + const harness = createHarness() + await harness.manager.create(process.cwd()) - for (const fire of deadlines) fire() - const outcomes = await Promise.allSettled([first, shared]) - assert.deepEqual(outcomes.map((outcome) => outcome.status), ["rejected", "rejected"]) - assert.ok(outcomes.every((outcome) => outcome.status === "rejected" && outcome.reason instanceof WorkspaceLaunchTimeoutError)) - assert.strictEqual((outcomes[0] as PromiseRejectedResult).reason, (outcomes[1] as PromiseRejectedResult).reason) - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.equal(harness.runtime.stopCalls >= 1, true) - assert.deepEqual(harness.manager.list(), []) - }) - } + await harness.manager.shutdown() + + assert.deepEqual(harness.manager.list(), []) + assert.equal(harness.service.evictionCalls.length, 0) + assert.equal(harness.service.shutdownCalls, 1) + assert.deepEqual(harness.stopped.length, 1) + }) - it("bounds shutdown instead of waiting forever", async () => { - let fireDeadline!: () => void - let cleared = 0 - const harness = createHarness({ - shutdownTimeoutMs: 25, - setTimeout: ((callback: () => void) => { - fireDeadline = callback - return {} as ReturnType - }) as typeof setTimeout, - clearTimeout: () => { cleared += 1 }, - } as never) - const workspaceId = await createReady(harness) - cleared = 0 - harness.runtime.stop = () => new Promise(() => undefined) - - const shutdown = harness.manager.shutdown() - fireDeadline() - await assert.rejects(shutdown, WorkspaceCleanupTimeoutError) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(cleared, 1) + it("cancels and removes an in-flight creation without evicting its location", async () => { + const harness = createHarness() + harness.service.validationGate = deferred() + const creation = harness.manager.create(process.cwd()) + await harness.service.validationStarted.promise + + await harness.manager.shutdown() + await assert.rejects(creation, WorkspaceLaunchCancelledError) + + assert.deepEqual(harness.manager.list(), []) + assert.equal((harness.manager as any).workspaces.size, 0) + assert.equal(harness.service.evictionCalls.length, 0) + assert.equal(harness.service.shutdownCalls, 1) }) - it("publishes stopped exactly once for normal exit, readiness failure, and manager cleanup", async () => { - const normal = createHarness() - const normalId = await createReady(normal) - normal.runtime.onExit?.({ workspaceId: normalId, code: 0, signal: null, requested: false }) - await normal.manager.delete(normalId) - assert.deepEqual(normal.stopped, [normalId]) - - const failed = createHarness() - const failedCreation = failed.manager.create(process.cwd()) - const failedId = await failed.runtime.launchCalled.promise - failed.runtime.resolveLaunch() - failed.readiness.reject(new Error("not ready")) - await assert.rejects(failedCreation, /not ready/) - assert.deepEqual(failed.stopped, [failedId]) - - const cleaned = createHarness() - const cleanedId = await createReady(cleaned) - await cleaned.manager.shutdown() - assert.deepEqual(cleaned.stopped, [cleanedId]) + it("waits for the underlying launch to settle before completing shutdown", async () => { + const harness = createHarness() + harness.service.validationGate = deferred() + harness.service.ignoreValidationAbort = true + const lifecycleEvents: string[] = [] + harness.eventBus.on("workspace.created", () => lifecycleEvents.push("created")) + harness.eventBus.on("workspace.started", () => lifecycleEvents.push("started")) + const creation = harness.manager.create(process.cwd()) + const creationFailure = assert.rejects(creation, WorkspaceLaunchCancelledError) + await harness.service.validationStarted.promise + const record = [...(harness.manager as any).workspaces.values()][0] + let shutdownSettled = false + + const shutdown = harness.manager.shutdown().then(() => { shutdownSettled = true }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(shutdownSettled, false) + harness.service.validationGate.resolve() + await shutdown + await creationFailure + + assert.deepEqual(record.location, { directory: process.cwd() }) + assert.equal(record[Object.getOwnPropertySymbols(record)[0]].locationOwned, false) + assert.deepEqual(lifecycleEvents, []) + assert.equal(harness.service.evictionCalls.length, 0) + assert.equal((harness.manager as any).workspaces.size, 0) }) - for (const [name, failure] of [ - ["cleanup failures", new Error("stop failed")], - ["incomplete Windows tree cleanup", new WorkspaceWindowsTreeCleanupIncompleteError("workspace", 4242, ["taskkill failed"])], - ] as const) { - it(`aggregates ${name} during shutdown`, async () => { - const harness = createHarness() - const workspaceId = await createReady(harness) - harness.runtime.stop = async () => { throw failure } - - await assert.rejects(harness.manager.shutdown(), (error: unknown) => { - assert.ok(error instanceof WorkspaceShutdownError) - assert.strictEqual(error.errors[0], failure) - return true - }) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(harness.runtime.active.has(workspaceId), true) + it("prevents a launch completing after bounded shutdown from mutating its removed record", async () => { + const service = new ControlledSharedService() + service.validationGate = deferred() + service.ignoreValidationAbort = true + const validationFinished = deferred() + service.afterValidation = validationFinished.resolve + const harness = createHarness(service, { shutdownTimeoutMs: 20 }) + const lifecycleEvents: string[] = [] + harness.eventBus.on("workspace.created", () => lifecycleEvents.push("created")) + harness.eventBus.on("workspace.started", () => lifecycleEvents.push("started")) + const creation = harness.manager.create(process.cwd()) + const creationFailure = assert.rejects(creation, WorkspaceLaunchCancelledError) + await service.validationStarted.promise + const record = [...(harness.manager as any).workspaces.values()][0] + + await assert.rejects(harness.manager.shutdown(), /Failed to stop 1 workspace during shutdown/) + assert.equal((harness.manager as any).workspaces.size, 0) + service.validationGate.resolve() + await validationFinished.promise + await new Promise((resolve) => setImmediate(resolve)) + await creationFailure + + assert.deepEqual(record.location, { directory: process.cwd() }) + assert.equal(record[Object.getOwnPropertySymbols(record)[0]].locationOwned, false) + assert.deepEqual(lifecycleEvents, []) + assert.equal(service.evictionCalls.length, 0) + }) + + it("bounds local adapter shutdown after removing workspace records", async () => { + const service = new ControlledSharedService() + service.shutdownGate = deferred() + const { manager } = createHarness(service, { shutdownTimeoutMs: 20 }) + await manager.create(process.cwd()) + const startedAt = Date.now() + + await assert.rejects(manager.shutdown(), /Failed to stop 1 workspace during shutdown/) + + assert.ok(Date.now() - startedAt < 1000) + assert.deepEqual(manager.list(), []) + assert.equal(service.evictionCalls.length, 0) + assert.equal(service.shutdownCalls, 1) + assert.ok((service.shutdownTimeouts[0] ?? 0) > 0) + }) + + it("waits for raw eviction cleanup after a deletion timeout", async () => { + const harness = createHarness(new ControlledSharedService(), { + launchSettlementTimeoutMs: 20, + shutdownTimeoutMs: 500, }) - } + const created = await harness.manager.create(process.cwd()) + harness.service.evictionGate = deferred() + await assert.rejects(harness.manager.delete(created.workspace.id), /cleanup did not finish/) + + let shutdownSettled = false + const shutdown = harness.manager.shutdown().then(() => { shutdownSettled = true }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(shutdownSettled, false) + + harness.service.evictionGate.resolve() + await shutdown + assert.equal(harness.service.evictionCalls.length, 1) + assert.equal(harness.service.shutdownCalls, 1) + }) + }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 6d39c9192..6018880a7 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,8 +1,9 @@ import path from "path" import { spawnSync } from "child_process" import { randomUUID } from "node:crypto" -import { connect } from "net" -import { setTimeout as delay } from "node:timers/promises" +import { realpath } from "node:fs/promises" +import type { Endpoint } from "@opencode-ai/client/service" +import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" import type { BinaryResolver } from "../settings/binaries" @@ -10,33 +11,41 @@ import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" -import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { - buildOpencodeConfigContent, - getCodeNomadPluginUrl, - resolveExistingOpencodeConfigContent, -} from "../opencode-plugin.js" + buildServiceLaunchSpec, + parseWslUncPath, + resolveWslHostDirectory, + resolveWslServiceDirectory, + type ServiceLaunchSpec, +} from "./spawn" import { - OPENCODE_SERVER_BASE_URL_ENV, - buildOpencodeBasicAuthHeader, - OPENCODE_SERVER_PASSWORD_ENV, - OPENCODE_SERVER_USERNAME_ENV, - resolveOpencodeServerAuth, -} from "./opencode-auth" -import { resolveWorkspaceIdentity } from "./workspace-identity" -import { parseWslUncPath } from "./spawn" -import { LOOPBACK_HOST } from "./loopback" - -const STARTUP_STABILITY_DELAY_MS = 1500 + HostOpenCodeService, + hostOpenCodeServiceIdentity, + startupEnvironmentHash, +} from "./host-opencode-service" +import { + OpenCodeSharedService, + type OpenCodeServiceLifecycle, + type OpenCodeSharedServiceOptions, +} from "./opencode-service" +import { WslOpenCodeService } from "./wsl-opencode-service" +import { isPathOwnedByWorktree, normalizeWslUncPath, resolveOwnedWorktreePath } from "./worktree-directory" + const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 -const ORDINARY_CREATION_OWNER = "" +const MAX_ACTIVE_WORKSPACE_CREATIONS = 32 const WORKSPACE_STATE = Symbol("workspaceState") -type ManagerTimeout = ReturnType - -interface WorkspaceRuntimeController { - launch: WorkspaceRuntime["launch"] - stop: WorkspaceRuntime["stop"] +type ManagerTimeout = number | NodeJS.Timeout + +interface SharedService { + endpoint: (options?: OpenCodeSharedServiceOptions) => Promise + client: (options?: OpenCodeSharedServiceOptions) => Promise + headers: (options?: OpenCodeSharedServiceOptions, requestOptions?: { deadlineAt?: number }) => Promise<{ authorization: string } | undefined> + validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal; deadlineAt?: number }, serviceOptions?: OpenCodeSharedServiceOptions) => Promise + evictLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, serviceOptions?: OpenCodeSharedServiceOptions) => Promise + subscribe: (requestOptions?: { signal?: AbortSignal }, serviceOptions?: OpenCodeSharedServiceOptions) => Promise> + invalidate?: () => void + shutdown: (options?: { timeoutMs?: number }) => Promise } export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean { @@ -59,30 +68,56 @@ interface WorkspaceManagerOptions { binaryResolver: BinaryResolver eventBus: EventBus logger: Logger - getServerBaseUrl: () => string - /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ - nodeExtraCaCertsPath?: string - runtime?: Pick + sharedService?: SharedService shutdownTimeoutMs?: number launchSettlementTimeoutMs?: number launchTimeoutMs?: number + now?: () => 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 + wslServiceLifecycleFactory?: ( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ) => OpenCodeServiceLifecycle + hostServiceLifecycleFactory?: ( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ) => OpenCodeServiceLifecycle +} + +export function isWindowsHostPath(directory: string): boolean { + return /^[A-Za-z]:[\\/]|^(?:\\\\|\/\/)/.test(directory) +} + +export function canonicalWorktreeIdentity(directory: string, platform = process.platform): string { + const wsl = normalizeWslUncPath(directory) + if (wsl) return wsl + const identity = path.normalize(directory) + return platform === "win32" ? identity.toLowerCase() : identity } interface WorkspaceRecord extends WorkspaceDescriptor { - identityKey: string - ownership: WorkspaceCreationOwnership + location?: LocationRef + wslDistro?: string [WORKSPACE_STATE]: WorkspaceState } interface WorkspaceState { abortController: AbortController - creation?: Promise settlement?: Promise + cleanupSettlement?: Promise deletePromise?: Promise published: boolean stoppedPublished: boolean + locationOwned: boolean + creationRequestId?: string + creationRequestState?: CreationRequestState + serviceOptions?: OpenCodeSharedServiceOptions } export class WorkspaceLaunchCancelledError extends Error { constructor(workspaceId: string) { @@ -119,31 +154,23 @@ export interface WorkspaceCreateResult { created: boolean } export interface WorkspaceCreateOptions { - binaryPath?: string requestId?: string - forceNew?: boolean } type CreationRequestState = "active" | "cancelled" | "released" -type WorkspaceCreationOwnership = Map -interface WorkspaceReadiness { - workspaceId: string - port: number - exitPromise: Promise - getLastOutput: () => string - signal?: AbortSignal -} export class WorkspaceManager { private readonly workspaces = new Map() - private readonly pendingWorkspaceCreations = new Map() private readonly cancelledCreationRequests = new Set() + private readonly activeLocationCreations = new Set>() + private locationEvictions: Promise = Promise.resolve() + private pendingLocationEvictions = 0 + private activeWorkspaceCreations = 0 private shuttingDown = false - private readonly runtime: Pick - private readonly codeNomadPluginUrl: string - private readonly opencodeAuth = new Map() + private readonly sharedService: SharedService + private serviceAuthorization?: string + private warnedLegacyServiceEnvironment = false constructor(private readonly options: WorkspaceManagerOptions) { - this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger) - this.codeNomadPluginUrl = getCodeNomadPluginUrl() + this.sharedService = options.sharedService ?? new OpenCodeSharedService() } list(): WorkspaceDescriptor[] { return Array.from(this.workspaces.values()) @@ -155,13 +182,119 @@ export class WorkspaceManager { return record?.[WORKSPACE_STATE].published ? record : undefined } - getInstancePort(id: string): number | undefined { + getInstanceAuthorizationHeader(id: string): string | undefined { + 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.port : undefined + return record?.[WORKSPACE_STATE].published ? record.location?.directory ?? record.path : undefined } - getInstanceAuthorizationHeader(id: string): string | undefined { - return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.opencodeAuth.get(id)?.authorization : undefined + async getSharedServiceEndpoint(id: string): Promise { + if (!this.workspaces.get(id)?.[WORKSPACE_STATE].published) return undefined + try { + const [endpoint, headers] = await Promise.all([this.sharedService.endpoint(), this.sharedService.headers()]) + this.serviceAuthorization = headers?.authorization + return endpoint + } catch (error) { + this.options.logger.warn({ err: error }, "Shared OpenCode service is unavailable") + return undefined + } + } + + getSharedServiceClient(): Promise { + return this.sharedService.client() + } + + invalidateSharedServiceConnection(): void { + this.sharedService.invalidate?.() + } + + async ownsDirectory(id: string, directory: string): Promise { + 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)) + } + + ownsLocationWorkspace(id: string, workspaceID: string): boolean { + const record = this.workspaces.get(id) + return Boolean(record?.[WORKSPACE_STATE].published && record.location?.workspaceID === workspaceID) + } + + async getServiceDirectoryForPath(id: string, directory: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return undefined + const owned = await this.resolveOwnedWorktree(record, directory) + if (!owned) return undefined + if (!record.wslDistro) return owned.directory + return this.resolveWslServiceDirectory(owned.directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) ?? undefined + } + + async getWorktreeIdentityForPath(id: string, directory: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return undefined + const owned = await this.resolveOwnedWorktree(record, directory) + if (!owned) return undefined + return canonicalWorktreeIdentity(owned.worktreeDirectory) + } + + async getServicePathForPath(id: string, candidate: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published || !await this.ownsPath(id, candidate)) return undefined + if (!record.wslDistro) 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 { + return (await resolveOwnedWorktreePath({ + workspaceId: record.id, + workspacePath: record.path, + directory, + logger: this.options.logger, + })) !== null + } + + private async resolveOwnedWorktree(record: WorkspaceRecord, directory: string) { + const hostDirectory = record.wslDistro + ? isWindowsHostPath(directory) + ? directory + : this.resolveWslHostDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) + : directory + if (!hostDirectory) return null + return resolveOwnedWorktreePath({ + workspaceId: record.id, + workspacePath: record.path, + directory: hostDirectory, + logger: this.options.logger, + }) + } + + async ownsPath(id: string, candidate: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return false + if (await this.ownsHostPath(record, candidate)) return true + if (!record.wslDistro) return false + const hostPath = this.resolveWslHostDirectory(candidate, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) + return Boolean(hostPath && await this.ownsHostPath(record, hostPath)) + } + + private ownsHostPath(record: WorkspaceRecord, candidate: string): Promise { + return isPathOwnedByWorktree({ + workspaceId: record.id, + workspacePath: record.path, + candidate, + logger: this.options.logger, + }) + } + + subscribeToSharedService(signal?: AbortSignal): Promise> { + return this.sharedService.subscribe({ signal }) } findReadyInstanceIdByBinary(binaryPath: string): string | undefined { @@ -171,25 +304,6 @@ export class WorkspaceManager { })?.id } - private findReadyWorkspaceByIdentity( - identityKey: string, - includeRestoreOwned: boolean, - ): WorkspaceDescriptor | undefined { - for (const record of this.workspaces.values()) { - const state = record[WORKSPACE_STATE] - if ( - state.published - && !state.abortController.signal.aborted - && (includeRestoreOwned || !record.requestId) - && record.status === "ready" - && record.identityKey === identityKey - ) { - return record - } - } - return undefined - } - listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) @@ -244,11 +358,17 @@ export class WorkspaceManager { name?: string, options: WorkspaceCreateOptions = {}, ): Promise { + if (this.activeWorkspaceCreations >= MAX_ACTIVE_WORKSPACE_CREATIONS) { + throw new Error("Too many workspace creations are already in progress") + } + this.activeWorkspaceCreations += 1 + let settlementTracked = false const launchTimeoutMs = Math.max(1, this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS) - const launchDeadlineAt = Date.now() + launchTimeoutMs + const launchDeadlineAt = this.now() + launchTimeoutMs try { - const { workspacePath, identityKey } = await this.withLaunchDeadline( - resolveWorkspaceIdentity(folder, this.options.rootDir), + const submittedPath = path.isAbsolute(folder) ? path.normalize(folder) : path.resolve(this.options.rootDir, folder) + const workspacePath = await this.withLaunchDeadline( + realpath(submittedPath).then((resolved) => path.normalize(resolved), () => submittedPath), undefined, launchDeadlineAt, launchTimeoutMs, @@ -259,58 +379,25 @@ export class WorkspaceManager { if (this.shuttingDown) { throw new Error("Workspace manager is shutting down") } - if (options.forceNew) { - const ownership = this.createOwnership(options.requestId) - const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) - return this.finishCreation(result, options.requestId, ownership) - } - const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) - if (existing) { - this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") - const record = this.workspaces.get(existing.id) - if (options.requestId && record) { - if (!record.ownership.has(options.requestId)) record.ownership.set(options.requestId, "active") - this.syncOwnership(record) - return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership) - } - return { workspace: existing, created: false } - } - const pending = this.pendingWorkspaceCreations.get(identityKey) - if (pending) { - const state = pending[WORKSPACE_STATE] - const owner = options.requestId ?? ORDINARY_CREATION_OWNER - if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") - this.syncOwnership(pending) - const result = await state.creation! - return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) - } - const ownership = this.createOwnership(options.requestId) - const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) + const record = this.reserveWorkspace(workspacePath, name, options, launchDeadlineAt) const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) - this.pendingWorkspaceCreations.set(identityKey, record) - try { - return this.finishCreation(await creation, options.requestId, ownership) - } finally { - if (this.pendingWorkspaceCreations.get(identityKey) === record) { - this.pendingWorkspaceCreations.delete(identityKey) - } - } + settlementTracked = true + void record[WORKSPACE_STATE].settlement!.then(() => { this.activeWorkspaceCreations -= 1 }) + return this.finishCreation(await creation, options.requestId, record) } finally { + if (!settlementTracked) this.activeWorkspaceCreations -= 1 if (options.requestId) this.cancelledCreationRequests.delete(options.requestId) } } private reserveWorkspace( workspacePath: string, - identityKey: string, name: string | undefined, options: WorkspaceCreateOptions, - ownership: WorkspaceCreationOwnership, launchDeadlineAt: number, ): WorkspaceRecord { const id = randomUUID() - const binary = this.options.binaryResolver.resolve(options.binaryPath) - const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - Date.now())) + const binary = this.options.binaryResolver.resolveDefault() + const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - this.now())) clearWorkspaceSearchCache(workspacePath) this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") @@ -332,9 +419,15 @@ export class WorkspaceManager { updatedAt: new Date().toISOString(), } as WorkspaceRecord Object.defineProperties(record, { - identityKey: { value: identityKey }, - ownership: { value: ownership }, - [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } }, + wslDistro: { value: undefined, writable: true }, + [WORKSPACE_STATE]: { value: { + abortController: new AbortController(), + published: false, + stoppedPublished: false, + locationOwned: false, + creationRequestId: options.requestId, + creationRequestState: options.requestId ? "active" : undefined, + } }, }) this.workspaces.set(id, record) @@ -345,14 +438,15 @@ export class WorkspaceManager { } private startCreation(record: WorkspaceRecord, options: WorkspaceCreateOptions, launchDeadlineAt: number, launchTimeoutMs: number): Promise { - const creation = this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) - record[WORKSPACE_STATE].creation = creation - record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined) + const launch = this.createResolvedWorkspace(record, launchDeadlineAt) + const creation = this.createWithDeadline(record, launchDeadlineAt, launchTimeoutMs, launch) + record[WORKSPACE_STATE].settlement = launch.then(() => undefined, () => undefined) return creation } - private async createWithDeadline(record: WorkspaceRecord, options: WorkspaceCreateOptions, - launchDeadlineAt: number, launchTimeoutMs: number): Promise { - const timeoutMs = Math.max(1, launchDeadlineAt - Date.now()) + private async createWithDeadline(record: WorkspaceRecord, + launchDeadlineAt: number, launchTimeoutMs: number, + launch: Promise): Promise { + const timeoutMs = Math.max(1, launchDeadlineAt - this.now()) const state = record[WORKSPACE_STATE] let timeout: ManagerTimeout | null = (this.options.setTimeout ?? setTimeout)(() => { timeout = null @@ -361,7 +455,10 @@ export class WorkspaceManager { } }, timeoutMs) try { - return await this.createResolvedWorkspace(record, options) + const deadline = new Promise((_resolve, reject) => { + state.abortController.signal.addEventListener("abort", () => reject(state.abortController.signal.reason), { once: true }) + }) + return await Promise.race([launch, deadline]) } finally { if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) } @@ -369,7 +466,7 @@ export class WorkspaceManager { private async withLaunchDeadline(operation: Promise, workspaceId: string | undefined, deadlineAt: number, launchTimeoutMs: number): Promise { - const timeoutMs = Math.max(1, deadlineAt - Date.now()) + const timeoutMs = Math.max(1, deadlineAt - this.now()) let timeout: ManagerTimeout | null = null const deadline = new Promise((_resolve, reject) => { timeout = (this.options.setTimeout ?? setTimeout)(() => { @@ -385,99 +482,90 @@ export class WorkspaceManager { } private async createResolvedWorkspace( record: WorkspaceRecord, - options: WorkspaceCreateOptions, + launchDeadlineAt: number, ): Promise { const state = record[WORKSPACE_STATE] - const { id, path: workspacePath, binaryId: resolvedBinaryPath, proxyPath } = record + const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record + let cancelledLocation: LocationRef | undefined try { - this.throwIfCancelled(record) - - const serverConfig = this.options.settings.getOwner("config", "server") - const envVars = (serverConfig as any)?.environmentVariables - const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} - const opencodeConfigContent = buildOpencodeConfigContent( - resolveExistingOpencodeConfigContent(userEnvironment), - this.codeNomadPluginUrl, - ) - const serverBaseUrl = this.options.getServerBaseUrl() - const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") - - const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({ - userEnvironment, - processEnv: process.env, + const launch = buildServiceLaunchSpec(resolvedBinaryPath, { + platform: this.options.platform, }) - const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword }) - if (!authorization) { - throw new Error("Failed to build OpenCode auth header") - } - this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization }) - - const environment = { - ...userEnvironment, - OPENCODE_CONFIG_CONTENT: opencodeConfigContent, - OPENCODE_EXPERIMENTAL_WORKSPACES: "true", - CODENOMAD_INSTANCE_ID: id, - CODENOMAD_BASE_URL: serverBaseUrl, - ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), - [OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`, - [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, - [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, + const timeoutMs = Math.max(1, launchDeadlineAt - this.now()) + const startupEnvironment = launch.kind === "wsl" + ? this.wslStartupEnvironment(this.serviceStartupEnvironment(), launch.distro, launchDeadlineAt) + : this.serviceStartupEnvironment() + const serviceOptions: OpenCodeSharedServiceOptions = { + kind: "lifecycle", + identity: launch.kind === "host" + ? hostOpenCodeServiceIdentity({ + binary: launch.binary, + platform: launch.platform, + startupEnvironment, + }) + : `wsl:${launch.distro.trim().toLowerCase()}:${path.posix.normalize(launch.binary)}` + + `:env:${startupEnvironmentHash(startupEnvironment, "linux")}`, + lifecycle: launch.kind === "host" + ? this.createHostServiceLifecycle(launch, timeoutMs, startupEnvironment) + : this.createWslServiceLifecycle(launch, timeoutMs, startupEnvironment), } - - const logLevel = (serverConfig as any)?.logLevel - const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ - workspaceId: id, - folder: workspacePath, - binaryPath: resolvedBinaryPath, - environment, - logLevel, - signal: state.abortController.signal, - onExit: (info) => this.handleProcessExit(info.workspaceId, info), - }) - record.pid = pid - record.port = port - + state.serviceOptions = serviceOptions this.throwIfCancelled(record) - state.published = true - this.options.eventBus.publish({ type: "workspace.created", workspace: record }) - this.throwIfCancelled(record) - const runtimeVersion = await this.waitForWorkspaceReadiness({ - workspaceId: id, - port, - exitPromise, - getLastOutput, - signal: state.abortController.signal, + record.wslDistro = launch.kind === "wsl" ? launch.distro : undefined + const serviceDirectory = launch.kind === "wsl" + ? this.requireWslServiceDirectory(workspacePath, launch.distro, launchDeadlineAt - this.now()) + : workspacePath + record.location = { directory: serviceDirectory } + return await this.withLocationCreation(async () => { + this.throwIfCancelled(record) + const [headersResult, locationResult] = await Promise.allSettled([ + this.sharedService.headers(serviceOptions, { deadlineAt: launchDeadlineAt }), + this.sharedService.validateLocation( + { directory: serviceDirectory }, + { signal: state.abortController.signal, deadlineAt: launchDeadlineAt }, + serviceOptions, + ), + ]) + if (state.abortController.signal.aborted && locationResult.status === "fulfilled") { + cancelledLocation = { + directory: locationResult.value.directory, + workspaceID: locationResult.value.workspaceID, + } + } + this.throwIfCancelled(record) + if (locationResult.status === "fulfilled") { + record.location = { directory: locationResult.value.directory, workspaceID: locationResult.value.workspaceID } + state.locationOwned = true + } + if (headersResult.status === "rejected") throw headersResult.reason + if (locationResult.status === "rejected") throw locationResult.reason + this.serviceAuthorization = headersResult.value?.authorization + this.throwIfCancelled(record) + state.published = true + this.options.eventBus.publish({ type: "workspace.created", workspace: record }) + this.throwIfCancelled(record) + + record.status = "ready" + record.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.started", workspace: record }) + this.options.logger.info({ workspaceId: id, location: record.location }, "Workspace ready") + return { workspace: record, created: true } }) - this.throwIfCancelled(record) - if (runtimeVersion) { - record.binaryVersion = runtimeVersion - } - - record.status = "ready" - record.updatedAt = new Date().toISOString() - this.options.eventBus.publish({ type: "workspace.started", workspace: record }) - this.options.logger.info({ workspaceId: id, port }, "Workspace ready") - return { workspace: record, created: true } } catch (error) { const launchFailure = state.abortController.signal.aborted ? state.abortController.signal.reason : error - let stopFailure: unknown - await this.runtime.stop(id).catch((stopError) => { - stopFailure = stopError - }) - if (!stopFailure) { - this.removeRecord(id, record, state.published) - throw launchFailure - } - if (!state.published) { - throw stopFailure + if ((state.locationOwned || cancelledLocation) && !this.shuttingDown) { + const eviction = cancelledLocation + ? this.evictCancelledLocation(record, cancelledLocation) + : this.evictRecordLocation(record) + await eviction.catch((evictionError) => { + this.options.logger.warn( + { workspaceId: id, err: evictionError }, + "Failed to evict an OpenCode location after workspace launch failure", + ) + }) } - record.status = "error" - record.error = stopFailure instanceof Error - ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}` - : launchFailure instanceof Error ? launchFailure.message : String(launchFailure) - record.updatedAt = new Date().toISOString() - if (this.workspaces.get(id) === record && state.published) { - this.options.eventBus.publish({ type: "workspace.error", workspace: record }) + if (!state.deletePromise && (!state.published || !state.locationOwned)) { + this.removeRecord(id, record, state.published) } this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start") throw launchFailure @@ -491,10 +579,6 @@ export class WorkspaceManager { if (!state.abortController.signal.aborted) { state.abortController.abort(new WorkspaceLaunchCancelledError(id)) } - const pending = this.pendingWorkspaceCreations.get(record.identityKey) - if (pending === record) { - this.pendingWorkspaceCreations.delete(record.identityKey) - } if (!state.deletePromise) { let deletePromise!: Promise deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => { @@ -509,88 +593,73 @@ export class WorkspaceManager { releaseCreationRequest(id: string, requestId: string): boolean { const record = this.workspaces.get(id) if (!record?.[WORKSPACE_STATE].published) return false - const ownership = record.ownership - const state = ownership.get(requestId) - if (state === "released") return true - if (state === "cancelled") return false - if (state !== "active") return false - ownership.set(requestId, "released") - this.syncOwnership(record) + const state = record[WORKSPACE_STATE] + if (state.creationRequestId !== requestId) return false + if (state.creationRequestState === "released") return true + if (state.creationRequestState !== "active") return false + state.creationRequestState = "released" + record.requestId = undefined return true } async cancelCreationRequest(requestId: string): Promise { - let matched = false for (const [workspaceId, record] of this.workspaces) { - const ownership = record.ownership - const state = ownership.get(requestId) - if (state === "released") { matched = true; continue } - if (state === "cancelled") { - matched = true - if (this.hasActiveRequest(ownership) || this.isRetained(ownership)) continue - await this.delete(workspaceId) - return - } - if (state !== "active") continue - matched = true - ownership.set(requestId, "cancelled") - this.syncOwnership(record) - if (this.hasActiveRequest(ownership) || this.isRetained(ownership)) return + const state = record[WORKSPACE_STATE] + if (state.creationRequestId !== requestId) continue + if (state.creationRequestState === "released") return + state.creationRequestState = "cancelled" + record.requestId = undefined await this.delete(workspaceId) return } - if (!matched) this.cancelledCreationRequests.add(requestId) - } - - private createOwnership(requestId?: string): WorkspaceCreationOwnership { - return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]) + this.cancelledCreationRequests.add(requestId) } private finishCreation( result: WorkspaceCreateResult, requestId: string | undefined, - ownership: WorkspaceCreationOwnership, + record: WorkspaceRecord, ): WorkspaceCreateResult { - if (requestId && ownership.get(requestId) === "cancelled") { + const requestState = record[WORKSPACE_STATE].creationRequestState + if (requestId && requestState === "cancelled") { throw new Error(`Workspace creation request ${requestId} was cancelled`) } - const retained = this.isRetained(ownership) return { workspace: requestId ? { ...result.workspace, requestId } : result.workspace, - created: result.created && !(requestId && retained), + created: result.created && requestState !== "released", } } - private syncOwnership(record: WorkspaceRecord | undefined): void { - if (!record) return - const ownership = record.ownership - record.requestId = this.isRetained(ownership) - ? undefined - : Array.from(ownership).find(([, state]) => state === "active")?.[0] - } - - private isRetained(ownership: WorkspaceCreationOwnership): boolean { - return ownership.has(ORDINARY_CREATION_OWNER) || Array.from(ownership.values()).includes("released") - } - - private hasActiveRequest(ownership: WorkspaceCreationOwnership): boolean { - return Array.from(ownership).some(([requestId, state]) => Boolean(requestId) && state === "active") - } - async shutdown() { this.shuttingDown = true this.options.logger.info("Shutting down all workspaces") - 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") - : [] - const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) - if (this.workspaces.size === 0) { - this.pendingWorkspaceCreations.clear() - this.cancelledCreationRequests.clear() - } else if (!stopFailures.length) stopFailures.push( - new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`), - ) + const shutdownTimeoutMs = Math.max(1, this.options.shutdownTimeoutMs ?? 10000) + const deadlineAt = this.now() + shutdownTimeoutMs + const records = Array.from(this.workspaces.entries()) + for (const [id, record] of records) { + const state = record[WORKSPACE_STATE] + if (!state.abortController.signal.aborted) state.abortController.abort(new WorkspaceLaunchCancelledError(id)) + } + const settlements = records.map(([, record]) => { + const state = record[WORKSPACE_STATE] + return state.cleanupSettlement ?? state.deletePromise ?? state.settlement ?? Promise.resolve() + }) + const stopFailures: unknown[] = [] + if (settlements.length) { + await this.withTimeout(Promise.allSettled(settlements), shutdownTimeoutMs, "shutdown") + .then((results) => { + stopFailures.push(...results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])) + }) + .catch((error) => stopFailures.push(error)) + } + for (const [id, record] of records) this.removeRecord(id, record, true, "stopped") + this.cancelledCreationRequests.clear() + const remaining = Math.max(1, deadlineAt - this.now()) + await this.withTimeout( + this.sharedService.shutdown({ timeoutMs: remaining }), + remaining, + "shared service shutdown", + ).catch((error) => stopFailures.push(error)) if (stopFailures.length) throw new WorkspaceShutdownError(stopFailures) } @@ -621,25 +690,203 @@ export class WorkspaceManager { } private async cleanupDeletedWorkspace(id: string, record: WorkspaceRecord): Promise { - // Stop once immediately, then again after launch settlement to cover a child - // that became available while cancellation was propagating. - const immediateStop = this.runtime.stop(id).catch((error) => { - this.options.logger.warn({ workspaceId: id, err: error }, "Initial workspace process cleanup failed; retrying after launch settles") + const timeoutMs = Math.max(1, this.options.launchSettlementTimeoutMs ?? 5000) + const state = record[WORKSPACE_STATE] + const cleanup = state.cleanupSettlement ?? (async () => { + await state.settlement! + await this.evictRecordLocation(record) + this.removeRecord(id, record, true) + return record + })() + state.cleanupSettlement = cleanup + void cleanup.catch(() => { + if (state.cleanupSettlement === cleanup) state.cleanupSettlement = undefined }) - await this.withTimeout(record[WORKSPACE_STATE].settlement!, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`) - await immediateStop - await this.runtime.stop(id) + return this.withTimeout(cleanup, timeoutMs, `${id} cleanup`) + } - this.removeRecord(id, record, true) - return record + private async evictRecordLocation(record: WorkspaceRecord): Promise { + const state = record[WORKSPACE_STATE] + if (!state.locationOwned || !record.location) return + await this.withLocationEviction(async () => { + if (this.shuttingDown) return + if (!state.locationOwned || !record.location) return + const directory = record.location.directory + const shared = Array.from(this.workspaces.values()).some((candidate) => ( + candidate !== record + && candidate[WORKSPACE_STATE].locationOwned + && !candidate[WORKSPACE_STATE].abortController.signal.aborted + && candidate.location?.directory === directory + )) + if (shared) { + state.locationOwned = false + return + } + await this.sharedService.evictLocation(record.location, undefined, state.serviceOptions) + for (const candidate of this.workspaces.values()) { + if (candidate.location?.directory === directory) candidate[WORKSPACE_STATE].locationOwned = false + } + }) + } + + private async evictCancelledLocation(record: WorkspaceRecord, location: LocationRef): Promise { + await this.withLocationEviction(async () => { + if (this.shuttingDown) return + const shared = Array.from(this.workspaces.values()).some((candidate) => ( + candidate !== record + && candidate[WORKSPACE_STATE].locationOwned + && !candidate[WORKSPACE_STATE].abortController.signal.aborted + && candidate.location?.directory === location.directory + )) + if (!shared) await this.sharedService.evictLocation(location, undefined, record[WORKSPACE_STATE].serviceOptions) + }) + } + + private async withLocationCreation(operation: () => Promise): Promise { + while (this.pendingLocationEvictions) await this.locationEvictions + let release!: () => void + const active = new Promise((resolve) => { release = resolve }) + this.activeLocationCreations.add(active) + try { + return await operation() + } finally { + this.activeLocationCreations.delete(active) + release() + } } - private removeRecord(id: string, record: WorkspaceRecord, publishStopped: boolean): void { + private withLocationEviction(operation: () => Promise): Promise { + this.pendingLocationEvictions += 1 + const run = this.locationEvictions.then(async () => { + await Promise.all(this.activeLocationCreations) + return operation() + }) + const settled = run.finally(() => { this.pendingLocationEvictions -= 1 }) + this.locationEvictions = settled.then(() => undefined, () => undefined) + return settled + } + + private requireWslServiceDirectory(directory: string, distro: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { + const translated = timeoutMs > 0 ? this.resolveWslServiceDirectory(directory, distro, timeoutMs) : null + 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 createWslServiceLifecycle( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ): OpenCodeServiceLifecycle { + return this.options.wslServiceLifecycleFactory?.(spec, timeoutMs, startupEnvironment) + ?? new WslOpenCodeService({ + distro: spec.distro, + binary: spec.binary, + startupEnvironment, + timeoutMs, + }) + } + + private createHostServiceLifecycle( + spec: Extract, + timeoutMs: number, + startupEnvironment: NodeJS.ProcessEnv, + ): OpenCodeServiceLifecycle { + return this.options.hostServiceLifecycleFactory?.(spec, timeoutMs, startupEnvironment) + ?? new HostOpenCodeService({ + binary: spec.binary, + platform: spec.platform, + startupEnvironment, + timeoutMs, + }) + } + + private serviceStartupEnvironment(): NodeJS.ProcessEnv { + const configured = this.options.settings.getOwner("config", "server").environmentVariables + const environment: NodeJS.ProcessEnv = {} + const omitted: string[] = [] + if (configured && typeof configured === "object" && !Array.isArray(configured)) { + for (const [key, value] of Object.entries(configured)) { + if (typeof value !== "string") continue + if (["OPENCODE_DB", "XDG_STATE_HOME"].includes(key.toUpperCase())) { + omitted.push(key) + continue + } + if (!key || key.includes("=") || key.includes("\0") || value.includes("\0")) { + throw new Error(`Invalid OpenCode service environment variable name: ${key || "(empty)"}`) + } + environment[key] = value + } + } + if (omitted.length && !this.warnedLegacyServiceEnvironment) { + this.warnedLegacyServiceEnvironment = true + this.options.logger.warn( + { variables: omitted }, + "Ignoring legacy OpenCode storage ownership variables for the global daemon", + ) + } + if (process.env.NODE_EXTRA_CA_CERTS !== undefined) { + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "NODE_EXTRA_CA_CERTS") delete environment[key] + } + environment.NODE_EXTRA_CA_CERTS = process.env.NODE_EXTRA_CA_CERTS + } + return environment + } + + private wslStartupEnvironment(environment: NodeJS.ProcessEnv, distro: string, deadlineAt: number): NodeJS.ProcessEnv { + let caKey: string | undefined + let caValue: string | undefined + for (const [key, value] of Object.entries(environment)) { + if (key.toUpperCase() !== "NODE_EXTRA_CA_CERTS") continue + if (caKey === undefined || key === "NODE_EXTRA_CA_CERTS") { + caKey = key + caValue = value + } + delete environment[key] + } + if (caKey === undefined) return environment + if (caValue && !path.posix.isAbsolute(caValue) && path.win32.isAbsolute(caValue)) { + const remaining = deadlineAt - this.now() + const translated = remaining > 0 ? this.resolveWslServiceDirectory(caValue, distro, remaining) : null + if (!translated) { + throw new Error(`Unable to translate NODE_EXTRA_CA_CERTS for WSL distro "${distro}": ${caValue}`) + } + caValue = translated + } + environment.NODE_EXTRA_CA_CERTS = caValue + return environment + } + + private now(): number { + return (this.options.now ?? Date.now)() + } + + private removeRecord( + id: string, + record: WorkspaceRecord, + publishStopped: boolean, + reason: "deleted" | "stopped" = "deleted", + ): void { if (this.workspaces.get(id) !== record) return this.workspaces.delete(id) - this.opencodeAuth.delete(id) clearWorkspaceSearchCache(record.path) - if (publishStopped) this.publishStopped(record, "deleted") + if (publishStopped) this.publishStopped(record, reason) } private publishStopped(record: WorkspaceRecord, reason: "deleted" | "stopped" = "stopped"): void { @@ -703,192 +950,4 @@ export class WorkspaceManager { return candidates[0] ?? "" } - - private async waitForWorkspaceReadiness(params: WorkspaceReadiness): Promise { - - await Promise.race([ - this.waitForPortAvailability(params.port, 5000, params.signal), - this.exitDuringStartup(params, "exited before becoming ready"), - ]) - - const version = await this.waitForInstanceHealth(params) - - await Promise.race([ - this.validateInstanceConfiguration(params), - this.exitDuringStartup(params, "exited during configuration validation"), - ]) - - await Promise.race([ - delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }), - this.exitDuringStartup(params, "exited shortly after start"), - ]) - - return version - } - - private async waitForInstanceHealth(params: WorkspaceReadiness): Promise { - const probeResult = await Promise.race([ - this.probeInstance(params.workspaceId, params.port, params.signal), - this.exitDuringStartup(params, "exited during health checks"), - ]) - - if (probeResult.ok) { - return probeResult.version - } - - const latestOutput = params.getLastOutput().trim() - if (latestOutput) { - throw new Error(latestOutput) - } - const reason = probeResult.reason ?? "Health check failed" - throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) - } - - private exitDuringStartup(params: WorkspaceReadiness, phase: string): Promise { - return params.exitPromise.then((info) => { - throw this.buildStartupError(params.workspaceId, phase, info, params.getLastOutput()) - }) - } - - private async probeInstance( - workspaceId: string, - port: number, - signal?: AbortSignal, - ): Promise<{ ok: boolean; reason?: string; version?: string }> { - const url = `http://${LOOPBACK_HOST}:${port}/global/health` - - try { - const response = await fetch(url, { headers: this.getInstanceRequestHeaders(workspaceId), signal }) - if (!response.ok) { - const reason = `/global/health returned HTTP ${response.status}` - this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") - return { ok: false, reason } - } - - const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown } - const healthy = payload?.healthy === true - const version = typeof payload?.version === "string" ? payload.version.trim() : undefined - - if (!healthy) { - const reason = "Instance reported unhealthy" - this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response") - return { ok: false, reason } - } - - return { ok: true, version: version || undefined } - } catch (error) { - const reason = error instanceof Error ? error.message : String(error) - this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") - return { ok: false, reason } - } - } - - private async validateInstanceConfiguration(params: WorkspaceReadiness): Promise { - const response = await fetch(`http://${LOOPBACK_HOST}:${params.port}/config`, { - headers: this.getInstanceRequestHeaders(params.workspaceId), - signal: params.signal, - }) - if (response.ok) { - await response.body?.cancel() - return - } - - const body = (await response.text()).trim() - throw new Error(body || `OpenCode /config returned HTTP ${response.status}`) - } - - private getInstanceRequestHeaders(workspaceId: string): Record { - const authorization = this.opencodeAuth.get(workspaceId)?.authorization - return authorization ? { Authorization: authorization } : {} - } - - private buildStartupError( - workspaceId: string, - phase: string, - exitInfo: ProcessExitInfo, - lastOutput: string, - ): Error { - const exitDetails = this.describeExit(exitInfo) - const trimmedOutput = lastOutput.trim() - const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : "" - return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`) - } - - private waitForPortAvailability(port: number, timeoutMs = 5000, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs - let settled = false - let retryTimer: NodeJS.Timeout | null = null - - const cleanup = () => { - settled = true - if (retryTimer) { - clearTimeout(retryTimer) - retryTimer = null - } - } - - const tryConnect = () => { - if (settled) return - const socket = connect({ port, host: LOOPBACK_HOST, signal }, () => { - cleanup() - socket.end() - resolve() - }) - socket.once("error", () => { - socket.destroy() - if (settled) return - if (signal?.aborted) { - cleanup() - reject(signal.reason) - return - } - if (Date.now() >= deadline) { - cleanup() - reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`)) - } else { - retryTimer = setTimeout(() => { - retryTimer = null - tryConnect() - }, 100) - } - }) - } - - if (signal?.aborted) return reject(signal.reason) - tryConnect() - }) - } - - private describeExit(info: ProcessExitInfo): string { - if (info.signal) { - return `signal ${info.signal}` - } - if (info.code !== null) { - return `code ${info.code}` - } - return "unknown reason" - } - - private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { - const record = this.workspaces.get(workspaceId) - if (!record) return - const workspace = record - - this.opencodeAuth.delete(workspaceId) - - this.options.logger.info({ workspaceId, ...info }, "Workspace process exited") - - workspace.pid = undefined - workspace.port = undefined - workspace.updatedAt = new Date().toISOString() - - if (record[WORKSPACE_STATE].abortController.signal.aborted || info.requested || info.code === 0) { - this.publishStopped(record) - } else { - workspace.status = "error" - workspace.error = `Process exited with code ${info.code}` - this.options.eventBus.publish({ type: "workspace.error", workspace }) - } - } } diff --git a/packages/server/src/workspaces/opencode-auth.test.ts b/packages/server/src/workspaces/opencode-auth.test.ts deleted file mode 100644 index e4a13a4d5..000000000 --- a/packages/server/src/workspaces/opencode-auth.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { resolveOpencodeServerAuth } from "./opencode-auth" - -describe("resolveOpencodeServerAuth", () => { - it("uses configured OpenCode auth from workspace environment", () => { - const auth = resolveOpencodeServerAuth({ - userEnvironment: { - OPENCODE_SERVER_USERNAME: "alice", - OPENCODE_SERVER_PASSWORD: "secret", - }, - processEnv: {}, - generatePassword: () => "generated", - }) - - assert.deepEqual(auth, { username: "alice", password: "secret" }) - }) - - it("uses process environment when workspace environment does not provide credentials", () => { - const auth = resolveOpencodeServerAuth({ - userEnvironment: {}, - processEnv: { - OPENCODE_SERVER_PASSWORD: "process-secret", - }, - generatePassword: () => "generated", - }) - - assert.deepEqual(auth, { username: "codenomad", password: "process-secret" }) - }) - - it("falls back to generated credentials", () => { - const auth = resolveOpencodeServerAuth({ - userEnvironment: {}, - processEnv: {}, - generatePassword: () => "generated", - }) - - assert.deepEqual(auth, { username: "codenomad", password: "generated" }) - }) -}) diff --git a/packages/server/src/workspaces/opencode-auth.ts b/packages/server/src/workspaces/opencode-auth.ts deleted file mode 100644 index 55daeed7b..000000000 --- a/packages/server/src/workspaces/opencode-auth.ts +++ /dev/null @@ -1,49 +0,0 @@ -import crypto from "node:crypto" - -export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const -export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const -export const OPENCODE_SERVER_BASE_URL_ENV = "OPENCODE_SERVER_BASE_URL" as const - -export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const - -export function generateOpencodeServerPassword(): string { - return crypto.randomBytes(32).toString("base64url") -} - -function readConfiguredValue(key: string, ...sources: Array | undefined>): string | undefined { - for (const source of sources) { - const value = source?.[key] - if (typeof value === "string" && value.trim().length > 0) { - return value - } - } - return undefined -} - -export function resolveOpencodeServerAuth(options: { - userEnvironment?: Record - processEnv?: NodeJS.ProcessEnv - generatePassword?: () => string -} = {}): { username: string; password: string } { - const generatePassword = options.generatePassword ?? generateOpencodeServerPassword - const username = - readConfiguredValue(OPENCODE_SERVER_USERNAME_ENV, options.userEnvironment, options.processEnv) ?? - DEFAULT_OPENCODE_USERNAME - const password = - readConfiguredValue(OPENCODE_SERVER_PASSWORD_ENV, options.userEnvironment, options.processEnv) ?? - generatePassword() - - return { username, password } -} - -export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined { - const username = params.username - const password = params.password - - if (!username || !password) { - return undefined - } - - const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64") - return `Basic ${token}` -} diff --git a/packages/server/src/workspaces/opencode-cli-service.ts b/packages/server/src/workspaces/opencode-cli-service.ts new file mode 100644 index 000000000..7074003ec --- /dev/null +++ b/packages/server/src/workspaces/opencode-cli-service.ts @@ -0,0 +1,266 @@ +import { execFile as nodeExecFile } from "node:child_process" +import { Service, type Endpoint } from "@opencode-ai/client/service" + +import { assertLoopbackServiceUrl } from "./service-state" +import type { OpenCodeServiceLifecycle } from "./opencode-service" +import type { SpawnSpec } from "./spawn" + +export const MAX_SERVICE_OUTPUT_BYTES = 64 * 1024 +const MAX_ERROR_CHARS = 1_024 + +export interface ServiceExecOptions { + encoding: "utf8" + maxBuffer: number + shell: false + timeout: number + windowsHide: true + cwd?: string + env?: NodeJS.ProcessEnv + windowsVerbatimArguments?: boolean +} + +export interface ServiceExecResult { + stdout: string + stderr: string +} + +export interface OpenCodeCliServiceDependencies { + execFile: (file: string, args: string[], options: ServiceExecOptions) => Promise + fetch: typeof globalThis.fetch +} + +export interface OpenCodeCliServiceOptions { + label: string + timeoutMs: number + command: (args: string[], start: boolean) => SpawnSpec + beforeHealth?: (endpoint: Endpoint, deadlineAt: number) => Promise + unreachableMessage?: (url: string) => string +} + +export class OpenCodeCliService implements OpenCodeServiceLifecycle { + private readonly dependencies: OpenCodeCliServiceDependencies + private readonly timeoutMs: number + + constructor( + private readonly options: OpenCodeCliServiceOptions, + dependencies: Partial = {}, + ) { + this.timeoutMs = Math.max(1, options.timeoutMs) + this.dependencies = { execFile: executeFile, fetch: globalThis.fetch, ...dependencies } + } + + async discover(deadlineAt = Date.now() + this.timeoutMs): Promise { + const status = this.singleLine(await this.run(["service", "status"], false, deadlineAt), "status") + if (status === "stopped") return undefined + return this.endpoint(status, deadlineAt) + } + + async ensure(deadlineAt = Date.now() + this.timeoutMs): Promise { + const url = this.singleLine(await this.run(["service", "start"], true, deadlineAt), "start") + return this.endpoint(url, deadlineAt) + } + + private async endpoint(value: string, deadlineAt: number): Promise { + const url = this.assertServiceUrl(value) + const password = this.singleLine( + await this.run(["service", "get", "password"], false, deadlineAt), + "password", + ) + if (!password) throw new Error(`${this.options.label} OpenCode service returned an empty password`) + const endpoint: Endpoint = { url, auth: { type: "basic", username: "opencode", password } } + await this.options.beforeHealth?.(endpoint, deadlineAt) + await this.validateHealth(endpoint, deadlineAt) + return endpoint + } + + private async run(args: string[], start: boolean, deadlineAt: number): Promise { + const commandLabel = args.join(" ") + const timeout = this.remaining(deadlineAt, commandLabel) + const spec = this.options.command(args, start) + const options: ServiceExecOptions = { + encoding: "utf8", + maxBuffer: MAX_SERVICE_OUTPUT_BYTES, + shell: false, + timeout, + windowsHide: true, + ...(spec.cwd ? { cwd: spec.cwd } : {}), + ...(spec.env ? { env: spec.env } : {}), + ...(spec.options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + } + try { + const result = await this.withDeadline( + this.dependencies.execFile(spec.command, spec.args, options), + deadlineAt, + commandLabel, + ) + return result.stdout + } catch (error) { + if (start || commandLabel === "service get password") { + const operation = start ? "start" : "password retrieval" + const code = safeNumericExecCode(error) + throw new Error( + `${this.options.label} OpenCode ${operation} failed${code === undefined ? "" : ` (exit code ${code})`}`, + ) + } + const detail = boundedExecError(error) + throw new Error(`${this.options.label} OpenCode ${commandLabel} failed${detail ? `: ${detail}` : ""}`) + } + } + + private async validateHealth(endpoint: Endpoint, deadlineAt: number): Promise { + let response: Response + try { + const timeout = this.remaining(deadlineAt, "health validation") + response = await this.withDeadline(this.dependencies.fetch(new URL("/api/health", endpoint.url), { + headers: Service.headers(endpoint), + signal: AbortSignal.timeout(timeout), + }), deadlineAt, "health validation") + } catch { + const message = this.options.unreachableMessage?.(endpoint.url) + throw new Error(message ?? `Cannot reach the ${this.options.label} OpenCode service at ${endpoint.url}`) + } + if (response.status === 401) { + throw new Error(`${this.options.label} OpenCode service authentication failed at ${endpoint.url} (HTTP 401)`) + } + if (!response.ok) { + throw new Error(`${this.options.label} OpenCode service health check failed at ${endpoint.url} (HTTP ${response.status})`) + } + + let health: unknown + try { + const body = await this.withDeadline( + readBoundedBody(response, MAX_SERVICE_OUTPUT_BYTES), + deadlineAt, + "health response", + ) + health = JSON.parse(body) + } catch { + throw new Error(`${this.options.label} OpenCode service returned an invalid health response at ${endpoint.url}`) + } + const value = health as { healthy?: unknown; version?: unknown; pid?: unknown } | null + if ( + !value + || typeof value !== "object" + || value.healthy !== true + || typeof value.version !== "string" + || !value.version.trim() + || !Number.isSafeInteger(value.pid) + || Number(value.pid) <= 0 + ) { + throw new Error(`${this.options.label} OpenCode service is not API-compatible at ${endpoint.url}`) + } + } + + private assertServiceUrl(value: string): string { + let url: URL + let wildcard = false + try { + wildcard = new URL(value).hostname === "0.0.0.0" + url = assertLoopbackServiceUrl(value) + } catch { + throw new Error(`${this.options.label} OpenCode service returned an invalid loopback URL`) + } + if (/[^\S\r\n]|[\x00-\x1f\x7f]/.test(value) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error(`${this.options.label} OpenCode service returned an invalid loopback URL`) + } + return wildcard ? url.toString() : value + } + + private singleLine(value: string, label: string): string { + const line = value.endsWith("\r\n") ? value.slice(0, -2) + : value.endsWith("\n") || value.endsWith("\r") ? value.slice(0, -1) + : value + if (line.includes("\n") || line.includes("\r")) { + throw new Error(`${this.options.label} OpenCode service returned multiline ${label} output`) + } + if (line !== line.trim()) { + throw new Error(`${this.options.label} OpenCode service returned malformed ${label} output`) + } + return line + } + + private remaining(deadlineAt: number, label: string): number { + const remaining = deadlineAt - Date.now() + if (remaining <= 0) { + throw new Error(`${this.options.label} OpenCode ${label} timed out after ${this.timeoutMs}ms`) + } + return remaining + } + + private async withDeadline(operation: Promise, deadlineAt: number, label: string): Promise { + const timeoutMs = this.remaining(deadlineAt, label) + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`${this.options.label} OpenCode ${label} timed out after ${this.timeoutMs}ms`)), + timeoutMs, + ) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } +} + +function executeFile(file: string, args: string[], options: ServiceExecOptions): Promise { + return new Promise((resolve, reject) => { + nodeExecFile(file, args, options, (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stdout, stderr }) + reject(error) + } else { + resolve({ stdout, stderr }) + } + }) + }) +} + +function boundedExecError(error: unknown): string { + if (!error || typeof error !== "object") return clip(String(error)) + const value = error as { code?: unknown; message?: unknown; stdout?: unknown; stderr?: unknown } + return clip([ + value.code === undefined ? "" : `code ${clip(String(value.code))}`, + value.message === undefined ? "" : clip(String(value.message)), + value.stderr === undefined ? "" : clip(String(value.stderr).trim()), + value.stdout === undefined ? "" : clip(String(value.stdout).trim()), + ].filter(Boolean).join(": ")) +} + +function safeNumericExecCode(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined + const code = (error as { code?: unknown }).code + return typeof code === "number" && Number.isSafeInteger(code) ? code : undefined +} + +async function readBoundedBody(response: Response, maxBytes: number): Promise { + if (!response.body) return "" + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (total + value.byteLength > maxBytes) { + await reader.cancel().catch(() => undefined) + throw new Error("Response body exceeds limit") + } + chunks.push(value) + total += value.byteLength + } + } catch (error) { + await reader.cancel().catch(() => undefined) + throw error + } finally { + reader.releaseLock() + } + return Buffer.concat(chunks, total).toString("utf8") +} + +function clip(value: string): string { + return value.length <= MAX_ERROR_CHARS ? value : `${value.slice(0, MAX_ERROR_CHARS)}...` +} diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts new file mode 100644 index 000000000..ebe7404db --- /dev/null +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient } from "@opencode-ai/client" +import type { Endpoint } from "@opencode-ai/client/service" + +import { + OpenCodeSharedService, + type OpenCodeServiceLifecycle, + type OpenCodeSharedServiceDependencies, + type OpenCodeSharedServiceOptions, +} from "./opencode-service" + +const endpoint: Endpoint = { + url: "http://127.0.0.1:4321", + auth: { type: "basic", username: "opencode", password: "secret" }, +} + +describe("OpenCodeSharedService", () => { + it("discovers or starts one pinned CLI lifecycle", async () => { + let discoveries = 0 + let starts = 0 + let running = false + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => { discoveries += 1; return running ? endpoint : undefined }, + ensure: async () => { starts += 1; running = true; return endpoint }, + } + const service = createService() + const options = lifecycleOptions("host:test", lifecycle) + + await Promise.all([service.endpoint(options), service.client(options)]) + assert.equal(starts, 1) + assert.equal(discoveries, 1) + assert.equal(await service.endpoint(), endpoint) + assert.equal(discoveries, 1) + }) + + it("rechecks cached connections after the validation interval and supports invalidation", async () => { + let now = 1_000 + let discoveries = 0 + const service = createService({ now: () => now }) + const lifecycle = { + discover: async () => { discoveries += 1; return endpoint }, + ensure: async () => endpoint, + } + const options = lifecycleOptions("host:cached", lifecycle) + + await service.endpoint(options) + await service.endpoint() + assert.equal(discoveries, 1) + + now += 30_000 + await service.endpoint() + assert.equal(discoveries, 2) + + service.invalidate() + await service.endpoint() + assert.equal(discoveries, 3) + }) + + it("uses one caller deadline for discovery and startup", async () => { + const deadlines: Array = [] + const lifecycle: OpenCodeServiceLifecycle = { + discover: async (deadlineAt) => { deadlines.push(deadlineAt); return undefined }, + ensure: async (deadlineAt) => { deadlines.push(deadlineAt); return endpoint }, + } + const service = createService() + + await service.endpoint(lifecycleOptions("host:deadline", lifecycle), { deadlineAt: 12345 }) + + assert.deepEqual(deadlines, [12345, 12345]) + }) + + it("requires configuration before discovery and rejects identity changes", async () => { + const service = createService() + await assert.rejects(service.endpoint(), /has not been configured/) + await service.endpoint(lifecycleOptions("host:first", lifecycleFor(endpoint))) + await assert.rejects( + service.endpoint(lifecycleOptions("host:second", lifecycleFor(endpoint))), + /identity cannot change/, + ) + await service.shutdown() + assert.equal(await service.endpoint(lifecycleOptions("host:second", lifecycleFor(endpoint))), endpoint) + }) + + it("releases an initial failed discovery or startup identity", async () => { + for (const failure of ["discover", "ensure"] as const) { + const service = createService() + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => { + if (failure === "discover") throw new Error("discovery failed") + return undefined + }, + ensure: async () => { throw new Error("startup failed") }, + } + + await assert.rejects(service.endpoint(lifecycleOptions(`host:${failure}`, lifecycle)), /failed/) + assert.equal(await service.endpoint(lifecycleOptions("host:retry", lifecycleFor(endpoint))), endpoint) + } + }) + + it("retains its identity after a successful connection and transient reconnect failure", async () => { + let now = 1_000 + let discoveries = 0 + const service = createService({ now: () => now }) + const options = lifecycleOptions("host:pinned", { + discover: async () => { + discoveries += 1 + if (discoveries === 1) return endpoint + throw new Error("service unavailable") + }, + ensure: async () => endpoint, + }) + + await service.endpoint(options) + now += 30_000 + await assert.rejects(service.endpoint(), /service unavailable/) + await assert.rejects( + service.endpoint(lifecycleOptions("host:replacement", lifecycleFor(endpoint))), + /identity cannot change/, + ) + }) + + it("keeps the first lifecycle object for equivalent identities", async () => { + let firstCalls = 0 + let replacementCalls = 0 + const service = createService() + await service.endpoint(lifecycleOptions("host:same", { + discover: async () => { firstCalls += 1; return endpoint }, + ensure: async () => endpoint, + })) + await service.endpoint(lifecycleOptions("host:same", { + discover: async () => { replacementCalls += 1; return endpoint }, + ensure: async () => endpoint, + })) + assert.equal(firstCalls, 1) + assert.equal(replacementCalls, 0) + }) + + it("drops only local state on shutdown and fences a late startup", async () => { + let resolveStart!: (value: Endpoint) => void + let discoveries = 0 + const lifecycle: OpenCodeServiceLifecycle = { + discover: async () => { discoveries += 1; return discoveries === 1 ? undefined : endpoint }, + ensure: async () => new Promise((resolve) => { resolveStart = resolve }), + } + const service = createService() + const pending = service.endpoint(lifecycleOptions("host:test", lifecycle)) + await new Promise((resolve) => setImmediate(resolve)) + await service.shutdown() + resolveStart(endpoint) + assert.equal(await pending, endpoint) + assert.equal(await service.endpoint(lifecycleOptions("host:test", lifecycle)), endpoint) + assert.equal(discoveries, 2) + }) + + it("rejects non-loopback endpoints", async () => { + const service = createService() + await assert.rejects(service.endpoint(lifecycleOptions("host:test", lifecycleFor({ + url: "http://192.0.2.1:4321", + auth: undefined, + }))), /must be loopback/) + }) + + it("normalizes wildcard lifecycle endpoints", async () => { + let baseUrl = "" + const service = createService({ + makeClient: (options) => { + baseUrl = options.baseUrl + return {} as OpenCodeClient + }, + }) + const wildcard = { ...endpoint, url: "http://0.0.0.0:4321" } + + assert.equal((await service.endpoint(lifecycleOptions("host:wildcard", lifecycleFor(wildcard)))).url, "http://127.0.0.1:4321/") + assert.equal(baseUrl, "http://127.0.0.1:4321/") + }) + + it("formats auth, validates locations, and evicts through the official debug API", async () => { + let clientHeaders: HeadersInit | undefined + let evicted: unknown + let evictionSignal: AbortSignal | undefined + const service = createService({ + makeClient: (options) => { + clientHeaders = options.headers + return { + location: { get: async () => ({ + directory: "/repo", + workspaceID: "canonical", + project: { id: "project", directory: "/repo", canonical: "/repo" }, + }) }, + debug: { location: { evict: async (input: unknown, request?: { signal?: AbortSignal }) => { + evicted = input + evictionSignal = request?.signal + } } }, + } as unknown as OpenCodeClient + }, + }) + const options = lifecycleOptions("host:test", lifecycleFor(endpoint)) + const signal = new AbortController().signal + + assert.deepEqual(await service.headers(options), { authorization: "Basic proxy" }) + await assert.rejects( + service.validateLocation({ directory: "/repo", workspaceID: "foreign" }, undefined, options), + /does not match/, + ) + await service.evictLocation( + { directory: "/repo", workspaceID: "canonical" }, + { signal }, + options, + ) + + assert.deepEqual(clientHeaders, { authorization: "Basic proxy" }) + assert.deepEqual(evicted, { location: { directory: "/repo", workspace: "canonical" } }) + assert.equal(evictionSignal, signal) + }) +}) + +function lifecycleOptions(identity: string, lifecycle: OpenCodeServiceLifecycle): OpenCodeSharedServiceOptions { + return { kind: "lifecycle", identity, lifecycle } +} + +function lifecycleFor(value: Endpoint): OpenCodeServiceLifecycle { + return { discover: async () => value, ensure: async () => value } +} + +function createService(overrides: Partial = {}) { + return new OpenCodeSharedService({ + headers: () => ({ authorization: "Basic proxy" }), + makeClient: () => ({} as OpenCodeClient), + ...overrides, + }) +} diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts new file mode 100644 index 000000000..199383262 --- /dev/null +++ b/packages/server/src/workspaces/opencode-service.ts @@ -0,0 +1,262 @@ +import { + OpenCode, + type LocationGetOutput, + type LocationRef, + type OpenCodeClient, + type OpenCodeEvent, +} from "@opencode-ai/client" +import { Service, type Endpoint } from "@opencode-ai/client/service" +import { assertLoopbackServiceUrl } from "./service-state" + +type RequestOptions = { signal?: AbortSignal; deadlineAt?: number } +const CONNECTION_RECHECK_INTERVAL_MS = 30_000 + +export interface OpenCodeServiceLifecycle { + discover: (deadlineAt?: number) => Promise + ensure: (deadlineAt?: number) => Promise +} + +export type OpenCodeSharedServiceOptions = { + kind: "lifecycle" + identity: string + lifecycle: OpenCodeServiceLifecycle +} + +interface ServiceConnection { + endpoint: Endpoint + client: OpenCodeClient +} + +export interface OpenCodeSharedServiceDependencies { + headers: typeof Service.headers + makeClient: typeof OpenCode.make + now?: () => number +} + +export class OpenCodeSharedService { + private connection?: Promise + private connected?: ServiceConnection + private healthCheck?: Promise + private serviceOptions?: OpenCodeSharedServiceOptions + private serviceIdentity?: string + private hasValidatedConnection = false + private readonly now: () => number + private connectionValidatedAt?: number + private generation = 0 + + constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { + headers: Service.headers, + makeClient: OpenCode.make, + }) { + this.now = dependencies.now ?? Date.now + } + + endpoint(options?: OpenCodeSharedServiceOptions, requestOptions?: RequestOptions): Promise { + return this.connect(options, requestOptions?.deadlineAt).then(({ endpoint }) => endpoint) + } + + client(options?: OpenCodeSharedServiceOptions, requestOptions?: RequestOptions): Promise { + return this.connect(options, requestOptions?.deadlineAt).then(({ client }) => client) + } + + async headers(options?: OpenCodeSharedServiceOptions, requestOptions?: RequestOptions): Promise> { + return this.dependencies.headers(await this.endpoint(options, requestOptions)) + } + + async validateLocation( + location: LocationRef, + requestOptions?: RequestOptions, + serviceOptions?: OpenCodeSharedServiceOptions, + ): Promise { + const result = await this.withClient(serviceOptions, (client) => client.location.get({ + location: { directory: location.directory }, + }, requestOptions?.signal ? { signal: requestOptions.signal } : undefined), requestOptions) + if ( + !result + || typeof result.directory !== "string" + || typeof result.project?.id !== "string" + || typeof result.project.directory !== "string" + || typeof result.project.canonical !== "string" + ) { + throw new Error("OpenCode returned an invalid location") + } + if (location.workspaceID && result.workspaceID !== location.workspaceID) { + throw new Error("OpenCode location workspace does not match the canonical location") + } + return result + } + + async evictLocation( + location: LocationRef, + requestOptions?: RequestOptions, + serviceOptions?: OpenCodeSharedServiceOptions, + ): Promise { + await this.withClient(serviceOptions, (client) => client.debug.location.evict({ + location: { + directory: location.directory, + ...(location.workspaceID ? { workspace: location.workspaceID } : {}), + }, + }, requestOptions?.signal ? { signal: requestOptions.signal } : undefined), requestOptions) + } + + async subscribe(requestOptions?: RequestOptions, serviceOptions?: OpenCodeSharedServiceOptions): Promise> { + let connection: ServiceConnection | undefined + try { + connection = await this.connect(serviceOptions, requestOptions?.deadlineAt) + const nativeRequestOptions = requestOptions?.signal ? { signal: requestOptions.signal } : undefined + return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection) + } catch (error) { + if (connection) this.invalidateConnection(connection) + throw error + } + } + + async shutdown(): Promise { + this.generation += 1 + this.clear() + this.serviceOptions = undefined + this.serviceIdentity = undefined + this.hasValidatedConnection = false + } + + invalidate(): void { + this.generation += 1 + this.clear() + } + + private connect(options?: OpenCodeSharedServiceOptions, deadlineAt?: number): Promise { + try { + this.pinServiceOptions(options) + } catch (error) { + return Promise.reject(error) + } + if (!this.connected) return this.connection ?? this.startConnection(deadlineAt) + if (this.healthCheck) return this.healthCheck + + const current = this.connected + if ( + this.connectionValidatedAt !== undefined + && this.now() - this.connectionValidatedAt < CONNECTION_RECHECK_INTERVAL_MS + ) { + return Promise.resolve(current) + } + const check = this.lifecycle().discover(deadlineAt).then((endpoint) => { + if (endpoint && this.sameEndpoint(endpoint, current.endpoint)) { + this.connectionValidatedAt = this.now() + return current + } + this.invalidateConnection(current) + return endpoint ? this.createConnection(endpoint, this.generation) : this.startConnection(deadlineAt) + }, () => { + this.invalidateConnection(current) + return this.startConnection(deadlineAt) + }) + const healthCheck = check.finally(() => { + if (this.healthCheck === healthCheck) this.healthCheck = undefined + }) + this.healthCheck = healthCheck + return healthCheck + } + + private startConnection(deadlineAt?: number): Promise { + const generation = this.generation + const lifecycle = this.lifecycle() + const startup = lifecycle.discover(deadlineAt) + .then((endpoint) => endpoint ?? lifecycle.ensure(deadlineAt)) + .then((endpoint) => this.createConnection(endpoint, generation)) + const connection = startup.catch((error) => { + if (this.connection === connection) { + this.clear() + if (!this.hasValidatedConnection) { + this.serviceOptions = undefined + this.serviceIdentity = undefined + } + } + throw error + }) + this.connection = connection + return connection + } + + private createConnection(endpoint: Endpoint, generation: number): ServiceConnection { + const wildcard = new URL(endpoint.url).hostname === "0.0.0.0" + const url = assertLoopbackServiceUrl(endpoint.url) + if (wildcard) endpoint = { ...endpoint, url: url.toString() } + const connection = { + endpoint, + client: this.dependencies.makeClient({ + baseUrl: endpoint.url, + headers: this.dependencies.headers(endpoint), + }), + } + if (generation === this.generation) { + this.hasValidatedConnection = true + this.connected = connection + this.connection = Promise.resolve(connection) + this.connectionValidatedAt = this.now() + } + return connection + } + + private async withClient( + options: OpenCodeSharedServiceOptions | undefined, + run: (client: OpenCodeClient) => Promise, + requestOptions?: RequestOptions, + ): Promise { + let connection: ServiceConnection | undefined + try { + connection = await this.connect(options, requestOptions?.deadlineAt) + return await run(connection.client) + } catch (error) { + if (connection) this.invalidateConnection(connection) + throw error + } + } + + private async *invalidateAfterStream(events: AsyncIterable, connection: ServiceConnection) { + try { + yield* events + } finally { + this.invalidateConnection(connection) + } + } + + private invalidateConnection(connection: ServiceConnection): void { + if (this.connected === connection) this.clear() + } + + private clear(): void { + this.connection = undefined + this.connected = undefined + this.healthCheck = undefined + this.connectionValidatedAt = undefined + } + + private sameEndpoint(left: Endpoint, right: Endpoint): boolean { + return left.url === right.url + && left.auth?.username === right.auth?.username + && left.auth?.password === right.auth?.password + } + + private lifecycle(): OpenCodeServiceLifecycle { + if (!this.serviceOptions) throw new Error("OpenCode service lifecycle has not been configured") + return this.serviceOptions.lifecycle + } + + private pinServiceOptions(options?: OpenCodeSharedServiceOptions): void { + const identity = options ? serviceIdentity(options) : this.serviceIdentity + if (!identity) throw new Error("OpenCode service lifecycle has not been configured") + if (this.serviceIdentity && this.serviceIdentity !== identity) { + throw new Error(`OpenCode service identity cannot change from ${this.serviceIdentity} to ${identity}`) + } + if (this.serviceIdentity) return + this.serviceIdentity = identity + this.serviceOptions = options + } +} + +function serviceIdentity(options: OpenCodeSharedServiceOptions): string { + const identity = options.identity.trim() + if (!identity) throw new Error("OpenCode service lifecycle identity must not be empty") + return `lifecycle:${identity}` +} diff --git a/packages/server/src/workspaces/process-identity.darwin.test.ts b/packages/server/src/workspaces/process-identity.darwin.test.ts deleted file mode 100644 index f657474f1..000000000 --- a/packages/server/src/workspaces/process-identity.darwin.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import assert from "node:assert/strict" -import { spawn, spawnSync } from "node:child_process" -import { once } from "node:events" -import { setTimeout as delay } from "node:timers/promises" -import { it } from "node:test" - -import { - LAUNCH_CLEANUP_TOKEN_ENV, - probePosixProcesses, - signalOwnedPosixProcessGroup, - signalPosixProcesses, -} from "./process-identity" - -const darwinOnly = { skip: process.platform !== "darwin", timeout: 10_000 } - -async function spawnDetachedGroup(cleanupToken?: string) { - const leader = spawn(process.execPath, ["-e", ` - const { spawn } = require("node:child_process") - spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) - process.stdout.write("ready\\n") - setInterval(() => {}, 1000) - `], { - detached: true, - stdio: ["ignore", "pipe", "ignore"], - env: { ...process.env, ...(cleanupToken ? { [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } : {}) }, - }) - assert.ok(leader.pid) - await once(leader.stdout!, "data") - return leader as typeof leader & { pid: number } -} - -async function assertGroupGone(groupId: number): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - const remaining = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId }) - if (remaining.ok && remaining.processes.size === 0) return - await delay(50) - } - assert.fail("owned Darwin process group remained alive after signaling") -} - -it("uses real Darwin ps identities to stop an owned detached process group", darwinOnly, async () => { - const leader = await spawnDetachedGroup() - - try { - const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { - pids: [leader.pid], - groupId: leader.pid, - }) - assert.equal(snapshot.ok, true) - assert.equal(snapshot.ok && snapshot.processes.get(leader.pid)?.groupId, leader.pid) - assert.equal(snapshot.ok && snapshot.processes.size >= 2, true) - - const signaled = signalOwnedPosixProcessGroup(spawnSync, leader.pid, "SIGTERM", 1_000) - assert.equal(signaled.ok && signaled.matched, true) - assert.equal(signaled.ok && signaled.signalSent, true) - await assertGroupGone(leader.pid) - } finally { - try { - process.kill(-leader.pid, "SIGKILL") - } catch { - // The successful path has already removed the process group. - } - } -}) - -it("uses a retained real Darwin identity anchor after the group leader exits", darwinOnly, async () => { - const cleanupToken = "darwin-integration-cleanup-token" - const leader = await spawnDetachedGroup(cleanupToken) - - try { - const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId: leader.pid }) - assert.equal(snapshot.ok, true) - const leaderIdentity = snapshot.ok ? snapshot.processes.get(leader.pid) : undefined - assert.ok(leaderIdentity) - assert.equal(snapshot.ok && snapshot.processes.size >= 2, true) - - leader.kill("SIGTERM") - if (leader.exitCode === null) await once(leader, "exit") - const signaled = signalPosixProcesses(spawnSync, { - leader: leaderIdentity, - groupId: leader.pid, - members: [], - signal: "SIGTERM", - allowLeaderlessGroup: true, - cleanupToken, - }, 1_000, "darwin") - assert.equal(signaled.ok && signaled.matched, true) - assert.equal(signaled.ok && signaled.signalSent, true) - await assertGroupGone(leader.pid) - } finally { - try { - process.kill(-leader.pid, "SIGKILL") - } catch { - // The successful path has already removed the process group. - } - } -}) diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts deleted file mode 100644 index 0efe5add9..000000000 --- a/packages/server/src/workspaces/process-identity.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import assert from "node:assert/strict" -import { spawn as spawnChild, spawnSync, type SpawnSyncReturns } from "node:child_process" -import { once } from "node:events" -import { readFileSync } from "node:fs" -import { describe, it } from "node:test" - -import { - probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, - signalOwnedPosixProcessGroup, signalPosixProcesses, signalWindowsProcesses, - startedNoLaterThan, type ProcessIdentity, -} from "./process-identity" - -type Spawn = typeof import("node:child_process").spawnSync -type Call = { command: string; args: readonly string[]; script: string } -const output = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => - ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) -const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[]) => { - if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "" }) - return output(stdout, status, stderr) -}) as unknown as Spawn -const b64 = (value: string) => Buffer.from(value).toString("base64") -const identity = (startTime = "123456"): ProcessIdentity => - ({ pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) - -describe("process identity probes", () => { - it("parses immutable Linux identities", () => { - const call = {} as Call - const probe = probePosixProcesses(spawn("42|1|42|123456|boot-a|123456\n", call), 25, "linux") - assert.deepEqual([call.command, call.args.includes("codenomad-posix-identity"), call.script.trimEnd().endsWith("exit 0")], ["sh", true, true]) - assert.deepEqual(probe.ok && probe.processes.get(42), identity()) - }) - - it("queries the requested Linux launch group without per-process subprocesses", () => { - const call = {} as Call - const probe = probePosixProcesses(spawn("42|1|42|123456|boot-a|123456\n", call), 25, "linux", { pids: [42], groupId: 42 }) - assert.deepEqual(call.args.slice(-1), ["42"]) - assert.match(call.script, /expected_group=\$stat_group/) - assert.doesNotMatch(call.script, /\b(?:cat|cut|sed|basename|dirname)\b/) - assert.deepEqual(probe.ok && probe.processes.get(42), identity()) - }) - - it("captures real Linux start ticks and launch-group members within the deadline", { skip: process.platform !== "linux" }, async () => { - const child = spawnChild("sh", ["-c", "sleep 5"], { stdio: "ignore" }) - await once(child, "spawn") - try { - const stat = readFileSync(`/proc/${process.pid}/stat`, "utf8") - const expectedStart = stat.slice(stat.lastIndexOf(") ") + 2).split(" ")[19] - const probe = probePosixProcesses(spawnSync, 1_000, "linux", { pids: [process.pid], groupId: process.pid }) - assert.equal(probe.ok && probe.processes.get(process.pid)?.startTime, expectedStart) - assert.equal(probe.ok && probe.processes.has(child.pid!), true) - } finally { - if (child.exitCode === null && child.signalCode === null) { - const exited = once(child, "exit") - child.kill() - await exited - } - } - }) - - it("uses one delimiter-safe process-table query on portable POSIX", () => { - const call = {} as Call - const command = "/opt/opencode 'pipe|value'\t\"quoted\" café" - const start = "Fri Jul 10 12:34:56 2026" - const probe = probePosixProcesses(spawn(`42 1 42 ${start} ${command}\n`, call), 25, "darwin") - assert.deepEqual([call.command, call.args], ["ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="]]) - assert.equal(probe.ok && probe.processes.get(42)?.startTime, `${start}\t${command}`) - }) - - it("ignores malformed unrelated portable rows but fails for a malformed requested identity", () => { - const start = "Fri Jul 10 12:34:56 2026" - const unrelated = `77 1 77 malformed identity\n42 1 42 ${start} opencode\n` - const filtered = probePosixProcesses(spawn(unrelated), 25, "darwin", { pids: [42], groupId: 42 }) - assert.equal(filtered.ok && filtered.processes.get(42)?.startTime, `${start}\topencode`) - assert.equal(probePosixProcesses(spawn(unrelated), 25, "darwin", { pids: [77] }).ok, false) - }) - - it("preserves delimiter-heavy identities through POSIX escalation and rescan", () => { - const command = "/opt/opencode pipe|value\nnext\t'quoted'" - const row = `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\nCODENOMAD_RESULT|1||1\n` - const expected = `Fri Jul 10 12:34:56 2026\t${command}` - const guardedCall = {} as Call - const guarded = signalPosixProcesses(spawn(row, guardedCall), { leader: identity(expected), groupId: 42, members: [identity(expected)], signal: "SIGKILL" }, 25, "darwin") - const call = {} as Call - const owned = signalOwnedPosixProcessGroup(spawn(row, call), 42, "SIGTERM", 25) - assert.deepEqual([guarded.ok, guarded.ok && guarded.signaled[0]?.startTime], [true, expected]) - assert.deepEqual([owned.ok && owned.matched, owned.ok && owned.signaled[0]?.startTime], [true, expected]) - assert.ok(call.script.indexOf('kill "-$requested_signal"') < call.script.lastIndexOf("for current_pid")) - assert.match(call.script, /group_pids\(\).*pid=,pgid=/) - assert.doesNotMatch(call.script, /ps -eo pid=/) - assert.match(guardedCall.script, /test "\$current_group" = "\$expected_group"/) - }) - - it("marks a retained portable group request for leaderless guarded cleanup", () => { - const call = {} as Call - const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|1||1\n", call), { - leader: identity("gone"), groupId: 42, members: [identity("member")], signal: "SIGTERM", - allowLeaderlessGroup: true, cleanupToken: "secret-token", - }, 25, "darwin") - assert.equal(guarded.ok, true) - assert.equal(call.args[7], "1") - assert.equal(call.args[8], "secret-token") - assert.match(call.script, /anchor=0/) - assert.match(call.script, /has_cleanup_token/) - }) - - it("queries WSL identities in the selected distro", () => { - const call = {} as Call - const probe = probeWslProcesses(spawn("99|1|99|123456|boot-a|123456\n101|99|99|123460|boot-a|123460\n", call), "Ubuntu Test", 25) - assert.deepEqual([call.command, call.args.slice(0, 4), call.args.includes("codenomad-wsl-identity"), call.script.trimEnd().endsWith("exit 0")], - ["wsl.exe", ["--distribution", "Ubuntu Test", "--exec", "sh"], true, true]) - assert.equal(probe.ok && probe.processes.get(101)?.startTime, "123460") - }) - - it("uses Windows CIM CreationDate as the immutable identity", () => { - const call = {} as Call - const probe = probeWindowsProcesses(spawn("4242|100|0|20260710123456.123456+000||20260710123456\n", call), 25) - assert.match(call.script, /Get-CimInstance Win32_Process/) - assert.match(call.script, /ProcessId -gt 0/) - assert.equal(probe.ok && probe.processes.get(4242)?.startTime, "20260710123456.123456+000") - }) - - it("rejects PID reuse and invalid start ordering", () => { - const original = identity("9") - for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], [{ ...original, pid: 43 }, false]] as const) - assert.equal(sameProcess(original, candidate), expected) - assert.equal(startedNoLaterThan(original, "10"), true) - assert.equal(startedNoLaterThan({ ...original, startOrder: "11" }, "10"), false) - assert.equal(startedNoLaterThan({ ...original, startOrder: "Fri Jul 10" }, "10"), false) - }) - - it("returns a POSIX mismatch without a second signal command", () => { - const call = {} as Call - const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|0||0\n", call), { leader: identity(), groupId: 42, members: [identity()], signal: "SIGTERM" }, 25, "linux") - assert.deepEqual(guarded, { ok: true, matched: false, signalSent: false, signaled: [] }) - assert.deepEqual([call.command, call.args[2], call.args.includes("123456")], ["sh", "codenomad-guarded-signal", true]) - assert.ok(call.script.indexOf('kill "-$requested_signal"') < call.script.indexOf("uptime=$(cut")) - }) - - it("selects and terminates Windows identities in one guarded CIM invocation", () => { - const call = {} as Call - const guarded = signalWindowsProcesses(spawn("CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_RESULT|1||1\n", call), { leader: identity("created"), groupId: 42, members: [identity("created")], signal: "SIGKILL" }, 25) - assert.equal(guarded.ok && guarded.matched, true) - assert.equal(call.command, "powershell.exe") - assert.match(call.script, /CreationDate.*Invoke-CimMethod -InputObject/s) - assert.equal(call.script.match(/foreach \(\$process in \$selected\)/g)?.length, 2) - assert.ok(call.script.indexOf("CODENOMAD_TARGET|") < call.script.indexOf("Invoke-CimMethod")) - assert.doesNotMatch(call.script, /taskkill/i) - }) - - it("retains observed Windows identities after partial termination failure", () => { - const rows = "CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_TARGET|4243|4242|0|descendant||100" - const guarded = signalWindowsProcesses(spawn(rows, undefined, 1, "termination failed"), { leader: identity("created"), groupId: 42, members: [identity("created")], signal: "SIGTERM" }, 25) - assert.equal(guarded.ok, false) - assert.deepEqual(!guarded.ok && guarded.observed?.map(({ pid }) => pid), [4242, 4243]) - }) - - it("fails conservatively for command, malformed, and empty probe output", () => { - assert.deepEqual(probeWindowsProcesses(spawn("", undefined, 1, "CIM unavailable"), 25), { ok: false, error: "CIM unavailable" }) - assert.deepEqual(probePosixProcesses(spawn("", undefined, 20, "proc unavailable"), 25, "linux"), { ok: false, error: "proc unavailable" }) - for (const probe of [probePosixProcesses(spawn("42 malformed process row\n"), 25, "darwin"), probeWslProcesses(spawn("not an identity"), "Ubuntu", 25)]) assert.equal(probe.ok, false) - }) -}) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts deleted file mode 100644 index 2d9b2f070..000000000 --- a/packages/server/src/workspaces/process-identity.ts +++ /dev/null @@ -1,561 +0,0 @@ -import type { SpawnSyncReturns, spawnSync } from "node:child_process" - -export interface ProcessIdentity { - pid: number - parentPid: number - groupId: number - startTime: string - bootId?: string - startOrder?: string -} - -export type ProcessSnapshot = - | { ok: true; processes: Map } - | { ok: false; error: string } - -export interface GuardedSignalRequest { - leader?: ProcessIdentity - groupId?: number - members: ProcessIdentity[] - signal: NodeJS.Signals - allowLeaderlessGroup?: boolean - cleanupToken?: string -} - -export interface PosixProcessFilter { - pids?: readonly number[] - groupId?: number -} - -export type GuardedSignalResult = - | { ok: true; matched: boolean; signalSent: boolean; signaled: ProcessIdentity[]; cutoff?: string } - | { ok: false; error: string; observed?: ProcessIdentity[] } - -export type TokenSignalResult = { ok: boolean; signalSent: boolean; targets: ProcessIdentity[]; error?: string } - -export const LAUNCH_CLEANUP_TOKEN_ENV = "CODENOMAD_LAUNCH_CLEANUP_TOKEN" - -type SpawnCommand = typeof spawnSync -const SHELL_DOLLAR = "$" - -const LINUX_IDENTITY_FUNCTIONS = String.raw` -IFS= read -r boot 2>/dev/null < /proc/sys/kernel/random/boot_id || exit 20 -read_stat() { - line= - while IFS= read -r chunk || test -n "$chunk"; do line=$line$chunk; done 2>/dev/null < "/proc/$1/stat" - test -n "$line" || return 1 - stat_pid=$1; rest=${SHELL_DOLLAR}{line##*) }; set -- $rest - test "$#" -ge 20 || return 1 - stat_ppid=$2; stat_group=$3; shift 19; stat_start=$1 -} -emit_linux() { - test -n "$1" && printf '%s|' "$1" - printf '%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" -} -` - -const LINUX_SNAPSHOT_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} -for stat in /proc/[0-9]*/stat; do - directory=${SHELL_DOLLAR}{stat%/stat}; pid=${SHELL_DOLLAR}{directory##*/}; read_stat "$pid" && emit_linux "" -done -exit 0 -` - -const LINUX_LAUNCH_GROUP_SNAPSHOT_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} -leader_pid=$1; read_stat "$leader_pid" || exit 22; expected_group=$stat_group; emit_linux "" -for stat in /proc/[0-9]*/stat; do - directory=${SHELL_DOLLAR}{stat%/stat}; pid=${SHELL_DOLLAR}{directory##*/}; test "$pid" = "$leader_pid" && continue - read_stat "$pid" && test "$stat_group" = "$expected_group" && emit_linux "" -done -exit 0 -` - -const LINUX_GUARDED_SIGNAL_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} -leader_pid=$1; leader_start=$2; leader_boot=$3; expected_group=$4; requested_signal=$5 -shift 5; matched=0; cutoff=; signal_sent=0 -if read_stat "$leader_pid" && test "$boot" = "$leader_boot" && test "$stat_start" = "$leader_start" && test "$stat_group" = "$expected_group"; then - matched=1 - for stat in /proc/[0-9]*/stat; do - directory=${SHELL_DOLLAR}{stat%/stat}; candidate=${SHELL_DOLLAR}{directory##*/}; read_stat "$candidate" && test "$stat_group" = "$expected_group" && emit_linux CODENOMAD_TARGET - done - if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then - signal_sent=1 - hz=$(getconf CLK_TCK 2>/dev/null) || exit 21 - uptime=$(cut -d' ' -f1 /proc/uptime 2>/dev/null) || exit 21 - cutoff=$(awk -v uptime="$uptime" -v hz="$hz" 'BEGIN { printf "%.0f", uptime * hz }') - fi -else - while test "$#" -ge 3; do - expected_pid=$1; expected_start=$2; expected_boot=$3; shift 3 - if read_stat "$expected_pid" && test "$boot" = "$expected_boot" && test "$stat_start" = "$expected_start"; then - emit_linux CODENOMAD_TARGET - if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi - fi - done -fi -printf 'CODENOMAD_RESULT|%s|%s|%s\n' "$matched" "$cutoff" "$signal_sent" -` - -const POSIX_IDENTITY_FUNCTIONS = String.raw` -LC_ALL=C; export LC_ALL; set -f -encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } -read_identity() { - current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 - current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 - test "$current_meta" = "$current_verify" || return 1 - set -- $current_meta; test "$#" -ge 7 || return 1 - current_ppid=$1; current_group=$2; shift 2; current_start="$1 $2 $3 $4 $5" - shift 5; current_command="$*"; test -n "$current_command" || return 1 - current_identity=$(printf '%s\t%s' "$current_start" "$current_command") -} -emit_target() { - printf 'CODENOMAD_TARGET_B64|%s|%s|%s|' "$current_pid" "$current_ppid" "$current_group" - encode "$current_start"; printf '|'; encode "$current_command"; printf '\n' -} -group_pids() { ps -axo pid=,pgid= 2>/dev/null | awk -v group="$1" '$2 == group { print $1 }'; } -has_cleanup_token() { - test -n "$cleanup_token" || return 1 - ps eww -p "$1" -o command= 2>/dev/null | tr ' ' '\n' | grep -Fqx -- "${LAUNCH_CLEANUP_TOKEN_ENV}=$cleanup_token" -} -` - -const LINUX_TOKEN_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} -key=$1; expected=$2; requested_signal=$3 -matches_token() { test -r "/proc/$1/environ" && tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected"; } -signal_sent=0; passes=1; test -n "$requested_signal" && passes=3 -pass=0 -while test "$pass" -lt "$passes"; do - pass=$((pass + 1)) - for environ in /proc/[0-9]*/environ; do - directory=${SHELL_DOLLAR}{environ%/environ}; pid=${SHELL_DOLLAR}{directory##*/} - if matches_token "$pid" && read_stat "$pid"; then - test -n "$requested_signal" && prefix=CODENOMAD_TARGET || prefix=CODENOMAD_PROCESS - emit_linux "$prefix" - if test -n "$requested_signal" && matches_token "$pid" && read_stat "$pid" && kill "-$requested_signal" "$pid" 2>/dev/null; then signal_sent=1; fi - fi - done -done -if test -n "$requested_signal"; then printf 'CODENOMAD_RESULT|%s\n' "$signal_sent"; fi -exit 0 -` - -const POSIX_GUARDED_SIGNAL_SCRIPT = String.raw`${POSIX_IDENTITY_FUNCTIONS} -leader_pid=$1; leader_start=$2; expected_group=$3; requested_signal=$4; allow_leaderless=$5; cleanup_token=$6; shift 6 -matched=0; signal_sent=0 -if read_identity "$leader_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$leader_start"; then - matched=1 - for current_pid in $(group_pids "$expected_group"); do - read_identity "$current_pid" && test "$current_group" = "$expected_group" && emit_target - done - if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi -elif test "$allow_leaderless" = 1 && ! read_identity "$expected_group"; then - anchor=0 - while test "$#" -ge 2; do - expected_pid=$1; expected_start=$2; shift 2; current_pid=$expected_pid - if read_identity "$expected_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$expected_start"; then anchor=1; fi - done - if test "$anchor" = 0; then - for current_pid in $(group_pids "$expected_group"); do - if has_cleanup_token "$current_pid" && read_identity "$current_pid" && test "$current_group" = "$expected_group"; then anchor=1; break; fi - done - fi - if test "$anchor" = 1; then - matched=1 - for current_pid in $(group_pids "$expected_group"); do - read_identity "$current_pid" && test "$current_group" = "$expected_group" && emit_target - done - if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi - fi -else - while test "$#" -ge 2; do - expected_pid=$1; expected_start=$2; shift 2; current_pid=$expected_pid - if read_identity "$expected_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$expected_start"; then - emit_target; if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi - fi - done -fi -printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" -` - -const POSIX_OWNED_GROUP_SIGNAL_SCRIPT = String.raw`${POSIX_IDENTITY_FUNCTIONS} -root_pid=$1; requested_signal=$2; matched=0; signal_sent=0 -if read_identity "$root_pid" && test "$current_group" = "$root_pid"; then - matched=1 - for current_pid in $(group_pids "$root_pid"); do - read_identity "$current_pid" && test "$current_group" = "$root_pid" && emit_target - done - if kill "-$requested_signal" -- "-$root_pid" 2>/dev/null; then signal_sent=1; fi - for current_pid in $(group_pids "$root_pid"); do - read_identity "$current_pid" && test "$current_group" = "$root_pid" && emit_target - done -fi -printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" -` - -const commandError = (result: SpawnSyncReturns): string => - result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` - -function parseDelimitedSnapshot(output: string, requireBootId = false): Map | null { - const processes = new Map() - for (const line of output.split(/\r?\n/)) { - if (!line) continue - const fields = line.split("|") - if (fields.length !== 6) return null - const [pidText, parentPidText, groupIdText, startTime = "", bootId = "", startOrder = ""] = fields - const pid = Number.parseInt(pidText ?? "", 10) - const parentPid = Number.parseInt(parentPidText ?? "", 10) - const groupId = Number.parseInt(groupIdText ?? "", 10) - if (!/^\d+$/.test(pidText ?? "") || !/^\d+$/.test(parentPidText ?? "") || !/^\d+$/.test(groupIdText ?? "") || - !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !startTime || (requireBootId && !bootId)) return null - processes.set(pid, { pid, parentPid, groupId: Number.isInteger(groupId) && groupId > 0 ? groupId : pid, startTime, - ...(bootId ? { bootId } : {}), ...(startOrder ? { startOrder } : {}) }) - } - return processes -} - -function decodeBase64Field(value: string): string | null { - if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - return null - } - try { - const bytes = Buffer.from(value, "base64") - if (bytes.toString("base64") !== value) return null - return new TextDecoder("utf-8", { fatal: true }).decode(bytes) - } catch { - return null - } -} - -function parseBase64Snapshot(output: string, prefix = "CODENOMAD_B64|"): Map | null { - const processes = new Map() - for (const line of output.split(/\r?\n/)) { - if (!line) continue - if (!line.startsWith(prefix)) return null - const fields = line.slice(prefix.length).split("|") - if (fields.length !== 5) return null - const [pidText = "", parentPidText = "", groupIdText = "", startEncoded = "", commandEncoded = ""] = fields - if (!/^\d+$/.test(pidText) || !/^\d+$/.test(parentPidText) || !/^\d+$/.test(groupIdText)) return null - const pid = Number.parseInt(pidText, 10) - const parentPid = Number.parseInt(parentPidText, 10) - const groupId = Number.parseInt(groupIdText, 10) - const start = decodeBase64Field(startEncoded) - const command = decodeBase64Field(commandEncoded) - if (pid <= 0 || parentPid < 0 || groupId <= 0 || start === null || command === null) return null - processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) - } - return processes -} - -function parsePortablePosixSnapshot(output: string, filter?: PosixProcessFilter): Map | null { - const processes = new Map() - const requestedPids = filter?.pids ? new Set(filter.pids) : undefined - for (const line of output.split(/\r?\n/)) { - if (!line.trim()) continue - const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/) - if (!match) { - const numeric = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+/) - if (!filter || !numeric || requestedPids?.has(Number(numeric[1])) || Number(numeric[3]) === filter.groupId) return null - continue - } - const [, pidText = "", parentPidText = "", groupIdText = "", start = "", command = ""] = match - const pid = Number.parseInt(pidText, 10) - const parentPid = Number.parseInt(parentPidText, 10) - const groupId = Number.parseInt(groupIdText, 10) - if (pid <= 0 || parentPid < 0 || groupId <= 0) return null - if (filter && !requestedPids?.has(pid) && groupId !== filter.groupId) continue - processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) - } - return processes -} - -function querySnapshot( - run: () => SpawnSyncReturns, - parse: (output: string) => Map | null, - options: { allowEmpty?: boolean; malformedError?: string; redact?: (error: string) => string } = {}, -): ProcessSnapshot { - const sanitize = options.redact ?? ((error: string) => error) - try { - const result = run() - if (result.status !== 0) return { ok: false, error: sanitize(commandError(result)) } - const processes = parse(String(result.stdout ?? "")) - if (processes && (options.allowEmpty || processes.size > 0)) return { ok: true, processes } - return { ok: false, error: options.malformedError ?? "process identity query returned no parseable processes" } - } catch (error) { - return { ok: false, error: sanitize(error instanceof Error ? error.message : String(error)) } - } -} - -function parsePrefixedSnapshot(output: string, prefix: string): Map | null { - const records: string[] = [] - for (const line of output.split(/\r?\n/)) { - if (!line) continue - if (!line.startsWith(prefix)) return null - records.push(line.slice(prefix.length)) - } - return parseDelimitedSnapshot(records.join("\n"), true) -} - -function parseGuardedResult(result: SpawnSyncReturns): GuardedSignalResult { - const signaled = new Map() - const failure = (error: string): GuardedSignalResult => ({ ok: false, error, - ...(signaled.size > 0 ? { observed: Array.from(signaled.values()) } : {}) }) - let matched: boolean | undefined - let signalSent = false - let cutoff: string | undefined - for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (line.startsWith("CODENOMAD_TARGET|") || line.startsWith("CODENOMAD_TARGET_B64|")) { - const parsed = line.startsWith("CODENOMAD_TARGET_B64|") - ? parseBase64Snapshot(line, "CODENOMAD_TARGET_B64|") - : parseDelimitedSnapshot(line.slice("CODENOMAD_TARGET|".length)) - if (!parsed) return failure("guarded signal command returned a malformed target record") - for (const identity of parsed.values()) signaled.set(identity.pid, identity) - continue - } - if (line.startsWith("CODENOMAD_RESULT|")) { - const fields = line.split("|") - if (fields.length !== 4 || !/^[01]$/.test(fields[1] ?? "") || !/^[01]$/.test(fields[3] ?? "")) { - return failure("guarded signal command returned a malformed result record") - } - const [, matchedText, cutoffText, signalSentText] = fields - matched = matchedText === "1" - cutoff = cutoffText || undefined - signalSent = signalSentText === "1" - continue - } - if (line) return failure("guarded signal command returned unexpected output") - } - if (result.status !== 0) return failure(commandError(result)) - return matched === undefined - ? failure("guarded signal command returned no structured result") - : { ok: true, matched, signalSent, signaled: Array.from(signaled.values()), ...(cutoff ? { cutoff } : {}) } -} - -function runGuardedCommand(run: () => SpawnSyncReturns): GuardedSignalResult { - try { - return parseGuardedResult(run()) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } -} - -const signalName = (signal: NodeJS.Signals): "TERM" | "KILL" => signal === "SIGKILL" ? "KILL" : "TERM" - -function runLinuxScript(spawnCommand: SpawnCommand, script: string, args: string[], timeoutMs: number, - label: string, distro?: string): SpawnSyncReturns { - return distro - ? spawnCommand("wsl.exe", ["--distribution", distro, "--exec", "sh", "-c", script, label, ...args], { encoding: "utf8", timeout: timeoutMs }) - : spawnCommand("sh", ["-c", script, label, ...args], { encoding: "utf8", timeout: timeoutMs }) -} - -const redactToken = (value: string, token: string): string => value.split(token).join("[REDACTED]") - -function shellGuardArgs(request: GuardedSignalRequest, linux: boolean): string[] { - const leader = request.leader - const args = linux - ? [String(leader?.pid ?? 0), leader?.startTime ?? "", leader?.bootId ?? "", String(request.groupId ?? 0), signalName(request.signal)] - : [String(leader?.pid ?? 0), leader?.startTime ?? "", String(request.groupId ?? 0), signalName(request.signal), - request.allowLeaderlessGroup ? "1" : "0", request.cleanupToken ?? ""] - for (const member of request.members) { - args.push(String(member.pid), member.startTime) - if (linux) args.push(member.bootId ?? "") - } - return args -} - -const quotePowerShell = (value: string): string => `'${value.replace(/'/g, "''")}'` - -function buildWindowsGuardedScript(request: GuardedSignalRequest): string { - const leaderPid = request.leader?.pid ?? 0 - const leaderStart = quotePowerShell(request.leader?.startTime ?? "") - const expected = request.members.map( - (identity) => `@{ Pid = ${identity.pid}; Start = ${quotePowerShell(identity.startTime)} }`, - ).join(", ") - return [ - "$ErrorActionPreference = 'Stop'", - `$leaderPid = ${leaderPid}`, - `$leaderStart = ${leaderStart}`, - `$expected = @(${expected})`, - "function Get-CodeNomadStart($process) { return ([datetime]$process.CreationDate).ToUniversalTime().Ticks.ToString() }", - "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", - "$byPid = @{}; $all | ForEach-Object { $byPid[[int]$_.ProcessId] = $_ }", - "$leader = $byPid[$leaderPid]", - "$matched = $null -ne $leader -and (Get-CodeNomadStart $leader) -eq $leaderStart", - "$selected = @()", - "if ($matched) {", - " $ids = @($leaderPid); $changed = $true", - " while ($changed) { $changed = $false; foreach ($process in $all) { if ($ids -contains [int]$process.ParentProcessId -and $ids -notcontains [int]$process.ProcessId) { $ids += [int]$process.ProcessId; $changed = $true } } }", - " $selected = @($all | Where-Object { $ids -contains [int]$_.ProcessId } | Sort-Object ProcessId -Descending)", - "} else {", - " foreach ($item in $expected) { $process = $byPid[[int]$item.Pid]; if ($null -ne $process -and (Get-CodeNomadStart $process) -eq [string]$item.Start) { $selected += $process } }", - "}", - "foreach ($process in $selected) {", - " $start = Get-CodeNomadStart $process", - " '{0}|{1}|0|{2}||{2}' -f [int]$process.ProcessId, [int]$process.ParentProcessId, $start | ForEach-Object { 'CODENOMAD_TARGET|' + $_ }", - "}", - "foreach ($process in $selected) {", - " Invoke-CimMethod -InputObject $process -MethodName Terminate -Arguments @{ Reason = 1 } -ErrorAction Stop | Out-Null", - "}", - "'CODENOMAD_RESULT|' + ($(if ($matched) { '1' } else { '0' })) + '||' + ($(if ($selected.Count -gt 0) { '1' } else { '0' }))", - ].join("; ") -} - -export function sameProcess(left: ProcessIdentity | undefined, right: ProcessIdentity | undefined): boolean { - return Boolean(left && right && left.pid === right.pid && left.startTime === right.startTime && - (!left.bootId || !right.bootId || left.bootId === right.bootId)) -} - -export function startedNoLaterThan(identity: ProcessIdentity, cutoff: string): boolean { - const startOrder = identity.startOrder ?? identity.startTime - if (!/^\d+$/.test(startOrder) || !/^\d+$/.test(cutoff)) return false - try { - return BigInt(startOrder) <= BigInt(cutoff) - } catch { - return false - } -} - -export function descendantsOf(processes: Map, rootPid: number): ProcessIdentity[] { - const descendants: ProcessIdentity[] = [] - const pending = [rootPid] - const seen = new Set(pending) - while (pending.length > 0) { - const parentPid = pending.shift()! - for (const process of processes.values()) { - if (process.parentPid !== parentPid || seen.has(process.pid)) continue - seen.add(process.pid) - pending.push(process.pid) - descendants.push(process) - } - } - return descendants -} - -export function probePosixProcesses(spawnCommand: SpawnCommand, timeoutMs: number, - platform: NodeJS.Platform = process.platform, filter?: PosixProcessFilter): ProcessSnapshot { - if (platform === "linux") { - const pids = filter?.pids?.filter((pid) => Number.isInteger(pid) && pid > 0).map(String) ?? [] - const launchGroupProbe = pids.length === 1 && filter?.groupId === Number(pids[0]) - return querySnapshot( - () => runLinuxScript(spawnCommand, launchGroupProbe ? LINUX_LAUNCH_GROUP_SNAPSHOT_SCRIPT : LINUX_SNAPSHOT_SCRIPT, - launchGroupProbe ? pids : [], timeoutMs, "codenomad-posix-identity"), - (output) => parseDelimitedSnapshot(output, true), - { allowEmpty: Boolean(filter) }, - ) - } - // POSIX has no portable pidfd/start ticks; collect one coherent table instead of probing every PID. - return querySnapshot( - () => spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="], { - encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C" }, - }), - (output) => parsePortablePosixSnapshot(output, filter), - { allowEmpty: Boolean(filter) }, - ) -} - -export function probeWindowsProcesses(spawnCommand: SpawnCommand, timeoutMs: number): ProcessSnapshot { - const script = [ - "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", - "$all | Where-Object { [int]$_.ProcessId -gt 0 } | ForEach-Object { $start = ([datetime]$_.CreationDate).ToUniversalTime().Ticks.ToString(); '{0}|{1}|0|{2}||{2}' -f [int]$_.ProcessId, [int]$_.ParentProcessId, $start }", - ].join("; ") - return querySnapshot( - () => spawnCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: timeoutMs }), - parseDelimitedSnapshot, - ) -} - -export function probeWslProcesses(spawnCommand: SpawnCommand, distro: string, timeoutMs: number): ProcessSnapshot { - return querySnapshot( - () => runLinuxScript(spawnCommand, LINUX_SNAPSHOT_SCRIPT, [], timeoutMs, "codenomad-wsl-identity", distro), - (output) => parseDelimitedSnapshot(output, true), - ) -} - -export function signalPosixProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, - timeoutMs: number, platform: NodeJS.Platform): GuardedSignalResult { - const linux = platform === "linux" - return runGuardedCommand(() => spawnCommand( - "sh", - ["-c", linux ? LINUX_GUARDED_SIGNAL_SCRIPT : POSIX_GUARDED_SIGNAL_SCRIPT, "codenomad-guarded-signal", ...shellGuardArgs(request, linux)], - { encoding: "utf8", timeout: timeoutMs }, - )) -} - -export function signalOwnedPosixProcessGroup(spawnCommand: SpawnCommand, rootPid: number, - signal: NodeJS.Signals, timeoutMs: number): GuardedSignalResult { - return runGuardedCommand(() => spawnCommand( - "sh", - ["-c", POSIX_OWNED_GROUP_SIGNAL_SCRIPT, "codenomad-owned-group-cleanup", String(rootPid), signalName(signal)], - { encoding: "utf8", timeout: timeoutMs }, - )) -} - -export function signalWslProcesses(spawnCommand: SpawnCommand, distro: string, - request: GuardedSignalRequest, timeoutMs: number): GuardedSignalResult { - return runGuardedCommand(() => runLinuxScript( - spawnCommand, - LINUX_GUARDED_SIGNAL_SCRIPT, - shellGuardArgs(request, true), - timeoutMs, - "codenomad-wsl-guarded-signal", - distro, - )) -} - -export function signalWindowsProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, - timeoutMs: number): GuardedSignalResult { - return runGuardedCommand(() => spawnCommand( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", buildWindowsGuardedScript(request)], - { encoding: "utf8", timeout: timeoutMs }, - )) -} - -export function probeLaunchCleanupToken(spawnCommand: SpawnCommand, token: string, - timeoutMs: number, distro?: string): ProcessSnapshot { - return querySnapshot( - () => runLinuxScript( - spawnCommand, - LINUX_TOKEN_SCRIPT, - [LAUNCH_CLEANUP_TOKEN_ENV, token, ""], - timeoutMs, - "codenomad-token-cleanup", - distro, - ), - (output) => parsePrefixedSnapshot(output, "CODENOMAD_PROCESS|"), - { - allowEmpty: true, - malformedError: "launch cleanup probe returned malformed or unexpected output", - redact: (error) => redactToken(error, token), - }, - ) -} - -export function signalLaunchCleanupToken(spawnCommand: SpawnCommand, token: string, - signal: NodeJS.Signals, timeoutMs: number, distro?: string): TokenSignalResult { - const failed = (error: string): TokenSignalResult => ({ ok: false, signalSent: false, targets: [], error }) - try { - const result = runLinuxScript( - spawnCommand, - LINUX_TOKEN_SCRIPT, - [LAUNCH_CLEANUP_TOKEN_ENV, token, signalName(signal)], - timeoutMs, - "codenomad-token-cleanup", - distro, - ) - if (result.status !== 0) return failed(redactToken(commandError(result), token)) - const lines = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean) - const resultLines = lines.filter((line) => line.startsWith("CODENOMAD_RESULT|")) - if (resultLines.length !== 1 || !/^CODENOMAD_RESULT\|[01]$/.test(resultLines[0] ?? "")) { - return failed("launch cleanup signal returned no valid structured result") - } - const targets = parsePrefixedSnapshot( - lines.filter((line) => !line.startsWith("CODENOMAD_RESULT|")).join("\n"), - "CODENOMAD_TARGET|", - ) - return targets - ? { ok: true, signalSent: resultLines[0]!.endsWith("1"), targets: Array.from(targets.values()) } - : failed("launch cleanup signal returned malformed or unexpected output") - } catch (error) { - return failed(redactToken(error instanceof Error ? error.message : String(error), token)) - } -} diff --git a/packages/server/src/workspaces/runtime.test.ts b/packages/server/src/workspaces/runtime.test.ts deleted file mode 100644 index 54fb92a9e..000000000 --- a/packages/server/src/workspaces/runtime.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import assert from "node:assert/strict" -import type { ChildProcess, SpawnSyncReturns } from "node:child_process" -import { EventEmitter } from "node:events" -import { PassThrough } from "node:stream" -import { describe, it } from "node:test" -import pino from "pino" - -import { EventBus } from "../events/bus" -import { WorkspaceRuntime, WorkspaceRuntimeIdentityCaptureError, WorkspaceStopTimeoutError, - WorkspaceWindowsTreeCleanupIncompleteError, type WorkspaceRuntimeOptions } from "./runtime" -type Timer = ReturnType -type Command = typeof import("node:child_process").spawnSync -type Call = { command: string; args: readonly string[] } -class ManualTimers { - private id = 0 - private pending = new Map void; delay: number }>() - set = (callback: () => void, delay: number) => { const id = ++this.id; this.pending.set(id, { callback, delay }); return id as unknown as Timer } - clear = (timer: Timer) => this.pending.delete(timer as unknown as number) - run(): void { - const next = [...this.pending].sort((a, b) => a[1].delay - b[1].delay || a[0] - b[0])[0] - assert.ok(next, "expected a pending timer") - this.pending.delete(next[0]) - next[1].callback() - } -} -class FakeChild extends EventEmitter { - stdout = new PassThrough() - stderr = new PassThrough() - exitCode: number | null = null - signalCode: NodeJS.Signals | null = null - signals: NodeJS.Signals[] = [] - constructor(readonly pid: number | undefined = 4242) { super() } - kill(signal: NodeJS.Signals = "SIGTERM") { this.signals.push(signal); return true } - exit(code: number | null = 0, signal: NodeJS.Signals | null = null) { this.exitCode = code; this.signalCode = signal; this.emit("exit", code, signal) } -} -const result = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => - ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) -const posix = (rows: Array<[number, number, number, string]>, boot = "boot-a") => - rows.map(([pid, ppid, pgid, start]) => `${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`).join("\n") -const portable = (rows: Array<[number, number, number, string, string]>) => - rows.map(([pid, ppid, pgid, start, command]) => `${pid} ${ppid} ${pgid} ${start} ${command}`).join("\n") -const windows = (rows: Array<[number, number, string]>) => - rows.map(([pid, ppid, start], i) => `${pid}|${ppid}|0|${start}||${100 + i}`).join("\n") -const guarded = (matched: boolean, rows: Array<[number, number, number, string]>, boot = "boot-a") => [ - ...rows.map(([pid, ppid, pgid, start]) => `CODENOMAD_TARGET|${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`), - `CODENOMAD_RESULT|${matched ? "1" : "0"}|200|${rows.length ? "1" : "0"}`, -].join("\n") -const token = (rows: Array<[number, number, number, string]>, signal: boolean, boot = "boot-a") => [ - ...rows.map(([pid, ppid, pgid, start]) => `${signal ? "CODENOMAD_TARGET" : "CODENOMAD_PROCESS"}|${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`), - ...(signal ? [`CODENOMAD_RESULT|${rows.length ? "1" : "0"}`] : []), -].join("\n") -const isToken = (args: readonly string[]) => args.includes("codenomad-token-cleanup") -const isSignal = (args: readonly string[]) => isToken(args) && (args.includes("TERM") || args.includes("KILL")) -const isGuarded = (args: readonly string[]) => !isToken(args) && args.some((arg) => arg.includes("guarded-signal") || arg.includes("CODENOMAD_RESULT")) -async function harness(options: WorkspaceRuntimeOptions & { binary?: string; output?: string; report?: boolean } = {}) { - const child = new FakeChild() - const timers = new ManualTimers() - const calls: Call[] = [] - const platform = options.platform ?? "linux" - const command = options.spawnSync ?? ((command: string, args: readonly string[]) => { - calls.push({ command, args: [...args] }) - const alive = child.exitCode === null && child.signalCode === null - if (isToken(args)) return result(token(alive ? [[4242, 1, 4242, "100"]] : [], isSignal(args))) - if (isGuarded(args)) return result(platform === "win32" - ? "CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1" - : guarded(true, [[4242, 1, 4242, "100"]])) - return result(platform === "win32" - ? windows(alive ? [[4242, 1, "win-start"]] : []) - : posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) - }) as Command - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - platform, gracefulStopTimeoutMs: 10, forcedStopTimeoutMs: 10, ...options, - spawnSync: command, setTimeout: timers.set, clearTimeout: timers.clear, - spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, - }) - const abort = new AbortController() - const folder = platform === "win32" && process.platform !== "win32" ? `/${process.cwd()}` : process.cwd() - const launch = runtime.launch({ workspaceId: "w", folder, binaryPath: options.binary ?? "opencode", signal: abort.signal }) - if (options.report !== false) { - queueMicrotask(() => child.stdout.write(options.output ?? "opencode server listening on http://127.0.0.1:4321\n")) - await launch - } - return { runtime, child, timers, calls, launch, abort } -} -describe("workspace runtime lifecycle contracts", () => { - it("captures the Linux launch group with one bounded shell command", async () => { - let launchCall: Call | undefined - await harness({ spawnSync: ((command: string, args: readonly string[]) => { - launchCall ??= { command, args: [...args] } - return result(posix([[4242, 1, 4242, "100"]])) - }) as unknown as Command }) - assert.deepEqual(launchCall?.args.slice(-1), ["4242"]) - }) - - it("cancels before spawn and while waiting for a port without losing retryable cleanup", async () => { - let spawned = false - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - spawn: (() => { spawned = true; return new FakeChild() as unknown as ChildProcess }) as typeof import("node:child_process").spawn, - }) - const pre = new AbortController(); pre.abort(new Error("pre-cancelled")) - await assert.rejects(runtime.launch({ workspaceId: "pre", folder: process.cwd(), binaryPath: "opencode", signal: pre.signal }), /pre-cancelled/) - assert.equal(spawned, false) - - let alive = true - const h = await harness({ report: false, spawnSync: ((_command: string, args: readonly string[]) => { - if (isToken(args)) return result(token(alive ? [[4242, 1, 4242, "100"]] : [], isSignal(args))) - if (isGuarded(args)) return result(guarded(true, [[4242, 1, 4242, "100"]])) - return result(posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) - }) as unknown as Command }) - h.abort.abort(new Error("port-cancelled")) - await assert.rejects(h.launch, /port-cancelled/) - const first = h.runtime.stop("w"); h.timers.run(); h.timers.run() - await assert.rejects(first, WorkspaceStopTimeoutError) - alive = false - const retry = h.runtime.stop("w"); h.child.exit(); await retry - - const direct = await harness({ report: false }) - const stopped = direct.runtime.stop("w") - await assert.rejects(direct.launch, /runtime launch was cancelled/) - direct.child.exit() - await stopped - }) - it("rejects launches whose immutable identity cannot be captured and safely cleans up", async () => { - for (const scenario of [{ platform: "linux" as const, binary: "opencode" }, { platform: "win32" as const, binary: "opencode.exe" }]) { - const child = new FakeChild(scenario.platform === "linux" ? undefined : 4242) - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - platform: scenario.platform, - spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, - spawnSync: (() => result("", 1, "identity unavailable")) as unknown as Command, - }) - await assert.rejects(runtime.launch({ workspaceId: scenario.platform, folder: process.cwd(), binaryPath: scenario.binary }), WorkspaceRuntimeIdentityCaptureError) - assert.deepEqual(child.signals, ["SIGTERM"]) - assert.doesNotThrow(() => child.emit("error", new Error("late spawn error"))) - } - }) - it("signals identity-matched POSIX, Windows, and WSL processes", async () => { - const scenarios = [ - { name: "POSIX", platform: "linux" as const, binary: "opencode", marker: "codenomad-guarded-signal" }, - { name: "Windows", platform: "win32" as const, binary: "opencode.exe", marker: "CODENOMAD_RESULT" }, - { name: "WSL", platform: "win32" as const, binary: "\\\\wsl$\\Ubuntu\\usr\\bin\\opencode", marker: "codenomad-wsl-guarded-signal", - output: "__CODENOMAD_WSL_PID__:99:99:50:wsl-boot\nopencode server listening on http://127.0.0.1:4321\n" }, - ] - for (const scenario of scenarios) { - let alive = true - const calls: Call[] = [] - const h = await harness({ platform: scenario.platform, binary: scenario.binary, output: scenario.output, spawnSync: ((command: string, args: readonly string[]) => { - calls.push({ command, args: [...args] }) - const wsl = scenario.name === "WSL" - if (wsl && command === "powershell.exe") return result(windows([[4242, 1, "host-start"]])) - if (isToken(args)) { const rows: Array<[number, number, number, string]> = alive ? [[wsl ? 99 : 4242, 1, wsl ? 99 : 4242, wsl ? "50" : "100"]] : []; if (isSignal(args)) alive = false; return result(token(rows, isSignal(args), wsl ? "wsl-boot" : "boot-a")) } - if (isGuarded(args)) { alive = false; return result(wsl ? guarded(true, [[99, 1, 99, "50"]], "wsl-boot") : scenario.platform === "win32" ? "CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1" : guarded(true, [[4242, 1, 4242, "100"]])) } - return result(wsl - ? posix(alive ? [[99, 1, 99, "50"]] : [[1, 0, 1, "10"]], "wsl-boot") - : scenario.platform === "win32" - ? windows(alive ? [[4242, 1, "win-start"]] : [[1, 0, "system-start"]]) - : posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) - }) as unknown as Command }) - const stop = h.runtime.stop("w") - h.child.exit() - await stop - assert.ok(calls.some(({ args }) => args.some((arg) => arg.includes(scenario.marker))), `${scenario.name} signal`) - assert.equal(calls.some(({ command }) => command === "taskkill.exe"), false) - } - }) - it("does not signal a reused PID or process group", async () => { - let launched = false - const calls: string[][] = [] - const h = await harness({ spawnSync: ((_command: string, args: readonly string[]) => { - calls.push([...args]) - if (isToken(args)) return result(token([], isSignal(args))) - if (isGuarded(args)) return result(guarded(true, [[4242, 1, 4242, "100"]])) - if (!launched) { launched = true; return result(posix([[4242, 1, 4242, "100"]])) } - return result(posix([[4242, 1, 4242, "300"], [6000, 4242, 4242, "150"]])) - }) as unknown as Command }) - await h.runtime.stop("w") - assert.ok(calls.every((args) => !args.includes("6000") && !args.includes("300"))) - }) - it("retains and cleans a portable process group after its leader exits", async () => { - const start = "Fri Jul 10 12:34:56 2026" - let alive = true - let leaderExited = false - const guardedCalls: readonly string[][] = [] - const h = await harness({ platform: "darwin", spawnSync: ((_command: string, args: readonly string[]) => { - if (isGuarded(args)) { - (guardedCalls as string[][]).push([...args]) - alive = false - return result(`CODENOMAD_TARGET_B64|5000|1|4242|${Buffer.from(start).toString("base64")}|${Buffer.from("opencode-child").toString("base64")}\nCODENOMAD_RESULT|1||1`) - } - const rows: Array<[number, number, number, string, string]> = !alive - ? [] - : leaderExited - ? [[5000, 4242, 4242, start, "opencode-child"]] - : [[4242, 1, 4242, start, "opencode"]] - return result(portable(rows)) - }) as unknown as Command }) - - leaderExited = true - h.child.exit(1) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(guardedCalls.length, 1) - assert.equal(guardedCalls[0]?.[7], "1") - assert.equal((h.runtime as unknown as { processes: Map }).processes.size, 0) - }) - it("refuses a leaderless portable group when no retained identity anchor remains", async () => { - const start = "Fri Jul 10 12:34:56 2026" - let leaderExited = false - const guardedCalls: readonly string[][] = [] - const h = await harness({ platform: "darwin", spawnSync: ((_command: string, args: readonly string[]) => { - if (isGuarded(args)) { - (guardedCalls as string[][]).push([...args]) - return result("CODENOMAD_RESULT|0||0") - } - return result(portable(leaderExited - ? [[6000, 1, 4242, "Fri Jul 10 99:99:99 2026", "unverified-process"]] - : [[4242, 1, 4242, start, "opencode"]])) - }) as unknown as Command }) - - leaderExited = true - h.child.exit(1) - const cleanup = h.runtime.stop("w") - h.timers.run(); h.timers.run() - await assert.rejects(cleanup, (error: unknown) => - error instanceof WorkspaceStopTimeoutError && /no longer has a verified identity anchor/.test(error.message)) - assert.equal(guardedCalls.length, 2) - assert.ok(guardedCalls.every((args) => args[7] === "1" && !args.includes("6000"))) - assert.equal((h.runtime as unknown as { processes: Map }).processes.size, 1) - }) - it("bounds direct Windows cleanup without falling back to taskkill", async () => { - const calls: Call[] = [] - const h = await harness({ platform: "win32", binary: "opencode.exe", spawnSync: ((command: string, args: readonly string[]) => { - calls.push({ command, args: [...args] }) - return isGuarded(args) ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") : result(windows([[4242, 1, "win-start"]])) - }) as unknown as Command }) - const stop = h.runtime.stop("w"); h.timers.run(); h.timers.run() - await assert.rejects(stop, WorkspaceStopTimeoutError) - assert.equal(calls.some(({ command }) => command === "taskkill.exe"), false) - assert.equal(calls.filter(({ args }) => isGuarded(args)).length, 2) - }) - it("escalates wrapper cleanup, reports incomplete exited trees, and permits retry", async () => { - let available = false - const calls: Call[] = [] - const h = await harness({ platform: "win32", binary: "opencode.cmd", spawnSync: ((command: string, args: readonly string[]) => { - calls.push({ command, args: [...args] }) - return available ? result() : result("", 1, "taskkill unavailable") - }) as unknown as Command }) - const first = h.runtime.stop("w"); h.timers.run(); h.timers.run() - await assert.rejects(first, (error: unknown) => error instanceof WorkspaceStopTimeoutError && /\/T \/F failed/.test(error.message)) - assert.deepEqual(calls.map(({ args }) => args), [["/PID", "4242", "/T"], ["/PID", "4242", "/T", "/F"]]) - available = true - const retry = h.runtime.stop("w"); h.child.exit(); await retry - - const exited = await harness({ platform: "win32", binary: "opencode.cmd", spawnSync: (() => result("", 1, "taskkill unavailable")) as unknown as Command }) - const incomplete = exited.runtime.stop("w"); exited.child.exit(1) - await assert.rejects(incomplete, WorkspaceWindowsTreeCleanupIncompleteError) - await assert.rejects(exited.runtime.stop("w"), WorkspaceWindowsTreeCleanupIncompleteError) - }) - it("shares one bounded stop operation across concurrent callers", async () => { - const h = await harness() - const first = h.runtime.stop("w"); const second = h.runtime.stop("w") - assert.strictEqual(first, second) - h.timers.run(); h.timers.run() - const outcomes = await Promise.allSettled([first, second]) - assert.deepEqual(outcomes.map(({ status }) => status), ["rejected", "rejected"]) - }) -}) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts deleted file mode 100644 index 41adab8cb..000000000 --- a/packages/server/src/workspaces/runtime.ts +++ /dev/null @@ -1,854 +0,0 @@ -import { ChildProcess, spawn, spawnSync } from "child_process" -import { randomBytes } from "crypto" -import { existsSync, statSync } from "fs" -import path from "path" -import { EventBus } from "../events/bus" -import { LogLevel, WorkspaceLogEntry } from "../api-types" -import { Logger } from "../logger" -import { buildSpawnSpec, type SpawnProcessKind } from "./spawn" -import { - descendantsOf, - LAUNCH_CLEANUP_TOKEN_ENV, - probeLaunchCleanupToken, - probePosixProcesses, - probeWindowsProcesses, - probeWslProcesses, - sameProcess, - signalPosixProcesses, - signalOwnedPosixProcessGroup, - signalLaunchCleanupToken, - signalWindowsProcesses, - signalWslProcesses, - startedNoLaterThan, - type GuardedSignalResult, - type ProcessIdentity, - type ProcessSnapshot, -} from "./process-identity" - -const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i -const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:" - -function redactEnvironment(env: Record): Record { - const redacted: Record = {} - for (const [key, value] of Object.entries(env)) { - if (value === undefined) { - redacted[key] = value - continue - } - redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value - } - return redacted -} - -interface LaunchOptions { - workspaceId: string - folder: string - binaryPath: string - environment?: Record - logLevel?: string - onExit?: (info: ProcessExitInfo) => void - signal?: AbortSignal -} - -export interface ProcessExitInfo { - workspaceId: string - code: number | null - signal: NodeJS.Signals | null - requested: boolean -} - -interface TrackedProcesses { - leader?: ProcessIdentity - groupId?: number - dispatchCutoff?: string - groupOwnershipRetained?: boolean - groupGoneConfirmed?: boolean - groupOwnershipUncertain?: boolean - members: Map -} - -interface ManagedProcess { - child: ChildProcess - cleanupToken: string - processKind: SpawnProcessKind - windowsTreeCleanupConfirmed?: boolean - windowsTreeCleanupFailures?: string[] - identityCaptureFailed?: boolean - requestedStop: boolean - stopPromise?: Promise - cancelLaunch?: () => void - finalizeExit?: (code: number | null, signal: NodeJS.Signals | null) => void - targets?: TrackedProcesses - wsl?: TrackedProcesses & { - distro: string - linuxPid: number | null - linuxPgid: number | null - leaderStartTime: string | null - bootId: string | null - } -} - -type RuntimeTimeout = ReturnType - -export interface WorkspaceRuntimeOptions { - gracefulStopTimeoutMs?: number - forcedStopTimeoutMs?: number - stopCommandTimeoutMs?: number - platform?: NodeJS.Platform - spawn?: typeof spawn - spawnSync?: typeof spawnSync - setTimeout?: (callback: () => void, delayMs: number) => RuntimeTimeout - clearTimeout?: (timer: RuntimeTimeout) => void -} - -export class WorkspaceStopTimeoutError extends Error { - readonly code = "WORKSPACE_STOP_TIMEOUT" - readonly retryable = true - - constructor(workspaceId: string, pid: number | undefined, timeoutMs: number, liveness: string, failures: string[]) { - const failureDetails = failures.length > 0 ? ` Stop failures: ${failures.join("; ")}.` : "" - super( - `Workspace ${workspaceId} process ${pid ?? "with unknown PID"} did not stop within ${timeoutMs}ms; ${liveness}.` + - `${failureDetails} The stop can be retried.`, - ) - this.name = "WorkspaceStopTimeoutError" - } -} - -export class WorkspaceWindowsTreeCleanupIncompleteError extends Error { - readonly code = "WORKSPACE_WINDOWS_TREE_CLEANUP_INCOMPLETE" - readonly retryable = true - - constructor(workspaceId: string, pid: number | undefined, failures: string[]) { - const failureDetails = failures.length > 0 ? ` Stop failures: ${failures.join("; ")}.` : "" - super( - `Workspace ${workspaceId} Windows wrapper ${pid ?? "with unknown PID"} exited before taskkill confirmed process-tree cleanup.` + - `${failureDetails} The workspace record was retained because cleanup is incomplete.`, - ) - this.name = "WorkspaceWindowsTreeCleanupIncompleteError" - } -} - -export class WorkspaceRuntimeIdentityCaptureError extends Error { - readonly code = "WORKSPACE_RUNTIME_IDENTITY_CAPTURE_FAILED" - - constructor(workspaceId: string, detail: string) { - super(`Workspace ${workspaceId} process identity capture failed: ${detail}`) - this.name = "WorkspaceRuntimeIdentityCaptureError" - } -} - -export class WorkspaceRuntime { - private processes = new Map() - private readonly platform: NodeJS.Platform - private readonly spawnProcess: typeof spawn - private readonly spawnCommand: typeof spawnSync - private readonly scheduleTimeout: (callback: () => void, delayMs: number) => RuntimeTimeout - private readonly cancelTimeout: (timer: RuntimeTimeout) => void - private readonly gracefulStopTimeoutMs: number - private readonly forcedStopTimeoutMs: number - private readonly stopCommandTimeoutMs: number - - constructor( - private readonly eventBus: EventBus, - private readonly logger: Logger, - options: WorkspaceRuntimeOptions = {}, - ) { - this.platform = options.platform ?? process.platform - this.spawnProcess = options.spawn ?? spawn - this.spawnCommand = options.spawnSync ?? spawnSync - this.scheduleTimeout = options.setTimeout ?? setTimeout - this.cancelTimeout = options.clearTimeout ?? clearTimeout - this.gracefulStopTimeoutMs = Math.max(0, options.gracefulStopTimeoutMs ?? 2000) - this.forcedStopTimeoutMs = Math.max(0, options.forcedStopTimeoutMs ?? 2000) - this.stopCommandTimeoutMs = Math.max(1, options.stopCommandTimeoutMs ?? 1000) - } - - async launch(options: LaunchOptions): Promise<{ - pid: number - port: number - exitPromise: Promise - getLastOutput: () => string - }> { - options.signal?.throwIfAborted() - this.validateFolder(options.folder) - - const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" - const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] - const cleanupToken = randomBytes(32).toString("hex") - const env = { ...process.env, ...(options.environment ?? {}), [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } - - let exitResolve: ((info: ProcessExitInfo) => void) | null = null - const exitPromise = new Promise((resolveExit) => { - exitResolve = resolveExit - }) - // Store recent output for debugging - keep last 50 lines from each stream - const MAX_OUTPUT_LINES = 50 - const recentStdout: string[] = [] - const recentStderr: string[] = [] - const getLastOutput = () => { - const combined: string[] = [] - if (recentStderr.length > 0) { - combined.push("Error Stream") - combined.push(...recentStderr.slice(-10)) - } - if (recentStdout.length > 0) { - combined.push("Output Stream") - combined.push(...recentStdout.slice(-10)) - } - return combined.join("\n") - } - - return new Promise((resolve, reject) => { - const propagatedEnvKeys = [...Object.keys(options.environment ?? {}), LAUNCH_CLEANUP_TOKEN_ENV] - const spec = buildSpawnSpec(options.binaryPath, args, { - cwd: options.folder, - env, - propagateEnvKeys: propagatedEnvKeys, - wslPidMarker: WSL_PID_MARKER, - platform: this.platform, - }) - const commandLine = [spec.command, ...spec.args].join(" ") - this.logger.info( - { - workspaceId: options.workspaceId, - folder: options.folder, - binary: options.binaryPath, - spawnCommand: spec.command, - commandLine, - }, - "Launching OpenCode process", - ) - - this.logger.debug( - { - workspaceId: options.workspaceId, - spawnArgs: spec.args, - }, - "OpenCode spawn args", - ) - - this.logger.trace( - { - workspaceId: options.workspaceId, - env: redactEnvironment(env), - }, - "OpenCode spawn environment", - ) - const detached = this.platform !== "win32" - const child = this.spawnProcess(spec.command, spec.args, { - cwd: spec.cwd, - env: spec.env, - stdio: ["ignore", "pipe", "pipe"], - detached, - ...spec.options, - }) - const handleEarlyError = (error: Error) => { - this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime failed before launch handlers were ready") - } - child.on("error", handleEarlyError) - - const managed: ManagedProcess = { - child, - cleanupToken, - processKind: spec.processKind, - requestedStop: false, - targets: { members: new Map() }, - ...(spec.wsl - ? { - wsl: { - distro: spec.wsl.distro, - linuxPid: null, - linuxPgid: null, - leaderStartTime: null, - bootId: null, - members: new Map(), - }, - } - : {}), - } - this.processes.set(options.workspaceId, managed) - if (spec.processKind === "posix" || spec.processKind === "wsl" || spec.processKind === "windows-direct") { - const launchSnapshot = child.pid - ? this.platform === "win32" - ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) - : probePosixProcesses( - this.spawnCommand, - this.stopCommandTimeoutMs, - this.platform, - { pids: [child.pid], groupId: child.pid }, - ) - : { ok: false as const, error: "spawned child did not expose a PID" } - const launchLeader = launchSnapshot.ok && child.pid ? launchSnapshot.processes.get(child.pid) : undefined - if (!launchLeader) { - const detail = launchSnapshot.ok - ? `spawned PID ${child.pid ?? "unknown"} was absent from the identity snapshot` - : launchSnapshot.error - this.beginFailedLaunchCleanup(options.workspaceId, managed) - reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, detail)) - return - } - managed.targets!.leader = launchLeader - managed.targets!.groupId = launchLeader.groupId - managed.targets!.groupOwnershipRetained = this.platform !== "linux" && this.platform !== "win32" && - launchLeader.groupId === launchLeader.pid - for (const identity of launchSnapshot.ok ? launchSnapshot.processes.values() : [launchLeader]) { - if (identity.groupId === launchLeader.groupId) managed.targets!.members.set(identity.pid, identity) - } - } - - let stdoutBuffer = "" - let stderrBuffer = "" - let portFound = false - let pendingPort: number | null = null - let launchSettled = false - const cancelLaunch = () => { - if (launchSettled) return - launchSettled = true - stopWarningTimer() - reject(options.signal?.reason ?? new Error(`Workspace ${options.workspaceId} runtime launch was cancelled`)) - } - managed.cancelLaunch = cancelLaunch - - let warningTimer: NodeJS.Timeout | null = null - - const startWarningTimer = () => { - warningTimer = setInterval(() => { - this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet") - }, 10000) - } - - const stopWarningTimer = () => { - if (warningTimer) { - clearInterval(warningTimer) - warningTimer = null - } - } - - startWarningTimer() - - options.signal?.addEventListener("abort", cancelLaunch, { once: true }) - if (options.signal?.aborted) cancelLaunch() - - const cleanupStreams = () => { - stopWarningTimer() - child.stdout?.removeAllListeners() - child.stderr?.removeAllListeners() - } - - let finalized = false - const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { - if (finalized) return - finalized = true - const cleanupRequired = !managed.requestedStop - this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited") - cleanupStreams() - options.signal?.removeEventListener("abort", cancelLaunch) - managed.cancelLaunch = undefined - child.removeListener("error", handleError) - child.removeListener("exit", handleExit) - const exitInfo: ProcessExitInfo = { - workspaceId: options.workspaceId, - code, - signal, - requested: managed.requestedStop, - } - if (exitResolve) { - exitResolve(exitInfo) - exitResolve = null - } - if (!portFound) { - const recentOutput = getLastOutput().trim() - const reason = recentOutput || stderrBuffer || `Process exited with code ${code}` - if (!launchSettled) { - launchSettled = true - reject(new Error(reason)) - } - } else { - options.onExit?.(exitInfo) - } - if (cleanupRequired && this.processes.get(options.workspaceId) === managed) { - void this.stop(options.workspaceId).catch((error) => { - this.logger.warn({ workspaceId: options.workspaceId, err: error }, "Unexpected workspace exit cleanup remains pending") - }) - } - } - managed.finalizeExit = handleExit - - const handleError = (error: Error) => { - const cleanupRequired = !managed.requestedStop - cleanupStreams() - options.signal?.removeEventListener("abort", cancelLaunch) - managed.cancelLaunch = undefined - child.removeListener("exit", handleExit) - this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") - if (exitResolve) { - exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) - exitResolve = null - } - if (!launchSettled) { - launchSettled = true - reject(error) - } - if (cleanupRequired && this.processes.get(options.workspaceId) === managed) { - void this.stop(options.workspaceId).catch((stopError) => { - this.logger.warn({ workspaceId: options.workspaceId, err: stopError }, "Workspace error cleanup remains pending") - }) - } - } - - child.removeListener("error", handleEarlyError) - child.on("error", handleError) - child.on("exit", handleExit) - - const resolveLaunchIfIdentified = () => { - if (launchSettled || pendingPort === null) return - if (managed.wsl && (!managed.wsl.linuxPid || !managed.wsl.linuxPgid || !managed.wsl.leaderStartTime || !managed.wsl.bootId)) { - return - } - portFound = true - launchSettled = true - stopWarningTimer() - options.signal?.removeEventListener("abort", cancelLaunch) - managed.cancelLaunch = undefined - child.removeListener("error", handleError) - this.logger.info({ workspaceId: options.workspaceId, port: pendingPort }, "Workspace runtime allocated port") - resolve({ pid: child.pid!, port: pendingPort, exitPromise, getLastOutput }) - } - - const failWslIdentityCapture = (detail: string) => { - if (launchSettled) return - launchSettled = true - managed.requestedStop = true - cleanupStreams() - this.beginFailedLaunchCleanup(options.workspaceId, managed) - reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, detail)) - } - - child.stdout?.on("data", (data: Buffer) => { - const text = data.toString() - stdoutBuffer += text - const lines = stdoutBuffer.split("\n") - stdoutBuffer = lines.pop() ?? "" - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) { - const [linuxPidText, linuxPgidText, linuxStartTime = "", bootId = ""] = trimmed.slice(WSL_PID_MARKER.length).split(":", 4) - const linuxPid = Number.parseInt(linuxPidText ?? "", 10) - const linuxPgid = Number.parseInt(linuxPgidText ?? "", 10) - if (Number.isInteger(linuxPid) && linuxPid > 0 && Number.isInteger(linuxPgid) && linuxPgid > 0 && /^\d+$/.test(linuxStartTime) && bootId) { - managed.wsl.linuxPid = linuxPid - managed.wsl.linuxPgid = linuxPgid - managed.wsl.leaderStartTime = linuxStartTime - managed.wsl.bootId = bootId - managed.wsl.members.set(linuxPid, { - pid: linuxPid, - parentPid: 0, - groupId: linuxPgid, - startTime: linuxStartTime, - bootId, - startOrder: linuxStartTime, - }) - this.logger.debug( - { - workspaceId: options.workspaceId, - linuxPid, - linuxPgid: managed.wsl.linuxPgid, - linuxStartTime: managed.wsl.leaderStartTime, - }, - "Captured WSL OpenCode process identity", - ) - resolveLaunchIfIdentified() - } else { - failWslIdentityCapture("WSL launcher returned an incomplete Linux PID identity") - } - continue - } - - recentStdout.push(trimmed) - if (recentStdout.length > MAX_OUTPUT_LINES) { - recentStdout.shift() - } - - this.emitLog(options.workspaceId, "info", line) - - if (!portFound) { - const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) - if (portMatch && !launchSettled) { - pendingPort = parseInt(portMatch[1], 10) - if (managed.wsl && (!managed.wsl.leaderStartTime || !managed.wsl.bootId)) { - failWslIdentityCapture("WSL process reported a port before its Linux identity") - } else { - resolveLaunchIfIdentified() - } - } - } - } - }) - - child.stderr?.on("data", (data: Buffer) => { - const text = data.toString() - stderrBuffer += text - const lines = stderrBuffer.split("\n") - stderrBuffer = lines.pop() ?? "" - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - recentStderr.push(trimmed) - if (recentStderr.length > MAX_OUTPUT_LINES) { - recentStderr.shift() - } - - this.emitLog(options.workspaceId, "error", line) - } - }) - }) - } - - private beginFailedLaunchCleanup(workspaceId: string, managed: ManagedProcess): void { - managed.identityCaptureFailed = true - void this.stop(workspaceId).catch((error) => { - this.logger.warn({ workspaceId, err: error }, "Unpublished workspace cleanup remains pending") - }) - if (managed.child.exitCode === null && managed.child.signalCode === null) { - try { - managed.child.kill("SIGTERM") - } catch (error) { - this.logger.debug({ workspaceId, err: error }, "Failed initial live-child cleanup signal") - } - } - } - - stop(workspaceId: string): Promise { - const managed = this.processes.get(workspaceId) - if (!managed) return Promise.resolve() - - if (managed.stopPromise) { - return managed.stopPromise - } - - const stopPromise = this.stopManagedProcess(workspaceId, managed) - managed.stopPromise = stopPromise - void stopPromise.finally(() => { - if (managed.stopPromise === stopPromise) managed.stopPromise = undefined - }).catch(() => undefined) - return stopPromise - } - - private stopManagedProcess(workspaceId: string, managed: ManagedProcess): Promise { - managed.requestedStop = true - managed.cancelLaunch?.() - managed.cancelLaunch = undefined - this.logger.info({ workspaceId }, "Stopping OpenCode process") - if (managed.processKind === "windows-wrapper") return this.stopOwnedWindowsProcess(workspaceId, managed) - - const { child } = managed - const pid = child.pid - const failures: string[] = [] - const wrapperExited = () => child.exitCode !== null || child.signalCode !== null - const hasWslIdentity = () => Boolean( - managed.wsl?.linuxPid && managed.wsl.linuxPgid && managed.wsl.leaderStartTime && managed.wsl.bootId, - ) - const trackedTarget = () => managed.wsl && hasWslIdentity() ? managed.wsl : managed.targets! - const trackedLeader = (): ProcessIdentity | undefined => { - if (!managed.wsl || !hasWslIdentity()) return managed.targets?.leader - return { - pid: managed.wsl.linuxPid!, - parentPid: 0, - groupId: managed.wsl.linuxPgid!, - startTime: managed.wsl.leaderStartTime!, - bootId: managed.wsl.bootId!, - startOrder: managed.wsl.leaderStartTime!, - } - } - - const refreshTargets = () => { - const target = trackedTarget() - const leader = trackedLeader() - const groupId = managed.wsl && hasWslIdentity() ? managed.wsl.linuxPgid! : target.groupId - const portableGroupId = this.platform !== "linux" && this.platform !== "win32" && - target.groupOwnershipRetained && !target.groupGoneConfirmed ? groupId : undefined - const snapshot = managed.wsl && hasWslIdentity() - ? probeWslProcesses(this.spawnCommand, managed.wsl.distro, this.stopCommandTimeoutMs) - : this.platform === "win32" - ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) - : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform, this.platform === "linux" - ? undefined - : { pids: [leader?.pid, ...target.members.keys()].filter((value): value is number => Boolean(value)), groupId: portableGroupId }) - if (!snapshot.ok) { - const platformName = managed.wsl && hasWslIdentity() ? "WSL" : this.platform === "win32" ? "Windows" : "POSIX" - failures.push(`${platformName} identity discovery failed: ${snapshot.error}`) - return { snapshot, aliveMembers: [] as ProcessIdentity[] } - } - - const leaderMatches = sameProcess(leader, leader ? snapshot.processes.get(leader.pid) : undefined) - const groupLeader = groupId ? snapshot.processes.get(groupId) : undefined - const groupWasReused = Boolean(groupLeader && !sameProcess(leader, groupLeader)) - const retainedAnchorMatches = Boolean(portableGroupId && Array.from(target.members.values()).some((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - )) - if (portableGroupId && groupWasReused) { - target.groupGoneConfirmed = true - target.groupOwnershipUncertain = false - } - for (const process of snapshot.processes.values()) { - const sameBoot = !leader?.bootId || process.bootId === leader.bootId - const withinDispatch = (this.platform === "linux" || Boolean(managed.wsl)) && Boolean( - target.dispatchCutoff && sameBoot && startedNoLaterThan(process, target.dispatchCutoff), - ) - const withinRetainedPortableGroup = Boolean(portableGroupId && !groupWasReused && retainedAnchorMatches) - if (groupId && process.groupId === groupId && (leaderMatches || withinRetainedPortableGroup || (!groupWasReused && withinDispatch))) { - target.members.set(process.pid, process) - } - } - if (portableGroupId && !groupWasReused) { - const groupPresent = Array.from(snapshot.processes.values()).some((process) => process.groupId === portableGroupId) - if (!groupPresent) { - target.groupGoneConfirmed = true - target.groupOwnershipUncertain = false - } else if (!leaderMatches && !retainedAnchorMatches) { - target.groupOwnershipUncertain = true - } - } - if (this.platform === "win32" && !managed.wsl && leaderMatches && leader) { - for (const descendant of descendantsOf(snapshot.processes, leader.pid)) target.members.set(descendant.pid, descendant) - } - const aliveMembers = Array.from(target.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ) - return { snapshot, aliveMembers } - } - - const usesTokenCleanup = () => this.platform === "linux" || Boolean(managed.wsl) - const refreshTokenTargets = (): ProcessSnapshot | undefined => { - if (!usesTokenCleanup()) return - const snapshot = probeLaunchCleanupToken( - this.spawnCommand, managed.cleanupToken, this.stopCommandTimeoutMs, managed.wsl?.distro, - ) - if (!snapshot.ok) failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token discovery failed: ${snapshot.error}`) - else for (const identity of snapshot.processes.values()) trackedTarget().members.set(identity.pid, identity) - return snapshot - } - const recordSignalResult = (result: GuardedSignalResult, target: TrackedProcesses, name: string, signal: NodeJS.Signals) => { - const identities = result.ok ? result.signaled : (result.observed ?? []) - for (const identity of identities) target.members.set(identity.pid, identity) - if (!result.ok) failures.push(`${name} guarded ${signal} failed: ${result.error}`) - else { - if (result.matched && !result.signalSent) failures.push(`${name} guarded ${signal} matched but sent no signal`) - if (result.cutoff) target.dispatchCutoff = result.cutoff - } - } - - const sendStopSignal = (signal: NodeJS.Signals) => { - if (!pid) failures.push(`${signal} was not sent because the process PID is unavailable`) - if (pid && wrapperExited() && this.platform !== "linux" && this.platform !== "win32") refreshTargets() - let signaledOwnedGroup = false - if (pid && managed.identityCaptureFailed && this.platform !== "linux" && this.platform !== "win32" && !wrapperExited()) { - const result = signalOwnedPosixProcessGroup(this.spawnCommand, pid, signal, this.stopCommandTimeoutMs) - recordSignalResult(result, managed.targets!, "owned POSIX group", signal) - const leader = result.ok && result.matched ? result.signaled.find((identity) => identity.pid === pid) : undefined - if (leader) Object.assign(managed.targets!, { leader, groupId: pid }) - refreshTargets() - signaledOwnedGroup = true - } - if (pid && !signaledOwnedGroup) { - const target = trackedTarget() - const groupId = managed.wsl && hasWslIdentity() ? managed.wsl.linuxPgid! : target.groupId - const request = { - leader: trackedLeader(), groupId, members: [...target.members.values()], signal, - allowLeaderlessGroup: this.platform !== "linux" && this.platform !== "win32" && - Boolean(target.groupOwnershipRetained && !target.groupGoneConfirmed && wrapperExited()), - cleanupToken: this.platform !== "linux" && this.platform !== "win32" ? managed.cleanupToken : undefined, - } - const result = managed.wsl && hasWslIdentity() - ? signalWslProcesses(this.spawnCommand, managed.wsl.distro, request, this.stopCommandTimeoutMs) - : this.platform === "win32" - ? signalWindowsProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs) - : signalPosixProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs, this.platform) - recordSignalResult(result, target, managed.wsl && hasWslIdentity() ? "WSL" : this.platform === "win32" ? "Windows" : "POSIX", signal) - refreshTargets() - } - if (usesTokenCleanup()) { - const result = signalLaunchCleanupToken( - this.spawnCommand, managed.cleanupToken, signal, this.stopCommandTimeoutMs, managed.wsl?.distro, - ) - const name = managed.wsl ? "WSL" : "Linux" - if (!result.ok) failures.push(`${name} launch-token ${signal} failed: ${result.error}`) - else { - if (result.targets.length > 0 && !result.signalSent) failures.push(`${name} launch-token ${signal} matched but sent no signal`) - for (const identity of result.targets) trackedTarget().members.set(identity.pid, identity) - } - refreshTokenTargets() - } - } - - const probeLiveness = () => { - const refreshed = pid ? refreshTargets() : undefined - const tokenSnapshot = refreshTokenTargets() - if (tokenSnapshot && !tokenSnapshot.ok) { - return { state: "unknown", detail: `${managed.wsl ? "WSL Linux" : "Linux"} launch-token cleanup could not be confirmed` } as const - } - if (refreshed && !refreshed.snapshot.ok && (managed.targets?.leader || !tokenSnapshot?.ok)) { - const name = managed.wsl ? "WSL Linux" : this.platform === "win32" ? "Windows" : "POSIX" - return { state: "unknown", detail: `${name} target identity could not be confirmed` } as const - } - if (managed.identityCaptureFailed && this.platform === "win32" && !managed.wsl) { - return { state: "unknown", detail: "Windows cleanup cannot prove exact launch ownership without a Job Object" } as const - } - if (managed.identityCaptureFailed && managed.wsl && !managed.targets?.leader && !wrapperExited()) { - return { state: "unknown", detail: "the unidentified Windows WSL wrapper is still alive" } as const - } - if (trackedTarget().groupOwnershipUncertain) { - return { state: "unknown", detail: "the retained POSIX process group no longer has a verified identity anchor" } as const - } - if (trackedTarget().members.size === 0) { - if (tokenSnapshot?.ok && tokenSnapshot.processes.size === 0) return { state: "gone", detail: "no process carries the unpublished launch token" } as const - return { state: "unknown", detail: pid ? "the original process identity was not captured" : "the target PID is unavailable" } as const - } - if ((refreshed?.aliveMembers.length ?? 0) === 0 && (!tokenSnapshot?.ok || tokenSnapshot.processes.size === 0)) { - return { state: "gone", detail: "all tracked original process identities are gone" } as const - } - const name = managed.wsl && hasWslIdentity() ? "WSL Linux process group" : this.platform === "win32" ? "Windows process tree" : "POSIX process group" - return { state: "alive", detail: `the tracked original ${name} is still alive` } as const - } - const stopped = () => probeLiveness().state === "gone" ? true : undefined - const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs - return this.runBoundedStop(workspaceId, managed, { - start: () => { - this.logger.debug({ workspaceId, pid, detached: this.platform !== "win32" }, "Sending SIGTERM to workspace process (tree/group)") - sendStopSignal("SIGTERM") - return stopped() - }, - exit: stopped, - error: (error) => { failures.push(`child process error while stopping: ${error.message}`) }, - escalate: () => { - const liveness = probeLiveness() - if (liveness.state === "gone") return true - this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating") - sendStopSignal("SIGKILL") - }, - deadline: () => { - const liveness = probeLiveness() - if (liveness.state === "gone") return true - const prefix = wrapperExited() ? "the wrapper exited but " : "" - return new WorkspaceStopTimeoutError(workspaceId, pid, totalTimeoutMs, `${prefix}${liveness.detail}`, failures) - }, - }) - } - - private stopOwnedWindowsProcess(workspaceId: string, managed: ManagedProcess): Promise { - const { child } = managed - const pid = child.pid - const failures = (managed.windowsTreeCleanupFailures ??= []) - const outcome = () => managed.windowsTreeCleanupConfirmed - ? true - : new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, pid, failures) - const stopChild = (force: boolean) => { - if (child.exitCode !== null || child.signalCode !== null) return - if (!pid) { - failures.push(`${force ? "forced" : "graceful"} stop was not sent because the process PID is unavailable`) - return - } - const args = ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])] - try { - const result = this.spawnCommand("taskkill.exe", args, { encoding: "utf8", timeout: this.stopCommandTimeoutMs }) - if (result.status === 0) managed.windowsTreeCleanupConfirmed = true - else { - const detail = result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` - failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${detail}`) - } - } catch (error) { - failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${error instanceof Error ? error.message : String(error)}`) - } - } - const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs - return this.runBoundedStop(workspaceId, managed, { - start: () => { - if (child.exitCode !== null || child.signalCode !== null) return outcome() - this.logger.debug({ workspaceId, pid }, "Stopping owned Windows workspace wrapper tree") - stopChild(false) - }, - exit: outcome, - error: (error) => { - failures.push(`child process error while stopping: ${error.message}`) - return error - }, - escalate: () => { - this.logger.warn({ workspaceId, pid }, "Owned Windows process did not stop after the graceful attempt, escalating") - stopChild(true) - }, - deadline: () => new WorkspaceStopTimeoutError( - workspaceId, pid, totalTimeoutMs, - child.exitCode !== null || child.signalCode !== null - ? "taskkill did not confirm tree cleanup before the owned Windows wrapper exited" - : "the owned Windows wrapper did not emit exit or error after tree termination", - failures, - ), - }) - } - - private runBoundedStop( - workspaceId: string, - managed: ManagedProcess, - actions: { - start: () => true | Error | void - exit: () => true | Error | void - error: (error: Error) => true | Error | void - escalate: () => true | Error | void - deadline: () => true | Error - }, - ): Promise { - const { child } = managed - return new Promise((resolve, reject) => { - let settled = false - const timers: RuntimeTimeout[] = [] - const finish = (outcome: true | Error | void) => { - if (settled || !outcome) return - settled = true - child.removeListener("exit", onExit) - child.removeListener("error", onError) - for (const timer of timers) this.cancelTimeout(timer) - if (outcome instanceof Error) reject(outcome) - else { - if (this.processes.get(workspaceId) === managed) this.processes.delete(workspaceId) - managed.finalizeExit?.(child.exitCode, child.signalCode) - resolve() - } - } - const onExit = () => finish(actions.exit()) - const onError = (error: Error) => finish(actions.error(error)) - child.once("exit", onExit) - child.on("error", onError) - timers.push(this.scheduleTimeout(() => finish(actions.escalate()), this.gracefulStopTimeoutMs)) - timers.push(this.scheduleTimeout(() => finish(actions.deadline()), this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs)) - finish(actions.start()) - }) - } - - private emitLog(workspaceId: string, level: LogLevel, message: string) { - const entry: WorkspaceLogEntry = { - workspaceId, - timestamp: new Date().toISOString(), - level, - message: message.trim(), - } - - this.eventBus.publish({ type: "workspace.log", entry }) - } - - private validateFolder(folder: string) { - const resolved = path.resolve(folder) - if (!existsSync(resolved)) { - throw new Error(`Folder does not exist: ${resolved}`) - } - const stats = statSync(resolved) - if (!stats.isDirectory()) { - throw new Error(`Path is not a directory: ${resolved}`) - } - } -} diff --git a/packages/server/src/workspaces/service-state.ts b/packages/server/src/workspaces/service-state.ts new file mode 100644 index 000000000..55c2cded9 --- /dev/null +++ b/packages/server/src/workspaces/service-state.ts @@ -0,0 +1,16 @@ +import { isIP } from "node:net" + +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}`) + } + if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1" + 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 +} diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 52ec18d79..531a41fe6 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -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,51 +7,25 @@ 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 CODENOMAD_PLUGIN_PACKAGE_NAME = "@codenomad/codenomad-opencode-plugin" -const WSL_PLUGIN_PATH_ENV = "CODENOMAD_OPENCODE_PLUGIN_WSL_PATH" -const WSL_PLUGIN_PATH_PLACEHOLDER = "__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__" -const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp( - `(${escapeRegex(CODENOMAD_PLUGIN_PACKAGE_NAME)}@file:)([A-Za-z]:[^"\\r\\n]+?\\.tgz)`, -) -const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV]) -const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" -const WINDOWS_SHELL_NAMES = new Set([ - "bash", - "bash.exe", - "cmd", - "cmd.exe", - "command.com", - "powershell", - "powershell.exe", - "pwsh", - "pwsh.exe", - "sh", - "sh.exe", -]) - -export type SpawnProcessKind = "posix" | "windows-direct" | "windows-wrapper" | "wsl" export interface SpawnSpec { command: string args: string[] - processKind: SpawnProcessKind options: { windowsVerbatimArguments?: boolean } cwd?: string env?: NodeJS.ProcessEnv - wsl?: { - distro: string - pidMarker?: string - } } +export type ServiceLaunchSpec = + | { kind: "host"; binary: string; platform: NodeJS.Platform } + | { kind: "wsl"; distro: string; binary: string } + interface BuildSpawnSpecOptions { cwd?: string env?: NodeJS.ProcessEnv - propagateEnvKeys?: string[] - wslPidMarker?: string platform?: NodeJS.Platform } @@ -97,7 +71,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)) { @@ -111,7 +86,6 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return { command: comspec, args: ["/d", "/s", "/c", commandLine], - processKind: "windows-wrapper", options: { windowsVerbatimArguments: true }, cwd: options.cwd, env: options.env, @@ -123,7 +97,6 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return { command: "powershell.exe", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolvedBinaryPath, ...args], - processKind: "windows-wrapper", options: {}, cwd: options.cwd, env: options.env, @@ -133,7 +106,6 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return { command: resolvedBinaryPath, args, - processKind: classifyWindowsCommand(resolvedBinaryPath), options: {}, cwd: options.cwd, env: options.env, @@ -145,7 +117,6 @@ export function buildSpawnSpec(binaryPath: string, args: string[], options: Buil return { command: binaryPath, args, - processKind: "posix", options: {}, cwd: options.cwd, env: options.env, @@ -155,6 +126,54 @@ 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, + options: BuildSpawnSpecOptions = {}, +): ServiceLaunchSpec { + const platform = options.platform ?? process.platform + const wslPath = platform === "win32" ? parseWslUncPath(binaryPath) : null + if (wslPath) return { kind: "wsl", distro: wslPath.distro, binary: wslPath.linuxPath } + return { kind: "host", binary: binaryPath, platform } +} + export function probeBinaryVersion(binaryPath: string): { valid: boolean version?: string @@ -211,8 +230,6 @@ export function probeBinaryVersion(binaryPath: string): { function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec { const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined - const env = buildWslEnvironment(options.env, options.propagateEnvKeys) - const shouldTranslatePluginPath = Boolean(env?.[WSL_PLUGIN_PATH_ENV]) if (options.cwd && !workingDirectory) { throw new Error( `Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`, @@ -220,14 +237,14 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn } const wslArgs = ["--distribution", wslPath.distro] - const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows" || shouldTranslatePluginPath + const shouldWrapWithShell = workingDirectory?.kind === "windows" if (!shouldWrapWithShell && workingDirectory?.kind === "linux") { wslArgs.push("--cd", workingDirectory.path) } if (shouldWrapWithShell) { - const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker, shouldTranslatePluginPath) + const launchScript = buildWslLaunchScript(workingDirectory) wslArgs.push( "--exec", "sh", @@ -249,27 +266,9 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn return { command: "wsl.exe", args: wslArgs, - processKind: "wsl", options: {}, - env, - wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker }, - } -} - -function classifyWindowsCommand(binaryPath: string): SpawnProcessKind { - const commandName = path.win32.basename(binaryPath).toLowerCase() - if (WINDOWS_SHELL_NAMES.has(commandName)) { - return "windows-wrapper" - } - - const extension = path.win32.extname(binaryPath).toLowerCase() - if (extension) { - return WINDOWS_DIRECT_EXTENSIONS.has(extension) ? "windows-direct" : "windows-wrapper" + env: options.env, } - - // Bare commands can resolve to npm/script shims, so keep them on the - // wrapper path. That path owns cleanup without requiring process discovery. - return "windows-wrapper" } function resolveBareWindowsCommand(binaryPath: string, options: BuildSpawnSpecOptions): string | null { @@ -316,19 +315,9 @@ function unquoteWindowsPathEntry(entry: string): string { : trimmed } -function buildWslLaunchScript( - workingDirectory: WslWorkingDirectory | undefined, - pidMarker: string | undefined, - translatePluginPath: boolean, -): string { +function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined): string { const steps: string[] = [] - if (pidMarker) { - steps.push( - `codenomad_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]'); codenomad_start=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null); codenomad_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null); test -n "$codenomad_pgid" && test -n "$codenomad_start" && test -n "$codenomad_boot" && printf '%s%s:%s:%s:%s\\n' '${pidMarker}' "$$" "$codenomad_pgid" "$codenomad_start" "$codenomad_boot"`, - ) - } - if (workingDirectory?.kind === "linux") { steps.push('cd "$1"') steps.push("shift") @@ -337,16 +326,24 @@ function buildWslLaunchScript( steps.push("shift") } - if (translatePluginPath) { - steps.push( - `if [ -n "$${WSL_PLUGIN_PATH_ENV}" ] && [ -n "$OPENCODE_CONFIG_CONTENT" ]; then escaped_plugin_path=$(printf '%s' "$${WSL_PLUGIN_PATH_ENV}" | sed 's/[\\&|]/\\\\&/g'); OPENCODE_CONFIG_CONTENT=$(printf '%s' "$OPENCODE_CONFIG_CONTENT" | sed "s|${WSL_PLUGIN_PATH_PLACEHOLDER}|$escaped_plugin_path|g"); export OPENCODE_CONFIG_CONTENT; unset ${WSL_PLUGIN_PATH_ENV}; fi`, - ) - } - steps.push('exec "$@"') 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) { @@ -359,70 +356,3 @@ function normalizeWindowsPath(input: string): string | null { return null } - -function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined { - if (!env) { - return env - } - - const next = { ...env } - rewriteOpencodePluginPathForWsl(next) - - const keysToPropagate = Array.from( - new Set([ - ...(propagateEnvKeys ?? []).filter((key) => next[key] !== undefined), - ...Array.from(WSL_PATH_ENV_KEYS).filter((key) => next[key] !== undefined), - ]), - ) - if (keysToPropagate.length === 0) { - return next - } - - const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0) - const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry])) - - for (const key of keysToPropagate) { - const existingEntry = byName.get(key) - if (existingEntry) { - byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key))) - continue - } - byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key) - } - - next.WSLENV = Array.from(byName.values()).join(":") - return next -} - -function rewriteOpencodePluginPathForWsl(env: NodeJS.ProcessEnv) { - const content = env.OPENCODE_CONFIG_CONTENT - if (!content) { - return - } - - const match = content.match(CODENOMAD_PLUGIN_FILE_SPEC_REGEX) - const hostPath = match?.[2] - if (!hostPath) { - return - } - - env.OPENCODE_CONFIG_CONTENT = content.replace(hostPath, WSL_PLUGIN_PATH_PLACEHOLDER) - env[WSL_PLUGIN_PATH_ENV] = path.win32.normalize(hostPath) -} - -function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string { - if (!requiresPathTranslation) { - return entry - } - - const [name, rawFlags = ""] = entry.split("/") - if (rawFlags.includes("p")) { - return entry - } - - return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p` -} - -function escapeRegex(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts deleted file mode 100644 index 803070156..000000000 --- a/packages/server/src/workspaces/workspace-identity.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { realpath, stat } from "node:fs/promises" -import path from "node:path" - -export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string { - const pathApi = platform === "win32" ? path.win32 : path.posix - const normalized = pathApi.normalize(value) - return platform === "win32" ? normalized.toLowerCase() : normalized -} - -export async function resolveWorkspaceIdentity( - folder: string, - rootDir: string, -): Promise<{ workspacePath: string; identityKey: string }> { - const submittedPath = path.isAbsolute(folder) ? path.normalize(folder) : path.resolve(rootDir, folder) - - try { - const workspacePath = path.normalize(await realpath(submittedPath)) - const metadata = await stat(workspacePath, { bigint: true }) - return { - workspacePath, - identityKey: metadata.ino > 0n - ? `fs:${metadata.dev.toString()}:${metadata.ino.toString()}` - : normalizeWorkspaceIdentityPath(workspacePath), - } - } catch { - // Preserve the existing launch behavior when the path cannot be resolved yet. - return { - workspacePath: submittedPath, - identityKey: normalizeWorkspaceIdentityPath(submittedPath), - } - } -} diff --git a/packages/server/src/workspaces/worktree-directory.test.ts b/packages/server/src/workspaces/worktree-directory.test.ts new file mode 100644 index 000000000..c66032ff6 --- /dev/null +++ b/packages/server/src/workspaces/worktree-directory.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { realpath } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { test } from "node:test" +import { isPathWithinWorktree, resolveOwnedWorktreePath } from "./worktree-directory" + +test("resolves nested and junction paths to their canonical owning worktree", async (t) => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-path-")) + t.after(() => rmSync(temp, { recursive: true, force: true })) + const root = path.join(temp, "repo") + const worktree = path.join(root, ".codenomad", "worktrees", "doomed") + const alias = path.join(temp, "alias") + mkdirSync(root) + execFileSync("git", ["init", "--initial-branch=main", root]) + writeFileSync(path.join(root, "README.md"), "test\n") + execFileSync("git", ["-C", root, "add", "README.md"]) + execFileSync("git", ["-C", root, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) + const workspaceId = `workspace-${Date.now()}` + assert.equal((await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, directory: root }))?.slug, "root") + mkdirSync(path.dirname(worktree), { recursive: true }) + execFileSync("git", ["-C", root, "worktree", "add", "-b", "doomed", worktree]) + mkdirSync(path.join(worktree, "nested")) + symlinkSync(worktree, alias, "junction") + const external = path.join(temp, "external") + const dangling = path.join(worktree, "dangling") + mkdirSync(external) + symlinkSync(external, dangling, "junction") + rmSync(external, { recursive: true }) + + const resolved = await resolveOwnedWorktreePath({ + workspaceId, + workspacePath: root, + directory: path.join(alias, "nested"), + }) + + assert.equal(resolved?.slug, "doomed") + assert.equal(resolved?.directory, await realpath(path.join(worktree, "nested"))) + assert.equal((await resolveOwnedWorktreePath({ + workspaceId, + workspacePath: root, + directory: path.join(worktree, "missing", "nested"), + }))?.slug, "doomed") + assert.equal(await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, directory: path.join(dangling, "nested") }), null) + assert.equal(isPathWithinWorktree("\\\\wsl.localhost\\Ubuntu\\repo\\Foo", "\\\\wsl.localhost\\Ubuntu\\repo\\foo\\nested"), false) + assert.equal(isPathWithinWorktree("\\\\wsl.localhost\\Ubuntu\\repo\\Foo", "\\\\wsl.localhost\\Ubuntu\\repo\\Foo\\nested"), true) + assert.equal(isPathWithinWorktree("\\\\WSL.LOCALHOST\\ubuntu\\repo\\Foo", "\\\\wsl.localhost\\Ubuntu\\repo\\Foo\\nested"), true) +}) diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts index 144aed98d..5348f1c3b 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -1,4 +1,5 @@ -import { realpath } from "fs/promises" +import { lstat, realpath } from "fs/promises" +import path from "node:path" import type { LogLike } from "./git-worktrees" import { listWorktrees, resolveRepoRoot } from "./git-worktrees" @@ -6,10 +7,12 @@ type WorktreeCacheEntry = { expiresAt: number repoRoot: string worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }> + resolvedDirectories: Map } -const WORKTREE_CACHE_TTL_MS = 2000 +const WORKTREE_CACHE_TTL_MS = 10_000 const worktreeCache = new Map() +const worktreeLoads = new Map>() async function normalizeDirectoryPath(directory: string): Promise { const trimmed = (directory ?? "").trim() @@ -28,21 +31,37 @@ async function getCachedWorktrees(params: { workspaceId: string; workspacePath: return cached } - const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) - const entry: WorktreeCacheEntry = { - expiresAt: now + WORKTREE_CACHE_TTL_MS, - repoRoot, - worktrees: await Promise.all( - worktrees.map(async (wt) => ({ - slug: wt.slug, - directory: wt.directory, - normalizedDirectory: await normalizeDirectoryPath(wt.directory), - })), - ), + const pending = worktreeLoads.get(params.workspaceId) + if (pending) return pending + + const load = (async () => { + const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) + const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) + const entry: WorktreeCacheEntry = { + expiresAt: Date.now() + WORKTREE_CACHE_TTL_MS, + repoRoot, + worktrees: await Promise.all( + worktrees.map(async (wt) => ({ + slug: wt.slug, + directory: wt.directory, + normalizedDirectory: await normalizeDirectoryPath(wt.directory), + })), + ), + resolvedDirectories: new Map(), + } + worktreeCache.set(params.workspaceId, entry) + return entry + })() + worktreeLoads.set(params.workspaceId, load) + try { + return await load + } finally { + if (worktreeLoads.get(params.workspaceId) === load) worktreeLoads.delete(params.workspaceId) } - worktreeCache.set(params.workspaceId, entry) - return entry +} + +export function invalidateWorktreeCache(workspaceId: string): void { + worktreeCache.delete(workspaceId) } export async function resolveWorktreeDirectory(params: { @@ -61,7 +80,7 @@ export async function resolveWorktreeDirectory(params: { return match.directory } - worktreeCache.delete(params.workspaceId) + invalidateWorktreeCache(params.workspaceId) const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, @@ -89,7 +108,7 @@ export async function resolveWorktreeSlugForDirectory(params: { return match.slug } - worktreeCache.delete(params.workspaceId) + invalidateWorktreeCache(params.workspaceId) const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, @@ -97,3 +116,84 @@ export async function resolveWorktreeSlugForDirectory(params: { }) return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null } + +async function resolveDirectoryPath(directory: string): Promise { + const trimmed = (directory ?? "").trim() + if (!trimmed) return null + let current = path.resolve(trimmed) + const missing: string[] = [] + while (true) { + try { + return path.join(await realpath(current), ...missing) + } catch { + try { + await lstat(current) + return null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null + } + const parent = path.dirname(current) + if (parent === current) return null + missing.unshift(path.basename(current)) + current = parent + } + } +} + +export function normalizeWslUncPath(directory: string): string | null { + const normalized = directory.replace(/\\/g, "/").replace(/\/+$/, "") + const match = /^(\/\/wsl(?:\.localhost|\$)\/)([^/]+)(.*)$/i.exec(normalized) + return match ? `${match[1].toLowerCase()}${match[2].toLowerCase()}${match[3]}` : null +} + +export function isPathWithinWorktree(root: string, candidate: string): boolean { + const normalizedRoot = normalizeWslUncPath(root) + if (normalizedRoot) { + const normalizedCandidate = normalizeWslUncPath(candidate) + if (!normalizedCandidate) return false + return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + } + const relative = path.relative(root, candidate) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +export async function resolveOwnedWorktreePath(params: { + workspaceId: string + workspacePath: string + directory: string + logger?: LogLike +}): Promise<{ slug: string; directory: string; worktreeDirectory: string } | null> { + const target = await resolveDirectoryPath(params.directory) + if (!target) return null + const find = (worktrees: WorktreeCacheEntry["worktrees"]) => worktrees + .filter((worktree) => isPathWithinWorktree(worktree.normalizedDirectory, target)) + .sort((left, right) => right.normalizedDirectory.length - left.normalizedDirectory.length)[0] + let entry = await getCachedWorktrees(params) + const known = entry.resolvedDirectories.get(target) + if (known) return known + let match = find(entry.worktrees) + if (!match || (match.slug === "root" && match.normalizedDirectory !== target)) { + invalidateWorktreeCache(params.workspaceId) + entry = await getCachedWorktrees(params) + match = find(entry.worktrees) + } + const resolved = match ? { slug: match.slug, directory: target, worktreeDirectory: match.normalizedDirectory } : null + if (resolved) entry.resolvedDirectories.set(target, resolved) + return resolved +} + +export async function isPathOwnedByWorktree(params: { + workspaceId: string + workspacePath: string + candidate: string + logger?: LogLike +}): Promise { + let target: string + try { + target = await realpath(params.candidate) + } catch { + return false + } + const cached = await getCachedWorktrees(params) + return cached.worktrees.some((worktree) => isPathWithinWorktree(worktree.normalizedDirectory, target)) +} diff --git a/packages/server/src/workspaces/worktree-map.ts b/packages/server/src/workspaces/worktree-map.ts index b54f0dc29..0dd674af0 100644 --- a/packages/server/src/workspaces/worktree-map.ts +++ b/packages/server/src/workspaces/worktree-map.ts @@ -1,20 +1,8 @@ -import fs from "fs" import { promises as fsp } from "fs" import path from "path" -import type { WorktreeMap } from "../api-types" import { resolveRepoRoot } from "./git-worktrees" import type { LogLike } from "./git-worktrees" -const DEFAULT_MAP: WorktreeMap = { - version: 1, - defaultWorktreeSlug: "root", - parentSessionWorktreeSlug: {}, -} - -function getMapPath(repoRoot: string): string { - return path.join(repoRoot, ".codenomad", "worktreeMap.json") -} - function getGitExcludePath(repoRoot: string): string { return path.join(repoRoot, ".git", "info", "exclude") } @@ -27,11 +15,7 @@ async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise { - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger) - const filePath = getMapPath(repoRoot) - try { - const raw = await fsp.readFile(filePath, "utf-8") - const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== "object") { - return DEFAULT_MAP - } - const version = (parsed as any).version - if (version !== 1) { - return DEFAULT_MAP - } - const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root" - const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug - const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {} - return { - version: 1, - defaultWorktreeSlug, - parentSessionWorktreeSlug: { ...mapping }, - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === "ENOENT") { - if (isGitRepo) { - // Best-effort ignore setup on first use. - await ensureGitExclude(repoRoot, logger).catch(() => undefined) - } - return DEFAULT_MAP - } - logger?.warn?.({ err: error, filePath }, "Failed to read worktree map") - return DEFAULT_MAP - } -} - -export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise { - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger) - const filePath = getMapPath(repoRoot) - await fsp.mkdir(path.dirname(filePath), { recursive: true }) - - // Ensure ignore rules are present (local-only). - if (isGitRepo) { - await ensureGitExclude(repoRoot, logger).catch(() => undefined) - } - - if (Object.keys(next.parentSessionWorktreeSlug ?? {}).length === 0) { - await deleteWorktreeMap(workspaceFolder, logger) - return - } - - const payload: WorktreeMap = { - version: 1, - defaultWorktreeSlug: next.defaultWorktreeSlug || "root", - parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {}, - } - - // Write atomically. - const tmpPath = `${filePath}.${process.pid}.tmp` - await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8") - await fsp.rename(tmpPath, filePath) -} - -export async function deleteWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { - const { repoRoot } = await resolveRepoRoot(workspaceFolder, logger) - const filePath = getMapPath(repoRoot) - try { - await fsp.rm(filePath, { force: true }) - } catch (error) { - logger?.warn?.({ err: error, filePath }, "Failed to delete worktree map") - throw error - } -} - -export function worktreeMapExists(repoRoot: string): boolean { - try { - return fs.existsSync(getMapPath(repoRoot)) - } catch { - return false - } -} diff --git a/packages/server/src/workspaces/worktree-session-evacuation.test.ts b/packages/server/src/workspaces/worktree-session-evacuation.test.ts new file mode 100644 index 000000000..2e0219d91 --- /dev/null +++ b/packages/server/src/workspaces/worktree-session-evacuation.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import { evacuateWorktreeSessions, WorktreeDeletionFence } from "./worktree-session-evacuation" + +function session(id: string, directory: string, parentID?: string): SessionInfo { + return { id, parentID, projectID: "project", location: { directory }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo +} + +describe("evacuateWorktreeSessions", () => { + it("serializes deletion attempts for the same worktree", async () => { + const fence = new WorktreeDeletionFence() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const calls: string[] = [] + const first = fence.run("/repo/worktree", ["/repo/worktree"], async () => { + calls.push("first:start") + await gate + calls.push("first:end") + }) + const second = fence.run("/repo/worktree", ["/repo/worktree"], async () => { calls.push("second") }) + + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["first:start"]) + assert.equal(fence.isBlocked("/repo/worktree/"), true) + release() + await Promise.all([first, second]) + assert.deepEqual(calls, ["first:start", "first:end", "second"]) + assert.equal(fence.isBlocked("/repo/worktree"), false) + }) + + it("waits for admitted mutations before deleting and rejects later admission", async () => { + const fence = new WorktreeDeletionFence() + const releaseMutation = fence.enter(["/repo/worktree"]) + assert.ok(releaseMutation) + const calls: string[] = [] + const deletion = fence.run("/repo/worktree", ["/repo/worktree"], async () => { calls.push("delete") }) + + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, []) + assert.equal(fence.enter(["/repo/worktree"]), undefined) + releaseMutation() + await deletion + assert.deepEqual(calls, ["delete"]) + }) + + it("fails deletion closed when an admitted mutation does not finish", async () => { + const fence = new WorktreeDeletionFence(1) + const releaseMutation = fence.enter(["/repo/worktree"]) + assert.ok(releaseMutation) + + await assert.rejects( + fence.run("/repo/worktree", ["/repo/worktree"], async () => {}), + /Timed out waiting for worktree mutations/, + ) + assert.equal(fence.isBlocked("/repo/worktree"), false) + releaseMutation() + }) + + it("finds later-page sessions and waits for their asynchronous moves", async () => { + const moves: Array<{ sessionID: string; directory: string }> = [] + const lists: unknown[] = [] + let listCall = 0 + const root = session("old-root", "/repo/worktree") + const child = session("old-child", "/repo/worktree", root.id) + const grandchild = session("old-grandchild", "/repo/worktree", child.id) + const state = new Map([root, child, grandchild].map((item) => [item.id, item])) + let removed = false + const client = { + project: { + list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }], + }, + session: { + list: async (input: unknown) => { + lists.push(input) + listCall += 1 + if (listCall === 1) return { data: [session("loaded", "/repo")], cursor: { next: "older" } } + if (listCall === 2) return { data: [root, child, grandchild], cursor: {} } + return { data: [session("loaded", "/repo"), ...state.values()], cursor: {} } + }, + active: async () => ({}), + move: async (input: { sessionID: string; directory: string }) => { + moves.push(input) + setImmediate(() => state.set(input.sessionID, { ...state.get(input.sessionID)!, location: { directory: input.directory } })) + }, + }, + } as unknown as OpenCodeClient + + await evacuateWorktreeSessions({ + client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", + remove: async () => { removed = true }, + }) + + assert.deepEqual(moves.map(({ sessionID }) => sessionID), [root.id, child.id, grandchild.id]) + assert.equal(removed, true) + assert.ok(listCall > 3) + assert.ok(lists.every((input: any) => input.project === "project" && input.directory === undefined)) + }) + + it("evacuates sessions whose directory resolves to the target alias", async () => { + const aliased = session("aliased", "/repo/alias") + let current = aliased + let removed = false + const client = { + project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, + session: { + list: async () => ({ data: [current], cursor: {} }), + active: async () => ({}), + move: async (input: { directory: string }) => { current = { ...current, location: { directory: input.directory } } }, + }, + } as unknown as OpenCodeClient + + await evacuateWorktreeSessions({ + client, + projectDirectory: "/repo", + targetDirectory: "/repo/worktree", + rootDirectory: "/repo", + resolveDirectoryIdentity: async (directory) => directory === "/repo/alias" ? "/repo/worktree" : directory, + remove: async () => { removed = true }, + }) + + assert.equal(current.location.directory, "/repo") + assert.equal(removed, true) + }) + + it("evacuates sessions nested under the target worktree identity", async () => { + let current = session("nested", "/repo/worktree/nested") + let removed = false + const client = { + project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, + session: { + list: async () => ({ data: [current], cursor: {} }), + active: async () => ({}), + move: async (input: { directory: string }) => { current = { ...current, location: { directory: input.directory } } }, + }, + } as unknown as OpenCodeClient + + await evacuateWorktreeSessions({ + client, + projectDirectory: "/repo", + targetDirectory: "/repo/worktree", + rootDirectory: "/repo", + resolveDirectoryIdentity: async (directory) => directory.startsWith("/repo/worktree") ? "workspace:worktree" : "workspace:root", + remove: async () => { removed = true }, + }) + + assert.equal(current.location.directory, "/repo") + assert.equal(removed, true) + }) + + it("rolls sessions back when Git removal fails", async () => { + const current = session("session", "/repo/worktree") + const moves: string[] = [] + const client = { + project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, + session: { + list: async () => ({ data: [current], cursor: {} }), + active: async () => ({}), + move: async ({ directory }: { directory: string }) => { + moves.push(directory) + current.location = { directory } + }, + }, + } as unknown as OpenCodeClient + + await assert.rejects(evacuateWorktreeSessions({ + client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", + remove: async () => { throw new Error("Git removal failed") }, + }), /Git removal failed/) + assert.deepEqual(moves, ["/repo", "/repo/worktree"]) + assert.equal(current.location.directory, "/repo/worktree") + }) + + it("re-inventories active sessions immediately before removal", async () => { + const current = session("session", "/repo/worktree") + const intruder = session("intruder", "/repo/worktree") + let listCalls = 0 + let removed = false + const client = { + project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, + session: { + list: async () => { + listCalls += 1 + return { data: listCalls >= 3 ? [current, intruder] : [current], cursor: {} } + }, + active: async () => ({ intruder: { type: "running" } }), + move: async ({ directory }: { directory: string }) => { current.location = { directory } }, + }, + } as unknown as OpenCodeClient + + await assert.rejects(evacuateWorktreeSessions({ + client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", + remove: async () => { removed = true }, + }), /Active sessions block worktree deletion: intruder/) + + assert.equal(removed, false) + assert.equal(current.location.directory, "/repo/worktree") + }) +}) diff --git a/packages/server/src/workspaces/worktree-session-evacuation.ts b/packages/server/src/workspaces/worktree-session-evacuation.ts new file mode 100644 index 000000000..394db46c6 --- /dev/null +++ b/packages/server/src/workspaces/worktree-session-evacuation.ts @@ -0,0 +1,203 @@ +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import { normalizeWslUncPath } from "./worktree-directory" + +const PAGE_SIZE = 200 +const MAX_PAGES = 10_000 +const MAX_SESSIONS = 1_000_000 +const MUTATION_DRAIN_TIMEOUT_MS = 30_000 + +function normalizeDirectory(directory: string): string { + const wsl = normalizeWslUncPath(directory) + if (wsl) return wsl + const normalized = directory.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/" + return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized +} + +export class WorktreeDeletionFence { + private readonly queues = new Map>() + private readonly blocked = new Map() + private readonly active = new Map() + private readonly idleWaiters = new Map void>>() + + constructor(private readonly mutationDrainTimeoutMs = MUTATION_DRAIN_TIMEOUT_MS) {} + + isBlocked(directory: string): boolean { + return this.blocked.has(normalizeDirectory(directory)) + } + + enter(directories: string[]): (() => void) | undefined { + const normalized = [...new Set(directories.map(normalizeDirectory))] + if (normalized.some((directory) => this.blocked.has(directory))) return undefined + for (const directory of normalized) this.active.set(directory, (this.active.get(directory) ?? 0) + 1) + + let released = false + return () => { + if (released) return + released = true + for (const directory of normalized) { + const count = this.active.get(directory) ?? 0 + if (count > 1) { + this.active.set(directory, count - 1) + continue + } + this.active.delete(directory) + for (const resolve of this.idleWaiters.get(directory) ?? []) resolve() + this.idleWaiters.delete(directory) + } + } + } + + run(key: string, directories: string[], operation: () => Promise): Promise { + const normalizedKey = normalizeDirectory(key) + const blocked = [...new Set(directories.map(normalizeDirectory))] + for (const directory of blocked) this.blocked.set(directory, (this.blocked.get(directory) ?? 0) + 1) + + const previous = this.queues.get(normalizedKey) ?? Promise.resolve() + const current = previous.catch(() => {}).then(async () => { + await Promise.all(blocked.map((directory) => this.waitForIdle(directory))) + return operation() + }) + this.queues.set(normalizedKey, current) + return current.finally(() => { + for (const directory of blocked) { + const count = this.blocked.get(directory) ?? 0 + if (count > 1) this.blocked.set(directory, count - 1) + else this.blocked.delete(directory) + } + if (this.queues.get(normalizedKey) === current) this.queues.delete(normalizedKey) + }) + } + + private waitForIdle(directory: string): Promise { + if (!this.active.has(directory)) return Promise.resolve() + return new Promise((resolve, reject) => { + const waiters = this.idleWaiters.get(directory) ?? new Set() + const done = () => { + clearTimeout(timer) + resolve() + } + waiters.add(done) + this.idleWaiters.set(directory, waiters) + const timer = setTimeout(() => { + waiters.delete(done) + if (!waiters.size) this.idleWaiters.delete(directory) + reject(new Error("Timed out waiting for worktree mutations to finish")) + }, this.mutationDrainTimeoutMs) + }) + } +} + +async function inventorySessions(client: OpenCodeClient, project: string): Promise { + const sessions = new Map() + const cursors = new Set() + let cursor: string | undefined + + for (let pageCount = 0; pageCount < MAX_PAGES; pageCount += 1) { + const page = await client.session.list({ project, limit: PAGE_SIZE, order: "asc", cursor }) + for (const session of page.data) { + sessions.set(session.id, session) + if (sessions.size > MAX_SESSIONS) throw new Error("Session inventory exceeded its safety limit") + } + + cursor = page.cursor.next ?? undefined + if (!cursor) return Array.from(sessions.values()) + if (cursors.has(cursor)) throw new Error(`Repeated session inventory cursor: ${cursor}`) + cursors.add(cursor) + } + + throw new Error("Session inventory exceeded its page limit") +} + +async function waitForInventory( + client: OpenCodeClient, + project: string, + predicate: (sessions: SessionInfo[]) => boolean | Promise, +): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await predicate(await inventorySessions(client, project))) return + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error("Timed out waiting for session moves") +} + +export async function evacuateWorktreeSessions(params: { + client: OpenCodeClient + projectDirectory: string + targetDirectory: string + rootDirectory: string + resolveDirectoryIdentity?: (directory: string) => Promise + remove: () => Promise +}): Promise { + const identity = async (directory: string) => normalizeDirectory( + await params.resolveDirectoryIdentity?.(directory) ?? directory, + ) + const target = await identity(params.targetDirectory) + const matchesTarget = async (directory: string) => await identity(directory) === target + const projects = await params.client.project.list() + let project: (typeof projects)[number] | undefined + for (const candidate of projects) { + if (normalizeDirectory(candidate.canonical) === normalizeDirectory(params.projectDirectory) + || (await Promise.all(candidate.sandboxes.map(matchesTarget))).some(Boolean)) { + project = candidate + break + } + } + if (!project) throw new Error("Unable to resolve the OpenCode project before deleting worktree") + const sessions = await inventorySessions(params.client, project.id) + const affected = (await Promise.all(sessions.map(async (session) => ( + await matchesTarget(session.location.directory) ? session : undefined + )))).filter((session): session is SessionInfo => Boolean(session)) + const assertInactive = async (candidates = affected) => { + const active = await params.client.session.active() + const blockers = candidates.filter((session) => Object.prototype.hasOwnProperty.call(active, session.id)) + if (blockers.length) throw new Error(`Active sessions block worktree deletion: ${blockers.map((session) => session.id).join(", ")}`) + } + await assertInactive() + + const moved: SessionInfo[] = [] + try { + for (const session of affected) { + await assertInactive() + const original = { ...session, location: { ...session.location } } + await params.client.session.move({ sessionID: session.id, directory: params.rootDirectory }) + moved.push(original) + } + await waitForInventory(params.client, project.id, async (current) => ( + !(await Promise.all(current.map((session) => matchesTarget(session.location.directory)))).some(Boolean) + )) + const finalInventory = await inventorySessions(params.client, project.id) + const finalAffected = (await Promise.all(finalInventory.map(async (session) => ( + await matchesTarget(session.location.directory) ? session : undefined + )))).filter((session): session is SessionInfo => Boolean(session)) + await assertInactive(finalAffected) + if (finalAffected.length) throw new Error("Sessions appeared in the worktree during deletion") + await params.remove() + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const session of moved.reverse()) { + try { + await params.client.session.move({ + sessionID: session.id, + directory: session.location.directory, + workspaceID: session.location.workspaceID, + }) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + try { + const expected = new Set(moved.map((session) => session.id)) + await waitForInventory(params.client, project.id, async (current) => { + const restored = current.filter((session) => expected.has(session.id)) + return restored.length === expected.size + && (await Promise.all(restored.map((session) => matchesTarget(session.location.directory)))).every(Boolean) + }) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + if (rollbackErrors.length) { + throw new AggregateError([error, ...rollbackErrors], "Session evacuation failed and could not be rolled back") + } + throw error + } +} diff --git a/packages/server/src/workspaces/wsl-opencode-service.test.ts b/packages/server/src/workspaces/wsl-opencode-service.test.ts new file mode 100644 index 000000000..6ba0f5fe6 --- /dev/null +++ b/packages/server/src/workspaces/wsl-opencode-service.test.ts @@ -0,0 +1,318 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + WslOpenCodeService, + type WslOpenCodeServiceDependencies, +} from "./wsl-opencode-service" + +type ExecCall = { + file: string + args: string[] + options: Parameters[2] +} + +const url = "http://127.0.0.1:4321" + +describe("WslOpenCodeService", () => { + it("discovers stopped and running services with exact CLI arguments and no shell", async () => { + const stopped = harness({ status: "stopped\n" }) + assert.equal(await stopped.service.discover(), undefined) + assert.equal(stopped.calls.length, 1) + assert.deepEqual(stopped.calls[0]?.args, [ + "--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "status", + ]) + + const running = harness({ status: `${url}\n`, password: "secret\n" }) + assert.deepEqual(await running.service.discover(), { + url, + auth: { type: "basic", username: "opencode", password: "secret" }, + }) + assert.deepEqual(running.calls.map((call) => call.args.slice(4)), [ + ["service", "status"], + ["service", "get", "password"], + ]) + for (const call of running.calls) { + assert.equal(call.file, "wsl.exe") + assert.equal(call.options.shell, false) + assert.equal(call.options.windowsHide, true) + assert.equal(call.options.maxBuffer, 64 * 1024) + assert.ok(call.options.timeout > 0 && call.options.timeout <= 500) + assert.equal("cwd" in call.options, false) + } + }) + + it("starts through the Linux CLI, fetches the password, and authenticates Windows health", async () => { + let healthRequest: { url: string; authorization: string | null } | undefined + const test = harness({ start: `${url}\r\n`, password: "start-secret\r\n" }, { + fetch: async (input, init) => { + healthRequest = { + url: String(input), + authorization: new Headers(init?.headers).get("authorization"), + } + return Response.json({ healthy: true, version: "2.0.0", pid: 987654 }) + }, + }) + + const endpoint = await test.service.ensure() + + assert.deepEqual(test.calls.map((call) => call.args.slice(4)), [ + ["service", "start"], + ["service", "get", "password"], + ]) + assert.deepEqual(healthRequest, { + url: `${url}/api/health`, + authorization: `Basic ${Buffer.from("opencode:start-secret").toString("base64")}`, + }) + assert.deepEqual(endpoint, { + url, + auth: { type: "basic", username: "opencode", password: "start-secret" }, + }) + assert.equal("pid" in endpoint, false) + }) + + it("passes startup environment only through wsl --exec env for a missing service", async () => { + const test = harness( + { status: "stopped\n", start: `${url}\n`, password: "secret\n" }, + {}, + 500, + { PROVIDER_TOKEN: "value with spaces", NODE_EXTRA_CA_CERTS: "/ca.pem" }, + ) + + assert.equal(await test.service.discover(), undefined) + await test.service.ensure() + assert.deepEqual(test.calls.map((call) => call.args), [ + ["--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "status"], + [ + "--distribution", "Ubuntu", "--exec", "env", + "NODE_EXTRA_CA_CERTS=/ca.pem", "PROVIDER_TOKEN=value with spaces", + "/home/dev/opencode2", "service", "start", + ], + ["--distribution", "Ubuntu", "--exec", "/home/dev/opencode2", "service", "get", "password"], + ]) + }) + + it("rejects malformed, multiline, non-loopback, and path-bearing service URLs", async () => { + for (const invalid of [ + "not-a-url\n", + `${url}\nhttp://127.0.0.1:4322\n`, + "http://192.0.2.1:4321\n", + `${url}/private\n`, + `http://user:pass@127.0.0.1:4321\n`, + `${url}?private=true\n`, + `${url}#private\n`, + ` ${url}\n`, + ]) { + await assert.rejects(harness({ status: invalid }).service.discover(), /invalid loopback URL|multiline|malformed/) + } + }) + + it("rejects empty and multiline passwords", async () => { + await assert.rejects(harness({ status: `${url}\n`, password: "\n" }).service.discover(), /empty password/) + await assert.rejects( + harness({ status: `${url}\n`, password: "first\nsecond\n" }).service.discover(), + /multiline password/, + ) + }) + + it("fails closed with actionable forwarding or health failures", async () => { + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => { throw new Error("ECONNREFUSED") }, + }).service.discover(), /Enable WSL localhost forwarding/) + + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(null, { status: 401 }), + }).service.discover(), /authentication failed.*401/) + + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(null, { status: 503 }), + }).service.discover(), /health check failed.*503/) + + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => Response.json({ healthy: false, pid: 12 }), + }).service.discover(), /not API-compatible/) + }) + + it("requires the complete compatible health shape", async () => { + for (const health of [ + { healthy: true, pid: 1 }, + { healthy: true, version: "", pid: 1 }, + { healthy: true, version: " ", pid: 1 }, + { healthy: true, version: "2.0.0", pid: 0 }, + { healthy: true, version: "2.0.0", pid: 1.5 }, + { healthy: true, version: "2.0.0", pid: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => Response.json(health), + }).service.discover(), /not API-compatible/) + } + }) + + it("streams at most 64 KiB of health data and cancels an oversized body", async () => { + const valid = JSON.stringify({ healthy: true, version: "2.0.0", pid: 123 }).padEnd(64 * 1024, " ") + await harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(valid), + }).service.discover() + + let cancelled = false + const oversized = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024)) + controller.enqueue(new Uint8Array([1])) + }, + cancel() { cancelled = true }, + }) + await assert.rejects(harness({ status: `${url}\n`, password: "secret\n" }, { + fetch: async () => new Response(oversized), + }).service.discover(), /invalid health response/) + assert.equal(cancelled, true) + }) + + it("redacts every error field from password retrieval failures and timeouts", async () => { + const secret = "PASSWORD_SENTINEL_DO_NOT_LEAK" + const failure = Object.assign(new Error(secret), { + name: secret, + stack: secret, + code: secret, + stdout: secret, + stderr: secret, + cause: secret, + signal: secret, + cmd: secret, + }) + const failed = harness({}, { + execFile: async (_file, args) => { + if (args[args.length - 1] === "status") return { stdout: `${url}\n`, stderr: "" } + throw failure + }, + }) + await assert.rejects(failed.service.discover(), (error: Error) => { + assert.match(error.message, /password retrieval failed/) + assert.equal(error.message.includes(secret), false) + return true + }) + + const nonzero = harness({}, { + execFile: async (_file, args) => { + if (args[args.length - 1] === "status") return { stdout: `${url}\n`, stderr: "" } + throw Object.assign(new Error(secret), { code: 7, stdout: secret, stderr: secret }) + }, + }) + await assert.rejects(nonzero.service.discover(), (error: Error) => { + assert.match(error.message, /password retrieval failed \(exit code 7\)/) + assert.equal(error.message.includes(secret), false) + return true + }) + + const timeout = harness({}, { + execFile: async (_file, args) => args[args.length - 1] === "status" + ? { stdout: `${url}\n`, stderr: "" } + : new Promise(() => {}), + }, 15) + await assert.rejects(timeout.service.discover(), (error: Error) => { + assert.match(error.message, /password retrieval failed/) + assert.equal(error.message.includes(secret), false) + return true + }) + }) + + it("bounds shared deadlines and nonzero command errors", async () => { + const commandTimeouts: number[] = [] + const shared = harness({}, { + execFile: async (_file, args, options) => { + commandTimeouts.push(options.timeout) + if (args[args.length - 1] === "status") { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: `${url}\n`, stderr: "" } + } + return { stdout: "secret\n", stderr: "" } + }, + }, 100) + await shared.service.discover() + assert.ok((commandTimeouts[1] ?? 100) < (commandTimeouts[0] ?? 0)) + + const timeout = harness({}, { + execFile: async () => new Promise(() => {}), + }, 15) + await assert.rejects(timeout.service.discover(), /timed out after 15ms/) + + const output = "x".repeat(100_000) + const failure = Object.assign(new Error(output), { code: 7, stdout: output, stderr: output }) + const nonzero = harness({}, { + execFile: async () => { throw failure }, + }) + await assert.rejects(nonzero.service.discover(), (error: Error) => { + assert.match(error.message, /code 7/) + assert.ok(error.message.length < 1_200) + return true + }) + }) + + it("shares an absolute deadline across discover and ensure", async () => { + const commandTimeouts: number[] = [] + const test = harness({}, { + execFile: async (_file, args, options) => { + commandTimeouts.push(options.timeout) + if (args[args.length - 1] === "status") { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: "stopped\n", stderr: "" } + } + return { stdout: args[args.length - 1] === "start" ? `${url}\n` : "secret\n", stderr: "" } + }, + }, 200) + const deadlineAt = Date.now() + 200 + + assert.equal(await test.service.discover(deadlineAt), undefined) + await test.service.ensure(deadlineAt) + + assert.ok((commandTimeouts[1] ?? 200) < (commandTimeouts[0] ?? 0)) + assert.ok((commandTimeouts[2] ?? 200) <= (commandTimeouts[1] ?? 0)) + }) + + it("uses only status, start, and password service commands", async () => { + const test = harness({ status: `${url}\n`, start: `${url}\n`, password: "secret\n" }) + await test.service.discover() + await test.service.ensure() + + const tokens = test.calls.flatMap((call) => call.args) + for (const prohibited of [ + "stop", "restart", "serve", "--service", "--port", "--state", "--db", "pid", "process.kill", + ]) { + assert.equal(tokens.includes(prohibited), false, prohibited) + } + assert.deepEqual(test.calls.map((call) => call.args.slice(4)), [ + ["service", "status"], + ["service", "get", "password"], + ["service", "start"], + ["service", "get", "password"], + ]) + }) +}) + +function harness( + output: Partial>, + overrides: Partial = {}, + timeoutMs = 500, + startupEnvironment: NodeJS.ProcessEnv = {}, +) { + const calls: ExecCall[] = [] + const dependencies: WslOpenCodeServiceDependencies = { + execFile: async (file, args, options) => { + calls.push({ file, args, options }) + const operation = args[args.length - 1] + const key = operation === "status" || operation === "start" ? operation : "password" + return { stdout: output[key] ?? "", stderr: "" } + }, + fetch: async () => Response.json({ healthy: true, version: "2.0.0", pid: 123 }), + ...overrides, + } + return { + calls, + service: new WslOpenCodeService({ + distro: "Ubuntu", + binary: "/home/dev/opencode2", + startupEnvironment, + timeoutMs, + }, dependencies), + } +} diff --git a/packages/server/src/workspaces/wsl-opencode-service.ts b/packages/server/src/workspaces/wsl-opencode-service.ts new file mode 100644 index 000000000..a216dfb5f --- /dev/null +++ b/packages/server/src/workspaces/wsl-opencode-service.ts @@ -0,0 +1,52 @@ +import { + OpenCodeCliService, + type OpenCodeCliServiceDependencies, + type ServiceExecOptions, +} from "./opencode-cli-service" +import { daemonProcessEnvironment } from "./host-opencode-service" + +const DEFAULT_TIMEOUT_MS = 30_000 + +export type WslOpenCodeServiceDependencies = OpenCodeCliServiceDependencies + +export interface WslOpenCodeServiceOptions { + distro: string + binary: string + startupEnvironment?: NodeJS.ProcessEnv + timeoutMs?: number +} + +export class WslOpenCodeService extends OpenCodeCliService { + constructor( + options: WslOpenCodeServiceOptions, + dependencies: Partial = {}, + ) { + super({ + label: "WSL", + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + command: (args, start) => ({ + command: "wsl.exe", + args: [ + "--distribution", options.distro, "--exec", + ...(start && Object.keys(options.startupEnvironment ?? {}).length + ? ["env", ...environmentAssignments(options.startupEnvironment!)] + : []), + options.binary, + ...args, + ], + options: {}, + ...(start ? { env: daemonProcessEnvironment() } : {}), + }), + unreachableMessage: (url) => `Cannot reach the WSL OpenCode service from Windows at ${url}. ` + + "Enable WSL localhost forwarding or configure a Windows opencode2 binary.", + }, dependencies) + } +} + +function environmentAssignments(environment: NodeJS.ProcessEnv): string[] { + return Object.entries(environment) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value ?? ""}`) +} + +export type { ServiceExecOptions } diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 434461633..770d6fc8f 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -512,12 +512,14 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tempfile", "url", "uuid", @@ -1014,15 +1016,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 +1642,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 +1767,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -3403,11 +3376,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 +3387,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -3428,14 +3398,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 +3438,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -4438,6 +4406,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -5231,19 +5215,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" diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js index daccde793..c58339e48 100644 --- a/packages/tauri-app/scripts/prebuild.js +++ b/packages/tauri-app/scripts/prebuild.js @@ -19,8 +19,6 @@ const serverInstallCommand = "npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false" const serverDevInstallCommand = "npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" -const pluginDevInstallCommand = - "npm install --workspace @codenomad/codenomad-opencode-plugin --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" const uiDevInstallCommand = "npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad" @@ -46,12 +44,6 @@ const serverBuildDependencyPaths = [ path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"), ] -const pluginRoot = path.resolve(root, "..", "opencode-plugin") -const pluginBuildDependencyPaths = [ - path.join(pluginRoot, "node_modules", "typescript", "package.json"), - path.join(pluginRoot, "node_modules", "@types", "node", "package.json"), -] - const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite") async function ensureMonacoAssets() { @@ -125,19 +117,6 @@ function ensureServerDevDependencies() { }) } -function ensurePluginDevDependencies() { - if (pluginBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) { - return - } - - console.log("[prebuild] ensuring OpenCode plugin build dependencies...") - execSync(pluginDevInstallCommand, { - cwd: workspaceRoot, - stdio: "inherit", - env: envWithRootBin, - }) -} - function ensureServerDependencies() { if (fs.existsSync(braceExpansionPath)) { return @@ -268,7 +247,6 @@ function copyUiLoadingAssets() { ;(async () => { ensureServerDevDependencies() - ensurePluginDevDependencies() ensureUiDevDependencies() await ensureMonacoAssets() ensureRollupPlatformBinary() diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 8c0c22ff4..64762a114 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -13,8 +13,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" base64 = "0.22" +sha2 = "0.10" 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" @@ -30,6 +31,7 @@ tauri-plugin-global-shortcut = "2" url = "2" uuid = { version = "1", features = ["v4"] } tauri-plugin-notification = "2" +tauri-plugin-single-instance = "2" [target.'cfg(windows)'.dependencies] webview2-com = "=0.38.2" diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index 8c4baa63e..c36e77283 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -1,5 +1,6 @@ fn main() { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"); + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"); let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by Cargo"); let manifest_path = std::path::Path::new(&manifest_dir); let bundled_resources = std::path::Path::new(&out_dir) @@ -16,10 +17,47 @@ fn main() { std::fs::remove_dir_all(&bundled_resources).expect("clean bundled resources output"); } - println!("cargo:rerun-if-changed={}", manifest_path.join("tauri.conf.json").display()); - println!("cargo:rerun-if-changed={}", resources_root.join("node").display()); - println!("cargo:rerun-if-changed={}", resources_root.join("server").display()); - println!("cargo:rerun-if-changed={}", resources_root.join("ui-loading").display()); + println!( + "cargo:rerun-if-changed={}", + manifest_path.join("tauri.conf.json").display() + ); + println!( + "cargo:rerun-if-changed={}", + resources_root.join("node").display() + ); + println!( + "cargo:rerun-if-changed={}", + resources_root.join("server").display() + ); + println!( + "cargo:rerun-if-changed={}", + resources_root.join("ui-loading").display() + ); - tauri_build::build() + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + tauri_build::AppManifest::new().commands(&[ + "cli_get_status", + "cli_restart", + "wake_lock_start", + "wake_lock_stop", + "needs_local_certificate_install", + "open_remote_window", + "client_state_claim_access", + "client_state_load", + "client_state_save", + "client_state_commit_partitions", + "client_state_load_partition", + "client_state_set_restore_enabled", + "client_state_clear", + "client_state_renderer_flushed", + "client_state_navigation_flushed", + "desktop_launch_ready", + "desktop_launch_next_folder", + "desktop_launch_acknowledge_folder", + "install_stable_update", + "open_workspace_target", + "set_workspace_menu_enabled", + ]), + )) + .expect("build Tauri application and command ACL") } diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index e32bfa288..82728cc4c 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -1,21 +1,48 @@ { "$schema": "https://schema.tauri.app/capabilities.json", "identifier": "main-window-native-dialogs", - "description": "Grant the main window access to required core features and native dialog commands.", + "description": "Grant local windows access to required core features and native dialog commands.", "remote": { - "urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"] + "urls": ["http://127.0.0.1:*", "http://localhost:1420", "http://tauri.localhost/*", "https://tauri.localhost/*"] }, - "windows": ["main"], + "windows": ["local-*"], "permissions": [ "core:default", "core:menu:default", "dialog:allow-open", - "opener:allow-default-urls", - "opener:allow-open-url", + { + "identifier": "opener:allow-open-url", + "allow": [ + { "url": "http://*" }, + { "url": "https://*" }, + { "url": "mailto:*" } + ] + }, "notification:allow-is-permission-granted", "notification:allow-request-permission", "notification:allow-notify", "notification:allow-show", - "core:webview:allow-set-webview-zoom" + "core:webview:allow-set-webview-zoom", + "allow-cli-get-status", + "allow-cli-restart", + "allow-wake-lock-start", + "allow-wake-lock-stop", + "allow-needs-local-certificate-install", + "allow-open-remote-window", + "allow-client-state-claim-access", + "allow-client-state-load", + "allow-client-state-save", + "allow-client-state-commit-partitions", + "allow-client-state-load-partition", + "allow-client-state-set-restore-enabled", + "allow-client-state-clear", + "allow-client-state-renderer-flushed", + "allow-client-state-navigation-flushed", + "allow-desktop-launch-ready", + "allow-desktop-launch-next-folder", + "allow-desktop-launch-acknowledge-folder", + "allow-install-stable-update", + "allow-open-workspace-target", + "allow-set-workspace-menu-enabled" ] } diff --git a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json index 07e39db3b..74aadde51 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json +++ b/packages/tauri-app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-cli-get-status":{"identifier":"allow-cli-get-status","description":"Enables the cli_get_status command without any pre-configured scope.","commands":{"allow":["cli_get_status"],"deny":[]}},"allow-cli-restart":{"identifier":"allow-cli-restart","description":"Enables the cli_restart command without any pre-configured scope.","commands":{"allow":["cli_restart"],"deny":[]}},"allow-client-state-claim-access":{"identifier":"allow-client-state-claim-access","description":"Enables the client_state_claim_access command without any pre-configured scope.","commands":{"allow":["client_state_claim_access"],"deny":[]}},"allow-client-state-clear":{"identifier":"allow-client-state-clear","description":"Enables the client_state_clear command without any pre-configured scope.","commands":{"allow":["client_state_clear"],"deny":[]}},"allow-client-state-commit-partitions":{"identifier":"allow-client-state-commit-partitions","description":"Enables the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":["client_state_commit_partitions"],"deny":[]}},"allow-client-state-load":{"identifier":"allow-client-state-load","description":"Enables the client_state_load command without any pre-configured scope.","commands":{"allow":["client_state_load"],"deny":[]}},"allow-client-state-load-partition":{"identifier":"allow-client-state-load-partition","description":"Enables the client_state_load_partition command without any pre-configured scope.","commands":{"allow":["client_state_load_partition"],"deny":[]}},"allow-client-state-navigation-flushed":{"identifier":"allow-client-state-navigation-flushed","description":"Enables the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":["client_state_navigation_flushed"],"deny":[]}},"allow-client-state-renderer-flushed":{"identifier":"allow-client-state-renderer-flushed","description":"Enables the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":["client_state_renderer_flushed"],"deny":[]}},"allow-client-state-save":{"identifier":"allow-client-state-save","description":"Enables the client_state_save command without any pre-configured scope.","commands":{"allow":["client_state_save"],"deny":[]}},"allow-client-state-set-restore-enabled":{"identifier":"allow-client-state-set-restore-enabled","description":"Enables the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":["client_state_set_restore_enabled"],"deny":[]}},"allow-desktop-launch-acknowledge-folder":{"identifier":"allow-desktop-launch-acknowledge-folder","description":"Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_acknowledge_folder"],"deny":[]}},"allow-desktop-launch-next-folder":{"identifier":"allow-desktop-launch-next-folder","description":"Enables the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":["desktop_launch_next_folder"],"deny":[]}},"allow-desktop-launch-ready":{"identifier":"allow-desktop-launch-ready","description":"Enables the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":["desktop_launch_ready"],"deny":[]}},"allow-install-stable-update":{"identifier":"allow-install-stable-update","description":"Enables the install_stable_update command without any pre-configured scope.","commands":{"allow":["install_stable_update"],"deny":[]}},"allow-needs-local-certificate-install":{"identifier":"allow-needs-local-certificate-install","description":"Enables the needs_local_certificate_install command without any pre-configured scope.","commands":{"allow":["needs_local_certificate_install"],"deny":[]}},"allow-open-remote-window":{"identifier":"allow-open-remote-window","description":"Enables the open_remote_window command without any pre-configured scope.","commands":{"allow":["open_remote_window"],"deny":[]}},"allow-open-workspace-target":{"identifier":"allow-open-workspace-target","description":"Enables the open_workspace_target command without any pre-configured scope.","commands":{"allow":["open_workspace_target"],"deny":[]}},"allow-set-workspace-menu-enabled":{"identifier":"allow-set-workspace-menu-enabled","description":"Enables the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":["set_workspace_menu_enabled"],"deny":[]}},"allow-wake-lock-start":{"identifier":"allow-wake-lock-start","description":"Enables the wake_lock_start command without any pre-configured scope.","commands":{"allow":["wake_lock_start"],"deny":[]}},"allow-wake-lock-stop":{"identifier":"allow-wake-lock-stop","description":"Enables the wake_lock_stop command without any pre-configured scope.","commands":{"allow":["wake_lock_stop"],"deny":[]}},"deny-cli-get-status":{"identifier":"deny-cli-get-status","description":"Denies the cli_get_status command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_get_status"]}},"deny-cli-restart":{"identifier":"deny-cli-restart","description":"Denies the cli_restart command without any pre-configured scope.","commands":{"allow":[],"deny":["cli_restart"]}},"deny-client-state-claim-access":{"identifier":"deny-client-state-claim-access","description":"Denies the client_state_claim_access command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_claim_access"]}},"deny-client-state-clear":{"identifier":"deny-client-state-clear","description":"Denies the client_state_clear command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_clear"]}},"deny-client-state-commit-partitions":{"identifier":"deny-client-state-commit-partitions","description":"Denies the client_state_commit_partitions command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_commit_partitions"]}},"deny-client-state-load":{"identifier":"deny-client-state-load","description":"Denies the client_state_load command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load"]}},"deny-client-state-load-partition":{"identifier":"deny-client-state-load-partition","description":"Denies the client_state_load_partition command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_load_partition"]}},"deny-client-state-navigation-flushed":{"identifier":"deny-client-state-navigation-flushed","description":"Denies the client_state_navigation_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_navigation_flushed"]}},"deny-client-state-renderer-flushed":{"identifier":"deny-client-state-renderer-flushed","description":"Denies the client_state_renderer_flushed command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_renderer_flushed"]}},"deny-client-state-save":{"identifier":"deny-client-state-save","description":"Denies the client_state_save command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_save"]}},"deny-client-state-set-restore-enabled":{"identifier":"deny-client-state-set-restore-enabled","description":"Denies the client_state_set_restore_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["client_state_set_restore_enabled"]}},"deny-desktop-launch-acknowledge-folder":{"identifier":"deny-desktop-launch-acknowledge-folder","description":"Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_acknowledge_folder"]}},"deny-desktop-launch-next-folder":{"identifier":"deny-desktop-launch-next-folder","description":"Denies the desktop_launch_next_folder command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_next_folder"]}},"deny-desktop-launch-ready":{"identifier":"deny-desktop-launch-ready","description":"Denies the desktop_launch_ready command without any pre-configured scope.","commands":{"allow":[],"deny":["desktop_launch_ready"]}},"deny-install-stable-update":{"identifier":"deny-install-stable-update","description":"Denies the install_stable_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_stable_update"]}},"deny-needs-local-certificate-install":{"identifier":"deny-needs-local-certificate-install","description":"Denies the needs_local_certificate_install command without any pre-configured scope.","commands":{"allow":[],"deny":["needs_local_certificate_install"]}},"deny-open-remote-window":{"identifier":"deny-open-remote-window","description":"Denies the open_remote_window command without any pre-configured scope.","commands":{"allow":[],"deny":["open_remote_window"]}},"deny-open-workspace-target":{"identifier":"deny-open-workspace-target","description":"Denies the open_workspace_target command without any pre-configured scope.","commands":{"allow":[],"deny":["open_workspace_target"]}},"deny-set-workspace-menu-enabled":{"identifier":"deny-set-workspace-menu-enabled","description":"Denies the set_workspace_menu_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_workspace_menu_enabled"]}},"deny-wake-lock-start":{"identifier":"deny-wake-lock-start","description":"Denies the wake_lock_start command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_start"]}},"deny-wake-lock-stop":{"identifier":"deny-wake-lock-stop","description":"Denies the wake_lock_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["wake_lock_stop"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index 37b0f153c..98a2b2716 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -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"]},"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"]}} \ No newline at end of file +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant local windows access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:1420","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["local-*"],"permissions":["core:default","core:menu:default","dialog:allow-open",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"mailto:*"}]},"notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom","allow-cli-get-status","allow-cli-restart","allow-wake-lock-start","allow-wake-lock-stop","allow-needs-local-certificate-install","allow-open-remote-window","allow-client-state-claim-access","allow-client-state-load","allow-client-state-save","allow-client-state-commit-partitions","allow-client-state-load-partition","allow-client-state-set-restore-enabled","allow-client-state-clear","allow-client-state-renderer-flushed","allow-client-state-navigation-flushed","allow-desktop-launch-ready","allow-desktop-launch-next-folder","allow-desktop-launch-acknowledge-folder","allow-install-stable-update","allow-open-workspace-target","allow-set-workspace-menu-enabled"]},"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"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json index f7ab81742..fab3a590e 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json @@ -344,6 +344,258 @@ "Identifier": { "description": "Permission identifier", "oneOf": [ + { + "description": "Enables the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-get-status", + "markdownDescription": "Enables the cli_get_status command without any pre-configured scope." + }, + { + "description": "Enables the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-restart", + "markdownDescription": "Enables the cli_restart command without any pre-configured scope." + }, + { + "description": "Enables the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-claim-access", + "markdownDescription": "Enables the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Enables the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-clear", + "markdownDescription": "Enables the client_state_clear command without any pre-configured scope." + }, + { + "description": "Enables the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-commit-partitions", + "markdownDescription": "Enables the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load", + "markdownDescription": "Enables the client_state_load command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load-partition", + "markdownDescription": "Enables the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Enables the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-navigation-flushed", + "markdownDescription": "Enables the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-renderer-flushed", + "markdownDescription": "Enables the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-save", + "markdownDescription": "Enables the client_state_save command without any pre-configured scope." + }, + { + "description": "Enables the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-set-restore-enabled", + "markdownDescription": "Enables the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-acknowledge-folder", + "markdownDescription": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-next-folder", + "markdownDescription": "Enables the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-ready", + "markdownDescription": "Enables the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Enables the install_stable_update command without any pre-configured scope.", + "type": "string", + "const": "allow-install-stable-update", + "markdownDescription": "Enables the install_stable_update command without any pre-configured scope." + }, + { + "description": "Enables the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "allow-needs-local-certificate-install", + "markdownDescription": "Enables the needs_local_certificate_install command without any pre-configured scope." + }, + { + "description": "Enables the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "allow-open-remote-window", + "markdownDescription": "Enables the open_remote_window command without any pre-configured scope." + }, + { + "description": "Enables the open_workspace_target command without any pre-configured scope.", + "type": "string", + "const": "allow-open-workspace-target", + "markdownDescription": "Enables the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Enables the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-set-workspace-menu-enabled", + "markdownDescription": "Enables the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-start", + "markdownDescription": "Enables the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-stop", + "markdownDescription": "Enables the wake_lock_stop command without any pre-configured scope." + }, + { + "description": "Denies the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-get-status", + "markdownDescription": "Denies the cli_get_status command without any pre-configured scope." + }, + { + "description": "Denies the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-restart", + "markdownDescription": "Denies the cli_restart command without any pre-configured scope." + }, + { + "description": "Denies the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-claim-access", + "markdownDescription": "Denies the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Denies the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-clear", + "markdownDescription": "Denies the client_state_clear command without any pre-configured scope." + }, + { + "description": "Denies the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-commit-partitions", + "markdownDescription": "Denies the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load", + "markdownDescription": "Denies the client_state_load command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load-partition", + "markdownDescription": "Denies the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Denies the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-navigation-flushed", + "markdownDescription": "Denies the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-renderer-flushed", + "markdownDescription": "Denies the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-save", + "markdownDescription": "Denies the client_state_save command without any pre-configured scope." + }, + { + "description": "Denies the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-set-restore-enabled", + "markdownDescription": "Denies the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-acknowledge-folder", + "markdownDescription": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-next-folder", + "markdownDescription": "Denies the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-ready", + "markdownDescription": "Denies the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Denies the install_stable_update command without any pre-configured scope.", + "type": "string", + "const": "deny-install-stable-update", + "markdownDescription": "Denies the install_stable_update command without any pre-configured scope." + }, + { + "description": "Denies the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "deny-needs-local-certificate-install", + "markdownDescription": "Denies the needs_local_certificate_install command without any pre-configured scope." + }, + { + "description": "Denies the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "deny-open-remote-window", + "markdownDescription": "Denies the open_remote_window command without any pre-configured scope." + }, + { + "description": "Denies the open_workspace_target command without any pre-configured scope.", + "type": "string", + "const": "deny-open-workspace-target", + "markdownDescription": "Denies the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Denies the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-set-workspace-menu-enabled", + "markdownDescription": "Denies the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-start", + "markdownDescription": "Denies the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-stop", + "markdownDescription": "Denies the wake_lock_stop command without any pre-configured scope." + }, { "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "type": "string", diff --git a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json index f7ab81742..fab3a590e 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json @@ -344,6 +344,258 @@ "Identifier": { "description": "Permission identifier", "oneOf": [ + { + "description": "Enables the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-get-status", + "markdownDescription": "Enables the cli_get_status command without any pre-configured scope." + }, + { + "description": "Enables the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "allow-cli-restart", + "markdownDescription": "Enables the cli_restart command without any pre-configured scope." + }, + { + "description": "Enables the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-claim-access", + "markdownDescription": "Enables the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Enables the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-clear", + "markdownDescription": "Enables the client_state_clear command without any pre-configured scope." + }, + { + "description": "Enables the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-commit-partitions", + "markdownDescription": "Enables the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load", + "markdownDescription": "Enables the client_state_load command without any pre-configured scope." + }, + { + "description": "Enables the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-load-partition", + "markdownDescription": "Enables the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Enables the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-navigation-flushed", + "markdownDescription": "Enables the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-renderer-flushed", + "markdownDescription": "Enables the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Enables the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-save", + "markdownDescription": "Enables the client_state_save command without any pre-configured scope." + }, + { + "description": "Enables the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-client-state-set-restore-enabled", + "markdownDescription": "Enables the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-acknowledge-folder", + "markdownDescription": "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-next-folder", + "markdownDescription": "Enables the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Enables the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "allow-desktop-launch-ready", + "markdownDescription": "Enables the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Enables the install_stable_update command without any pre-configured scope.", + "type": "string", + "const": "allow-install-stable-update", + "markdownDescription": "Enables the install_stable_update command without any pre-configured scope." + }, + { + "description": "Enables the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "allow-needs-local-certificate-install", + "markdownDescription": "Enables the needs_local_certificate_install command without any pre-configured scope." + }, + { + "description": "Enables the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "allow-open-remote-window", + "markdownDescription": "Enables the open_remote_window command without any pre-configured scope." + }, + { + "description": "Enables the open_workspace_target command without any pre-configured scope.", + "type": "string", + "const": "allow-open-workspace-target", + "markdownDescription": "Enables the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Enables the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "allow-set-workspace-menu-enabled", + "markdownDescription": "Enables the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-start", + "markdownDescription": "Enables the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Enables the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "allow-wake-lock-stop", + "markdownDescription": "Enables the wake_lock_stop command without any pre-configured scope." + }, + { + "description": "Denies the cli_get_status command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-get-status", + "markdownDescription": "Denies the cli_get_status command without any pre-configured scope." + }, + { + "description": "Denies the cli_restart command without any pre-configured scope.", + "type": "string", + "const": "deny-cli-restart", + "markdownDescription": "Denies the cli_restart command without any pre-configured scope." + }, + { + "description": "Denies the client_state_claim_access command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-claim-access", + "markdownDescription": "Denies the client_state_claim_access command without any pre-configured scope." + }, + { + "description": "Denies the client_state_clear command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-clear", + "markdownDescription": "Denies the client_state_clear command without any pre-configured scope." + }, + { + "description": "Denies the client_state_commit_partitions command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-commit-partitions", + "markdownDescription": "Denies the client_state_commit_partitions command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load", + "markdownDescription": "Denies the client_state_load command without any pre-configured scope." + }, + { + "description": "Denies the client_state_load_partition command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-load-partition", + "markdownDescription": "Denies the client_state_load_partition command without any pre-configured scope." + }, + { + "description": "Denies the client_state_navigation_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-navigation-flushed", + "markdownDescription": "Denies the client_state_navigation_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_renderer_flushed command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-renderer-flushed", + "markdownDescription": "Denies the client_state_renderer_flushed command without any pre-configured scope." + }, + { + "description": "Denies the client_state_save command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-save", + "markdownDescription": "Denies the client_state_save command without any pre-configured scope." + }, + { + "description": "Denies the client_state_set_restore_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-client-state-set-restore-enabled", + "markdownDescription": "Denies the client_state_set_restore_enabled command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-acknowledge-folder", + "markdownDescription": "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_next_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-next-folder", + "markdownDescription": "Denies the desktop_launch_next_folder command without any pre-configured scope." + }, + { + "description": "Denies the desktop_launch_ready command without any pre-configured scope.", + "type": "string", + "const": "deny-desktop-launch-ready", + "markdownDescription": "Denies the desktop_launch_ready command without any pre-configured scope." + }, + { + "description": "Denies the install_stable_update command without any pre-configured scope.", + "type": "string", + "const": "deny-install-stable-update", + "markdownDescription": "Denies the install_stable_update command without any pre-configured scope." + }, + { + "description": "Denies the needs_local_certificate_install command without any pre-configured scope.", + "type": "string", + "const": "deny-needs-local-certificate-install", + "markdownDescription": "Denies the needs_local_certificate_install command without any pre-configured scope." + }, + { + "description": "Denies the open_remote_window command without any pre-configured scope.", + "type": "string", + "const": "deny-open-remote-window", + "markdownDescription": "Denies the open_remote_window command without any pre-configured scope." + }, + { + "description": "Denies the open_workspace_target command without any pre-configured scope.", + "type": "string", + "const": "deny-open-workspace-target", + "markdownDescription": "Denies the open_workspace_target command without any pre-configured scope." + }, + { + "description": "Denies the set_workspace_menu_enabled command without any pre-configured scope.", + "type": "string", + "const": "deny-set-workspace-menu-enabled", + "markdownDescription": "Denies the set_workspace_menu_enabled command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_start command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-start", + "markdownDescription": "Denies the wake_lock_start command without any pre-configured scope." + }, + { + "description": "Denies the wake_lock_stop command without any pre-configured scope.", + "type": "string", + "const": "deny-wake-lock-stop", + "markdownDescription": "Denies the wake_lock_stop command without any pre-configured scope." + }, { "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "type": "string", diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/cli_get_status.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_get_status.toml new file mode 100644 index 000000000..29e0e21f9 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_get_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-cli-get-status" +description = "Enables the cli_get_status command without any pre-configured scope." +commands.allow = ["cli_get_status"] + +[[permission]] +identifier = "deny-cli-get-status" +description = "Denies the cli_get_status command without any pre-configured scope." +commands.deny = ["cli_get_status"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/cli_restart.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_restart.toml new file mode 100644 index 000000000..6ef8a2939 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/cli_restart.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-cli-restart" +description = "Enables the cli_restart command without any pre-configured scope." +commands.allow = ["cli_restart"] + +[[permission]] +identifier = "deny-cli-restart" +description = "Denies the cli_restart command without any pre-configured scope." +commands.deny = ["cli_restart"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_claim_access.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_claim_access.toml new file mode 100644 index 000000000..44d26f347 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_claim_access.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-claim-access" +description = "Enables the client_state_claim_access command without any pre-configured scope." +commands.allow = ["client_state_claim_access"] + +[[permission]] +identifier = "deny-client-state-claim-access" +description = "Denies the client_state_claim_access command without any pre-configured scope." +commands.deny = ["client_state_claim_access"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_clear.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_clear.toml new file mode 100644 index 000000000..2306ce817 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_clear.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-clear" +description = "Enables the client_state_clear command without any pre-configured scope." +commands.allow = ["client_state_clear"] + +[[permission]] +identifier = "deny-client-state-clear" +description = "Denies the client_state_clear command without any pre-configured scope." +commands.deny = ["client_state_clear"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_commit_partitions.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_commit_partitions.toml new file mode 100644 index 000000000..9c1374e64 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_commit_partitions.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-commit-partitions" +description = "Enables the client_state_commit_partitions command without any pre-configured scope." +commands.allow = ["client_state_commit_partitions"] + +[[permission]] +identifier = "deny-client-state-commit-partitions" +description = "Denies the client_state_commit_partitions command without any pre-configured scope." +commands.deny = ["client_state_commit_partitions"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load.toml new file mode 100644 index 000000000..96453e783 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-load" +description = "Enables the client_state_load command without any pre-configured scope." +commands.allow = ["client_state_load"] + +[[permission]] +identifier = "deny-client-state-load" +description = "Denies the client_state_load command without any pre-configured scope." +commands.deny = ["client_state_load"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load_partition.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load_partition.toml new file mode 100644 index 000000000..29b98c4d8 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_load_partition.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-load-partition" +description = "Enables the client_state_load_partition command without any pre-configured scope." +commands.allow = ["client_state_load_partition"] + +[[permission]] +identifier = "deny-client-state-load-partition" +description = "Denies the client_state_load_partition command without any pre-configured scope." +commands.deny = ["client_state_load_partition"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_navigation_flushed.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_navigation_flushed.toml new file mode 100644 index 000000000..5b2142535 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_navigation_flushed.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-navigation-flushed" +description = "Enables the client_state_navigation_flushed command without any pre-configured scope." +commands.allow = ["client_state_navigation_flushed"] + +[[permission]] +identifier = "deny-client-state-navigation-flushed" +description = "Denies the client_state_navigation_flushed command without any pre-configured scope." +commands.deny = ["client_state_navigation_flushed"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_renderer_flushed.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_renderer_flushed.toml new file mode 100644 index 000000000..9cda03fa5 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_renderer_flushed.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-renderer-flushed" +description = "Enables the client_state_renderer_flushed command without any pre-configured scope." +commands.allow = ["client_state_renderer_flushed"] + +[[permission]] +identifier = "deny-client-state-renderer-flushed" +description = "Denies the client_state_renderer_flushed command without any pre-configured scope." +commands.deny = ["client_state_renderer_flushed"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_save.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_save.toml new file mode 100644 index 000000000..262d04c8a --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_save.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-save" +description = "Enables the client_state_save command without any pre-configured scope." +commands.allow = ["client_state_save"] + +[[permission]] +identifier = "deny-client-state-save" +description = "Denies the client_state_save command without any pre-configured scope." +commands.deny = ["client_state_save"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_set_restore_enabled.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_set_restore_enabled.toml new file mode 100644 index 000000000..9bf09e82a --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/client_state_set_restore_enabled.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-client-state-set-restore-enabled" +description = "Enables the client_state_set_restore_enabled command without any pre-configured scope." +commands.allow = ["client_state_set_restore_enabled"] + +[[permission]] +identifier = "deny-client-state-set-restore-enabled" +description = "Denies the client_state_set_restore_enabled command without any pre-configured scope." +commands.deny = ["client_state_set_restore_enabled"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_acknowledge_folder.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_acknowledge_folder.toml new file mode 100644 index 000000000..378a6ae53 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_acknowledge_folder.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-desktop-launch-acknowledge-folder" +description = "Enables the desktop_launch_acknowledge_folder command without any pre-configured scope." +commands.allow = ["desktop_launch_acknowledge_folder"] + +[[permission]] +identifier = "deny-desktop-launch-acknowledge-folder" +description = "Denies the desktop_launch_acknowledge_folder command without any pre-configured scope." +commands.deny = ["desktop_launch_acknowledge_folder"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_next_folder.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_next_folder.toml new file mode 100644 index 000000000..ae7cb50f4 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_next_folder.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-desktop-launch-next-folder" +description = "Enables the desktop_launch_next_folder command without any pre-configured scope." +commands.allow = ["desktop_launch_next_folder"] + +[[permission]] +identifier = "deny-desktop-launch-next-folder" +description = "Denies the desktop_launch_next_folder command without any pre-configured scope." +commands.deny = ["desktop_launch_next_folder"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_ready.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_ready.toml new file mode 100644 index 000000000..f42dd3729 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/desktop_launch_ready.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-desktop-launch-ready" +description = "Enables the desktop_launch_ready command without any pre-configured scope." +commands.allow = ["desktop_launch_ready"] + +[[permission]] +identifier = "deny-desktop-launch-ready" +description = "Denies the desktop_launch_ready command without any pre-configured scope." +commands.deny = ["desktop_launch_ready"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/install_stable_update.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/install_stable_update.toml new file mode 100644 index 000000000..6c20d1031 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/install_stable_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-install-stable-update" +description = "Enables the install_stable_update command without any pre-configured scope." +commands.allow = ["install_stable_update"] + +[[permission]] +identifier = "deny-install-stable-update" +description = "Denies the install_stable_update command without any pre-configured scope." +commands.deny = ["install_stable_update"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml new file mode 100644 index 000000000..8870800b9 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/needs_local_certificate_install.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-needs-local-certificate-install" +description = "Enables the needs_local_certificate_install command without any pre-configured scope." +commands.allow = ["needs_local_certificate_install"] + +[[permission]] +identifier = "deny-needs-local-certificate-install" +description = "Denies the needs_local_certificate_install command without any pre-configured scope." +commands.deny = ["needs_local_certificate_install"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml new file mode 100644 index 000000000..1c8c6e886 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/open_remote_window.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-remote-window" +description = "Enables the open_remote_window command without any pre-configured scope." +commands.allow = ["open_remote_window"] + +[[permission]] +identifier = "deny-open-remote-window" +description = "Denies the open_remote_window command without any pre-configured scope." +commands.deny = ["open_remote_window"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/open_workspace_target.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/open_workspace_target.toml new file mode 100644 index 000000000..b79ca4825 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/open_workspace_target.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-workspace-target" +description = "Enables the open_workspace_target command without any pre-configured scope." +commands.allow = ["open_workspace_target"] + +[[permission]] +identifier = "deny-open-workspace-target" +description = "Denies the open_workspace_target command without any pre-configured scope." +commands.deny = ["open_workspace_target"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/set_workspace_menu_enabled.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/set_workspace_menu_enabled.toml new file mode 100644 index 000000000..ed13d480b --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/set_workspace_menu_enabled.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-set-workspace-menu-enabled" +description = "Enables the set_workspace_menu_enabled command without any pre-configured scope." +commands.allow = ["set_workspace_menu_enabled"] + +[[permission]] +identifier = "deny-set-workspace-menu-enabled" +description = "Denies the set_workspace_menu_enabled command without any pre-configured scope." +commands.deny = ["set_workspace_menu_enabled"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_start.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_start.toml new file mode 100644 index 000000000..1991b6cc6 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_start.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-wake-lock-start" +description = "Enables the wake_lock_start command without any pre-configured scope." +commands.allow = ["wake_lock_start"] + +[[permission]] +identifier = "deny-wake-lock-start" +description = "Denies the wake_lock_start command without any pre-configured scope." +commands.deny = ["wake_lock_start"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_stop.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_stop.toml new file mode 100644 index 000000000..85e75671f --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/wake_lock_stop.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-wake-lock-stop" +description = "Enables the wake_lock_stop command without any pre-configured scope." +commands.allow = ["wake_lock_stop"] + +[[permission]] +identifier = "deny-wake-lock-stop" +description = "Denies the wake_lock_stop command without any pre-configured scope." +commands.deny = ["wake_lock_stop"] diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 78c42dd11..38825cbb7 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -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; @@ -23,7 +22,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; +use tauri::{webview::cookie::Cookie, AppHandle, Manager, Url}; #[cfg(windows)] use std::os::windows::io::AsRawHandle; @@ -274,6 +273,12 @@ fn wait_for_termination( } } +fn termination_timeout(default: Duration, deadline: Option) -> Duration { + deadline + .map(|deadline| default.min(deadline.saturating_duration_since(Instant::now()))) + .unwrap_or(default) +} + #[derive(Debug)] struct TerminationTimeout; @@ -307,6 +312,7 @@ fn process_group_is_gone(pid: u32) -> anyhow::Result { fn stop_child( child: &mut Child, #[cfg(windows)] job: Option<&WindowsJobObject>, + deadline: Option, ) -> anyhow::Result<()> { let pid = child.id(); #[cfg(windows)] @@ -314,7 +320,7 @@ fn stop_child( if child.try_wait()?.is_none() { let _ = child.kill(); let _ = wait_for_termination( - Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + termination_timeout(Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), deadline), Duration::from_millis(25), || Ok(child.try_wait()?.is_some()), ); @@ -347,18 +353,22 @@ fn stop_child( None => log_line(&format!("CLI control channel is unavailable pid={pid}")), } } - let graceful = wait_for_termination(graceful_timeout, Duration::from_millis(50), || { - let child_exited = child.try_wait()?.is_some(); - #[cfg(unix)] - return Ok(child_exited && process_group_is_gone(pid)?); - #[cfg(windows)] - return Ok(windows_containment_confirmed( - child_exited, - job.map(WindowsJobObject::active_processes).transpose()?, - )); - #[cfg(not(any(unix, windows)))] - Ok(child_exited) - }); + let graceful = wait_for_termination( + termination_timeout(graceful_timeout, deadline), + Duration::from_millis(50), + || { + let child_exited = child.try_wait()?.is_some(); + #[cfg(unix)] + return Ok(child_exited && process_group_is_gone(pid)?); + #[cfg(windows)] + return Ok(windows_containment_confirmed( + child_exited, + job.map(WindowsJobObject::active_processes).transpose()?, + )); + #[cfg(not(any(unix, windows)))] + Ok(child_exited) + }, + ); match graceful { Ok(()) => return Ok(()), Err(err) if !err.is::() => { @@ -384,7 +394,7 @@ fn stop_child( child.kill()?; wait_for_termination( - Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + termination_timeout(Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), deadline), Duration::from_millis(25), || { let child_exited = child.try_wait()?.is_some(); @@ -402,11 +412,34 @@ fn stop_child( .map_err(|err| anyhow::anyhow!("CLI pid={pid} termination was not confirmed: {err}")) } +fn discard_unregistered_child(child: &mut Child, #[cfg(windows)] job: &WindowsJobObject) { + #[cfg(unix)] + let pid = child.id(); + #[cfg(unix)] + unsafe { + if libc::kill(-(pid as i32), libc::SIGKILL) != 0 { + let _ = child.kill(); + } + } + #[cfg(windows)] + { + let _ = job.terminate(); + let _ = child.kill(); + } + #[cfg(not(any(unix, windows)))] + let _ = child.kill(); + let _ = child.wait(); +} + fn navigate_main(manager: &CliProcessManager, generation: u64, app: &AppHandle, url: &str) { if !manager.is_current_generation(generation) { return; } - if app.webview_windows().contains_key("main") { + if !app + .state::() + .records() + .is_empty() + { let final_url = augment_launch_url(url); let mut display = final_url.clone(); if let Some(hash_index) = display.find('#') { @@ -416,29 +449,41 @@ fn navigate_main(manager: &CliProcessManager, generation: u64, app: &AppHandle, if let Ok(parsed) = Url::parse(&final_url) { let current = manager.clone(); let navigate = manager.clone(); - crate::client_state::before_main_window_navigation_if( - app, - crate::client_state::NavigationKind::Cli, - Some(parsed.clone()), - move || current.is_current_generation(generation), - move |app| { - navigate - .with_current_generation(generation, || { - let window = app.get_webview_window("main").ok_or_else(|| { - "main window not found for CLI navigation".to_string() - })?; - window.navigate(parsed).map_err(|err| { - format!("failed to navigate main window to CLI URL: {err}") + app.state::() + .set_backend_target(Some(final_url)); + for record in app.state::().records() { + let current = current.clone(); + let navigate = navigate.clone(); + let parsed = parsed.clone(); + let label = record.label.clone(); + let target_label = label.clone(); + crate::client_state::before_window_navigation_if( + app, + label, + crate::client_state::NavigationKind::Cli, + Some(parsed.clone()), + move || current.is_current_generation(generation), + move |app| { + navigate + .with_current_generation(generation, || { + app.get_webview_window(&target_label) + .ok_or_else(|| { + "local window not found for CLI navigation".to_string() + })? + .navigate(parsed) + .map_err(|err| { + format!("failed to navigate local window to CLI URL: {err}") + }) }) - }) - .unwrap_or_else(|| Err("discarded stale CLI navigation".to_string())) - }, - ); + .unwrap_or_else(|| Err("discarded stale CLI navigation".to_string())) + }, + ); + } } else { log_line("failed to parse URL for navigation"); } } else { - log_line("main window not found for navigation"); + log_line("local window not found for navigation"); } } @@ -480,6 +525,21 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { 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, @@ -548,8 +608,10 @@ fn set_session_cookie( .same_site(tauri::webview::cookie::SameSite::Lax) .build(); - if let Some(win) = app.webview_windows().get("main") { - win.set_cookie(cookie)?; + for record in app.state::().records() { + if let Some(win) = app.get_webview_window(&record.label) { + win.set_cookie(cookie.clone())?; + } } Ok(()) @@ -565,15 +627,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 +765,13 @@ pub struct CliStatus { pub error: Option, } +#[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 { @@ -724,6 +784,17 @@ impl Default for CliStatus { } } +fn cli_exit_error(status: &CliStatus, exit: &std::process::ExitStatus) -> String { + if status.state == CliState::Ready { + format!("CLI exited unexpectedly after readiness: {exit}") + } else { + status + .error + .clone() + .unwrap_or_else(|| format!("CLI exited early: {exit}")) + } +} + #[derive(Debug, Clone)] pub struct CliProcessManager { status: Arc>, @@ -731,10 +802,11 @@ pub struct CliProcessManager { #[cfg(windows)] job: Arc>>, bootstrap_token: Arc>>, - session_cookie: Arc>>, - auth_cookie_name: Arc>>, + local_access: Arc>>, lifecycle: Arc>, + generation_authority: Arc>, generation: Arc, + accepting_spawns: Arc, } impl CliProcessManager { @@ -745,21 +817,22 @@ 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_authority: Arc::new(Mutex::new(())), generation: Arc::new(AtomicU64::new(0)), + accepting_spawns: Arc::new(AtomicBool::new(true)), } } pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> { let _lifecycle = self.lifecycle.lock(); + self.accepting_spawns.store(true, Ordering::SeqCst); let generation = self.advance_generation(); - 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; + *self.local_access.lock() = None; + log_line(&format!("start requested (dev={dev})")); + self.stop_tracked_child(None)?; { let mut status = self.status.lock(); status.state = CliState::Starting; @@ -782,18 +855,72 @@ impl CliProcessManager { } pub fn stop(&self) -> anyhow::Result<()> { + self.accepting_spawns.store(false, Ordering::SeqCst); + let generation = self.advance_generation(); + *self.bootstrap_token.lock() = None; + *self.local_access.lock() = None; let _lifecycle = self.lifecycle.lock(); - self.advance_generation(); - self.stop_tracked_child()?; + if !self.generation_matches(generation) { + return Err(anyhow::anyhow!("CLI stop was superseded")); + } + self.stop_tracked_child(None)?; + self.reset_stopped_status(); + Ok(()) + } + + pub(crate) fn stop_until(&self, deadline: Instant) -> anyhow::Result<()> { + self.accepting_spawns.store(false, Ordering::SeqCst); + let Some(generation) = self.advance_generation_until(deadline) else { + return Err(anyhow::anyhow!( + "timed out waiting for CLI generation authority" + )); + }; + let Some(mut bootstrap_token) = self.bootstrap_token.try_lock_until(deadline) else { + return Err(anyhow::anyhow!( + "timed out waiting for CLI bootstrap token authority" + )); + }; + *bootstrap_token = None; + drop(bootstrap_token); + let Some(mut local_access) = self.local_access.try_lock_until(deadline) else { + return Err(anyhow::anyhow!( + "timed out waiting for CLI local access authority" + )); + }; + *local_access = None; + drop(local_access); + let Some(_lifecycle) = self.lifecycle.try_lock_until(deadline) else { + return Err(anyhow::anyhow!("timed out waiting for CLI lifecycle lock")); + }; + if Instant::now() >= deadline { + return Err(anyhow::anyhow!("CLI stop deadline elapsed")); + } + if !self.generation_matches(generation) { + return Err(anyhow::anyhow!("CLI stop was superseded")); + } + self.stop_tracked_child(Some(deadline))?; self.reset_stopped_status(); Ok(()) } fn advance_generation(&self) -> u64 { + let _authority = self.generation_authority.lock(); self.generation.fetch_add(1, Ordering::SeqCst) + 1 } + fn advance_generation_until(&self, deadline: Instant) -> Option { + if Instant::now() >= deadline { + return None; + } + let _authority = self.generation_authority.try_lock_until(deadline)?; + (Instant::now() < deadline).then(|| self.generation.fetch_add(1, Ordering::SeqCst) + 1) + } + fn is_current_generation(&self, generation: u64) -> bool { + self.accepting_spawns.load(Ordering::SeqCst) && self.generation_matches(generation) + } + + fn generation_matches(&self, generation: u64) -> bool { self.generation.load(Ordering::SeqCst) == generation } @@ -802,22 +929,30 @@ impl CliProcessManager { generation: u64, operation: impl FnOnce() -> T, ) -> Option { - let _lifecycle = self.lock_current_generation(generation)?; - Some(operation()) + let _lifecycle = self.lifecycle.lock(); + let _authority = self.generation_authority.lock(); + self.is_current_generation(generation).then(operation) } fn lock_current_generation(&self, generation: u64) -> Option> { let lifecycle = self.lifecycle.lock(); - self.is_current_generation(generation).then_some(lifecycle) + let current = { + let _authority = self.generation_authority.lock(); + self.is_current_generation(generation) + }; + current.then_some(lifecycle) } - fn stop_tracked_child(&self) -> anyhow::Result<()> { + fn stop_tracked_child(&self, deadline: Option) -> anyhow::Result<()> { let Some(mut child) = self.child.lock().take() else { #[cfg(windows)] if let Some(job) = self.job.lock().take() { let result = job.terminate().and_then(|()| { wait_for_termination( - Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + termination_timeout( + Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + deadline, + ), Duration::from_millis(25), || Ok(job.active_processes()? == 0), ) @@ -836,6 +971,7 @@ impl CliProcessManager { &mut child, #[cfg(windows)] job.as_ref(), + deadline, ); if let Err(err) = result { *self.child.lock() = Some(child); @@ -848,6 +984,38 @@ impl CliProcessManager { Ok(()) } + fn register_spawned_child( + &self, + generation: u64, + mut child: Child, + #[cfg(windows)] job: WindowsJobObject, + ) -> Option<( + Option>, + Option>, + )> { + let authority = self.generation_authority.lock(); + if !self.is_current_generation(generation) { + drop(authority); + discard_unregistered_child( + &mut child, + #[cfg(windows)] + &job, + ); + return None; + } + let pid = child.id(); + let stdout = child.stdout.take().map(BufReader::new); + let stderr = child.stderr.take().map(BufReader::new); + debug_assert!(self.child.lock().is_none()); + self.status.lock().pid = Some(pid); + *self.child.lock() = Some(child); + #[cfg(windows)] + { + *self.job.lock() = Some(job); + } + Some((stdout, stderr)) + } + fn reset_stopped_status(&self) { let mut status = self.status.lock(); status.state = CliState::Stopped; @@ -855,18 +1023,19 @@ 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()); let snapshot = status.clone(); drop(status); - let _ = app.emit("cli:error", json!({"message": message})); - let _ = app.emit("cli:status", snapshot); + crate::local_windows::emit_all(app, "cli:error", json!({"message": message})); + crate::local_windows::emit_all(app, "cli:status", snapshot); }); } @@ -874,24 +1043,12 @@ impl CliProcessManager { self.status.lock().clone() } - pub fn desktop_event_stream_config(&self) -> Option { - 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 { + let _lifecycle = self.lifecycle.lock(); + if self.status.lock().state != CliState::Ready { + return None; + } + self.local_access.lock().clone() } fn spawn_cli( @@ -1016,6 +1173,13 @@ impl CliProcessManager { }; #[cfg(windows)] { + let authority = manager.generation_authority.lock(); + if !manager.is_current_generation(generation) { + drop(authority); + discard_unregistered_child(&mut child, &job); + return Ok(()); + } + drop(authority); let gate_result = child .stdin .as_mut() @@ -1034,16 +1198,14 @@ 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)] - { - *manager.job.lock() = Some(job); - } + let Some((stdout, stderr)) = manager.register_spawned_child( + generation, + child, + #[cfg(windows)] + job, + ) else { + return Ok(()); + }; Self::emit_status(&app, &manager.status.lock()); drop(lifecycle); @@ -1095,7 +1257,7 @@ impl CliProcessManager { } manager.advance_generation(); log_line("timeout waiting for CLI readiness"); - let stop_error = manager.stop_tracked_child().err(); + let stop_error = manager.stop_tracked_child(None).err(); let message = stop_error.map_or_else( || "CLI did not start in time".to_string(), |err| format!("CLI did not start in time; cleanup failed: {err}"), @@ -1105,8 +1267,8 @@ impl CliProcessManager { status.error = Some(message.clone()); let snapshot = status.clone(); drop(status); - let _ = app.emit("cli:error", json!({"message": message})); - let _ = app.emit("cli:status", snapshot); + crate::local_windows::emit_all(&app, "cli:error", json!({"message": message})); + crate::local_windows::emit_all(&app, "cli:status", snapshot); }); } @@ -1167,19 +1329,16 @@ 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; - if status.error.is_none() { - status.error = Some(format!("CLI exited early: {code}")); - } - let _ = app.emit( - "cli:error", - json!({"message": status.error.clone().unwrap_or_default()}), - ); - } else { - status.state = CliState::Stopped; - } + let message = cli_exit_error(&status, &code); + status.state = CliState::Error; + status.error = Some(message.clone()); + crate::local_windows::emit_all( + &app, + "cli:error", + json!({"message": message}), + ); Self::emit_status(&app, &status); }); return; @@ -1299,10 +1458,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 +1475,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); } @@ -1338,13 +1499,16 @@ impl CliProcessManager { } manager.with_current_generation(generation, || { let status = manager.status.lock().clone(); - let _ = app.emit("cli:ready", status.clone()); + crate::local_windows::emit_all(app, "cli:ready", status.clone()); Self::emit_status(app, &status); }); } fn emit_status(app: &AppHandle, status: &CliStatus) { - let _ = app.emit("cli:status", status.clone()); + if status.state != CliState::Ready { + crate::local_windows::show_loading_all(app); + } + crate::local_windows::emit_all(app, "cli:status", status.clone()); } } @@ -1763,7 +1927,93 @@ mod tests { } #[test] - fn stop_waits_for_an_authorized_spawn_section() { + fn generation_invalidated_exit_cannot_replace_requested_stop_status() { + let manager = CliProcessManager::new(); + let generation = manager.advance_generation(); + manager.status.lock().state = CliState::Ready; + + manager.advance_generation(); + manager.reset_stopped_status(); + + assert!(manager + .with_current_generation(generation, || { + manager.status.lock().state = CliState::Error; + }) + .is_none()); + assert_eq!(manager.status().state, CliState::Stopped); + } + + #[test] + fn unexpected_ready_exit_is_an_error_with_the_platform_status() { + let status = if cfg!(windows) { + Command::new("cmd.exe") + .args(["/C", "exit", "23"]) + .status() + .unwrap() + } else { + Command::new("sh").args(["-c", "exit 23"]).status().unwrap() + }; + + let message = cli_exit_error( + &CliStatus { + state: CliState::Ready, + ..CliStatus::default() + }, + &status, + ); + assert!( + message.contains("unexpectedly after readiness"), + "{message}" + ); + assert!(message.contains("23"), "{message}"); + } + + #[cfg(unix)] + #[test] + fn unexpected_ready_exit_preserves_the_signal() { + use std::os::unix::process::ExitStatusExt; + + let status = std::process::ExitStatus::from_raw(9); + let message = cli_exit_error( + &CliStatus { + state: CliState::Ready, + ..CliStatus::default() + }, + &status, + ); + assert!(message.contains("signal: 9"), "{message}"); + } + + #[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_invalidates_before_waiting_for_an_authorized_spawn_section() { let manager = CliProcessManager::new(); let generation = manager.advance_generation(); let worker_manager = manager.clone(); @@ -1791,6 +2041,116 @@ mod tests { assert!(!manager.is_current_generation(generation)); } + #[test] + fn stop_rejects_an_expired_absolute_deadline_before_locking() { + let manager = CliProcessManager::new(); + let generation = manager.advance_generation(); + let _lifecycle = manager.lifecycle.lock(); + + let error = manager.stop_until(Instant::now()).unwrap_err().to_string(); + + assert!(error.contains("timed out waiting for CLI generation authority")); + assert!(!manager.is_current_generation(generation)); + assert!(manager.generation_matches(generation)); + assert!(manager.child.lock().is_none()); + } + + #[test] + fn stop_generation_authority_wait_honors_the_absolute_deadline() { + let manager = CliProcessManager::new(); + let _authority = manager.generation_authority.lock(); + + let error = manager + .stop_until(Instant::now() + Duration::from_millis(20)) + .unwrap_err() + .to_string(); + + assert!(error.contains("timed out waiting for CLI generation authority")); + } + + #[test] + fn stop_bootstrap_token_wait_honors_the_absolute_deadline() { + let manager = CliProcessManager::new(); + let _token = manager.bootstrap_token.lock(); + + let error = manager + .stop_until(Instant::now() + Duration::from_millis(20)) + .unwrap_err() + .to_string(); + + assert!(error.contains("timed out waiting for CLI bootstrap token authority")); + } + + #[test] + fn stop_local_access_wait_honors_the_absolute_deadline() { + let manager = CliProcessManager::new(); + let _access = manager.local_access.lock(); + + let error = manager + .stop_until(Instant::now() + Duration::from_millis(20)) + .unwrap_err() + .to_string(); + + assert!(error.contains("timed out waiting for CLI local access authority")); + } + + #[test] + fn production_spawn_registration_is_rejected_after_timed_stop_returns() { + let manager = CliProcessManager::new(); + let generation = manager.advance_generation(); + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd.exe"); + command.args(["/C", "ping", "-n", "30", "127.0.0.1"]); + command + }; + #[cfg(unix)] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 30"]); + configure_posix_process_group(&mut command); + command + }; + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + configure_spawn(&mut command); + let child = command.spawn().unwrap(); + #[cfg(windows)] + let job = WindowsJobObject::create() + .and_then(|job| { + job.assign_child(&child)?; + Ok(job) + }) + .unwrap(); + + assert!(manager.stop_until(Instant::now()).is_err()); + let registered = manager.register_spawned_child( + generation, + child, + #[cfg(windows)] + job, + ); + + assert!(registered.is_none()); + assert!(manager.child.lock().is_none()); + } + + #[test] + fn termination_waits_are_clipped_to_the_remaining_absolute_deadline() { + let deadline = Instant::now() + Duration::from_millis(20); + let timeout = termination_timeout(Duration::from_secs(30), Some(deadline)); + assert!(timeout <= Duration::from_millis(20)); + + std::thread::sleep(Duration::from_millis(20)); + assert_eq!( + termination_timeout(Duration::from_secs(30), Some(deadline)), + Duration::ZERO + ); + assert_eq!( + termination_timeout(Duration::from_secs(30), None), + Duration::from_secs(30) + ); + } + #[cfg(windows)] #[test] fn windows_launch_gate_requires_parent_release() { @@ -1837,7 +2197,7 @@ mod tests { let mut child = command.spawn().unwrap(); child.wait().unwrap(); - let error = stop_child(&mut child, None).unwrap_err().to_string(); + let error = stop_child(&mut child, None, None).unwrap_err().to_string(); assert!(error.contains("cannot be confirmed without a Windows job")); } diff --git a/packages/tauri-app/src-tauri/src/client_state.rs b/packages/tauri-app/src-tauri/src/client_state.rs index d2b37b188..58bcf8fb7 100644 --- a/packages/tauri-app/src-tauri/src/client_state.rs +++ b/packages/tauri-app/src-tauri/src/client_state.rs @@ -1,44 +1,48 @@ mod access; mod commands; mod cross_host; +mod envelope; mod navigation; +mod partitions; mod process; mod window; #[doc(hidden)] pub use commands::{ - __cmd__client_state_claim_access, __cmd__client_state_clear, __cmd__client_state_load, - __cmd__client_state_navigation_flushed, __cmd__client_state_renderer_flushed, - __cmd__client_state_save, __cmd__client_state_set_restore_enabled, + __cmd__client_state_claim_access, __cmd__client_state_clear, + __cmd__client_state_commit_partitions, __cmd__client_state_load, + __cmd__client_state_load_partition, __cmd__client_state_navigation_flushed, + __cmd__client_state_renderer_flushed, __cmd__client_state_save, + __cmd__client_state_set_restore_enabled, }; pub use commands::{ - client_state_claim_access, client_state_clear, client_state_load, - client_state_navigation_flushed, client_state_renderer_flushed, client_state_save, - client_state_set_restore_enabled, + client_state_claim_access, client_state_clear, client_state_commit_partitions, + client_state_load, client_state_load_partition, client_state_navigation_flushed, + client_state_renderer_flushed, client_state_save, client_state_set_restore_enabled, }; pub(crate) use navigation::{ - before_main_window_navigation, before_main_window_navigation_if, NavigationKind, + before_window_navigation, before_window_navigation_if, NavigationKind, }; pub use window::{ - capture_and_flush_main_window, main_window_zoom, set_main_window_zoom, setup_main_window, - DEFAULT_ZOOM_LEVEL, + capture_and_flush_all_windows, capture_and_flush_window, local_window_zoom, + set_local_window_zoom, setup_local_window, DEFAULT_ZOOM_LEVEL, }; +use envelope::PersistedClientState; use serde::Serialize; use serde_json::Value; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::AtomicU64; use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager}; use url::Url; -use window::NativeWindowState; -const CLIENT_STATE_VERSION: u64 = 1; const CLIENT_STATE_FILENAME: &str = "client-state.json"; -const MAX_CLIENT_SNAPSHOT_BYTES: usize = 1024 * 1024; +const MAX_CLIENT_SNAPSHOT_BYTES: usize = envelope::MAX_SNAPSHOT_BYTES; const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Serialize)] @@ -50,36 +54,7 @@ pub(crate) struct RendererFlushRequest { #[derive(Default)] struct RendererFlush { request_lock: Mutex<()>, - next_generation: AtomicU64, - acknowledged_generation: AtomicU64, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct PersistedClientState { - version: u64, - restore_enabled: bool, - #[serde(skip_serializing_if = "Option::is_none")] - snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - window: Option, - #[serde(skip)] - unsupported_future_envelope: bool, - #[serde(skip)] - writes_enabled: bool, -} - -impl Default for PersistedClientState { - fn default() -> Self { - Self { - version: CLIENT_STATE_VERSION, - restore_enabled: true, - snapshot: None, - window: None, - unsupported_future_envelope: false, - writes_enabled: true, - } - } + windows: Mutex>, } #[derive(Debug, PartialEq, Serialize)] @@ -88,16 +63,18 @@ pub struct ClientStateLoadResult { is_primary: bool, restore_enabled: bool, snapshot: Value, + partition_protocol_version: Option, } pub struct ClientState { state_path: PathBuf, process: process::ProcessState, state: Mutex, - zoom_level: Mutex, + zoom_levels: Mutex>, write_lock: Mutex<()>, save_generation: AtomicU64, renderer_access: access::RendererAccess, + ephemeral_windows: Mutex>, renderer_flush: RendererFlush, write_state: StateWriter, } @@ -106,20 +83,49 @@ type StateWriter = std::sync::Arc bool) -> Result<(), String> + Send + Sync>; impl ClientState { - pub fn initialize(app: &AppHandle) -> Self { + pub(crate) fn stage_renderer_page_load( + &self, + window_id: &str, + url: &Url, + ) -> Result<(), String> { + self.renderer_access + .begin_navigation_for(window_id, Some(url)) + .map(|_| ()) + } + + pub fn initialize(app: &AppHandle, scoped_client_state_directory: Option<&Path>) -> 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)) => { - let legacy_electron = cross_host::legacy_electron_data_directory(); + let paths = scoped_client_state_directory + .map(|directory| { + ( + Ok(directory.join("election")), + Ok(directory.join(CLIENT_STATE_FILENAME)), + Ok(None), + ) + }) + .unwrap_or_else(|| { + ( + cross_host::election_directory(), + cross_host::state_path(), + cross_host::legacy_state_path().map(Some), + ) + }); + match paths { + (Ok(election_dir), Ok(state_path), Ok(legacy_state_path)) => { + let legacy_electron = scoped_client_state_directory + .is_none() + .then(cross_host::legacy_electron_data_directory) + .flatten(); Self::initialize_managed_at_with_election( &app_data_dir, &election_dir, &state_path, + legacy_state_path.as_deref(), 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)) } @@ -144,12 +150,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), ) @@ -160,11 +168,10 @@ impl ClientState { } fn disabled(state_path: PathBuf) -> Self { - let state = PersistedClientState { - restore_enabled: false, - writes_enabled: false, - ..PersistedClientState::default() - }; + let mut state = PersistedClientState::default(); + let record = state.active_mut(); + record.restore_enabled = false; + record.writes_enabled = false; Self::new( state_path, process::ProcessState::disabled(), @@ -179,19 +186,28 @@ impl ClientState { state: PersistedClientState, write_state: StateWriter, ) -> Self { - let zoom_level = state - .restore_enabled - .then(|| state.window.as_ref().map(|window| window.zoom_factor)) - .flatten() - .unwrap_or(DEFAULT_ZOOM_LEVEL); + let zoom_levels = state + .window_order + .iter() + .map(|id| { + let record = state.windows.get(id).expect("validated window order"); + let zoom = record + .restore_enabled + .then(|| record.window.as_ref().map(|window| window.zoom_factor)) + .flatten() + .unwrap_or(DEFAULT_ZOOM_LEVEL); + (id.clone(), zoom) + }) + .collect(); Self { state_path, process, state: Mutex::new(state), - zoom_level: Mutex::new(zoom_level), + zoom_levels: Mutex::new(zoom_levels), write_lock: Mutex::new(()), save_generation: AtomicU64::new(0), renderer_access: access::RendererAccess::default(), + ephemeral_windows: Mutex::new(HashSet::new()), renderer_flush: RendererFlush::default(), write_state, } @@ -212,6 +228,7 @@ impl ClientState { &app_data_dir.join(".cross-host-election"), &app_data_dir.join(CLIENT_STATE_FILENAME), None, + None, write_state, ) } @@ -220,6 +237,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 { @@ -235,6 +253,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 { @@ -244,12 +267,7 @@ impl ClientState { } let state = if registration.is_primary() { if future_legacy { - PersistedClientState { - restore_enabled: false, - unsupported_future_envelope: true, - writes_enabled: false, - ..PersistedClientState::default() - } + envelope::unsupported() } else { read_client_state(state_path) } @@ -269,38 +287,180 @@ impl ClientState { self.process.is_primary() } - fn claim_renderer_access(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { + pub(crate) fn is_primary_process(&self) -> bool { + self.is_primary() + } + + pub(crate) fn window_ids(&self) -> Vec { + self.state + .lock() + .map(|state| state.window_order.clone()) + .unwrap_or_default() + } + + pub(crate) fn set_active_window(&self, window_id: &str) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; - self.renderer_access.claim(access_token, renderer_url) + if !self.is_primary() { + return Ok(false); + } + let previous = { + let mut state = self.state.lock().map_err(|err| err.to_string())?; + if state.unsupported_future_envelope || state.active_window_id == window_id { + return Ok(!state.unsupported_future_envelope); + } + state.record(window_id)?; + let previous = state.active_window_id.clone(); + state.active_window_id = window_id.to_string(); + previous + }; + if let Err(error) = self.write_current_state() { + self.state + .lock() + .map_err(|err| err.to_string())? + .active_window_id = previous; + return Err(error); + } + Ok(true) + } + + pub(crate) fn active_window_id(&self) -> Result { + self.state + .lock() + .map(|state| state.active_window_id.clone()) + .map_err(|err| err.to_string()) + } + + fn claim_renderer_access( + &self, + window_id: &str, + access_token: &str, + renderer_url: &Url, + ) -> Result<(), String> { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + let persisted = self + .state + .lock() + .map_err(|err| err.to_string())? + .record(window_id) + .is_ok(); + let ephemeral = self + .ephemeral_windows + .lock() + .map_err(|err| err.to_string())? + .contains(window_id); + if !persisted && !ephemeral { + return Err("Unknown client state window".to_string()); + } + self.renderer_access + .claim_for(window_id, access_token, renderer_url) } fn load(&self) -> Result { + let window_id = self.active_window_id()?; + self.load_window(&window_id) + } + + fn load_window(&self, window_id: &str) -> Result { let state = self.state.lock().map_err(|err| err.to_string())?; + let record = match state.record(window_id) { + Ok(record) => record, + Err(_error) + if self + .ephemeral_windows + .lock() + .map_err(|err| err.to_string())? + .contains(window_id) => + { + return Ok(ClientStateLoadResult { + is_primary: false, + restore_enabled: false, + snapshot: Value::Null, + partition_protocol_version: Some(partitions::PROTOCOL_VERSION), + }); + } + Err(error) => return Err(error), + }; let is_primary = self.is_primary(); Ok(ClientStateLoadResult { is_primary, restore_enabled: if is_primary || !self.process.is_registered() { - state.restore_enabled + record.restore_enabled } else { - true + false }, - snapshot: if is_primary && state.restore_enabled { - state.snapshot.clone().unwrap_or(Value::Null) + snapshot: if is_primary && record.restore_enabled { + record.snapshot.clone().unwrap_or(Value::Null) } else { Value::Null }, + partition_protocol_version: Some(partitions::PROTOCOL_VERSION), }) } + #[cfg(test)] + fn load_partition_guarded( + &self, + key: &str, + access_valid: impl Fn() -> bool, + ) -> Result, String> { + let window_id = self.active_window_id()?; + self.load_partition_guarded_for(&window_id, key, access_valid) + } + + fn load_partition_guarded_for( + &self, + window_id: &str, + key: &str, + access_valid: impl Fn() -> bool, + ) -> Result, String> { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err( + "Client state renderer authority changed before partition read".to_string(), + ); + } + if !partitions::valid_key(key) { + return Err("Invalid client state partition key".to_string()); + } + if !self.is_primary() { + return Ok(None); + } + let state = self.state.lock().map_err(|err| err.to_string())?; + let record = state.record(window_id)?; + if !record.restore_enabled + || !record.partition_root_supported + || !record + .partition_keys + .as_ref() + .is_some_and(|keys| keys.iter().any(|candidate| candidate == key)) + { + return Ok(None); + } + drop(state); + self.partition_store() + .load(key, &|| self.is_primary() && access_valid()) + } + #[cfg(test)] fn save_snapshot(&self, snapshot: Value) -> Result { - self.save_snapshot_guarded(snapshot, || true) + let window_id = self.active_window_id()?; + self.save_snapshot_guarded_for(&window_id, snapshot, || true) } fn save_snapshot_guarded( &self, snapshot: Value, access_valid: impl Fn() -> bool, + ) -> Result { + let window_id = self.active_window_id()?; + self.save_snapshot_guarded_for(&window_id, snapshot, access_valid) + } + + fn save_snapshot_guarded_for( + &self, + window_id: &str, + snapshot: Value, + access_valid: impl Fn() -> bool, ) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; if !access_valid() { @@ -309,23 +469,99 @@ impl ClientState { if !self.is_primary() { return Ok(false); } - if self.normal_writes_suppressed()? { + if self.normal_writes_suppressed(window_id)? { return Ok(true); } if serialized_value_size(&snapshot)? > MAX_CLIENT_SNAPSHOT_BYTES { return Err("Client snapshot exceeds the 1 MiB limit".to_string()); } - self.mutate_and_write(|state| state.snapshot = Some(snapshot), &access_valid) + let result = self.mutate_and_write( + window_id, + |state| { + let record = state.record_mut(window_id)?; + record.snapshot = Some(snapshot); + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; + Ok(()) + }, + &access_valid, + )?; + self.collect_partitions(&access_valid); + Ok(result) + } + + #[cfg(test)] + fn commit_partitions_guarded( + &self, + payload: partitions::PartitionCommit, + access_valid: impl Fn() -> bool, + ) -> Result { + let window_id = self.active_window_id()?; + self.commit_partitions_guarded_for(&window_id, payload, access_valid) + } + + fn commit_partitions_guarded_for( + &self, + window_id: &str, + payload: partitions::PartitionCommit, + access_valid: impl Fn() -> bool, + ) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err("Client state renderer authority changed before mutation".to_string()); + } + let commit = payload.validate()?; + if !self.is_primary() { + return Ok(false); + } + if self.normal_writes_suppressed(window_id)? { + return Ok(true); + } + self.partition_store() + .prepare(&commit, &|| self.is_primary() && access_valid())?; + let snapshot = commit.snapshot.clone(); + let partition_keys = commit.partition_keys.clone(); + let result = self.mutate_and_write( + window_id, + |state| { + let record = state.record_mut(window_id)?; + record.snapshot = Some(snapshot); + record.partition_protocol_version = Some(partitions::PROTOCOL_VERSION); + record.partition_keys = Some(partition_keys); + record.partition_root_supported = true; + Ok(()) + }, + &access_valid, + )?; + self.collect_partitions(&access_valid); + Ok(result) + } + + fn collect_partitions(&self, access_valid: &dyn Fn() -> bool) { + let retained = self + .state + .lock() + .map(|state| state.retained_partition_keys()) + .unwrap_or_default(); + if let Err(err) = self + .partition_store() + .sweep(&retained, &|| self.is_primary() && access_valid()) + { + eprintln!("[client-state] failed to sweep partitions: {err}"); + } } #[cfg(test)] fn set_restore_enabled(&self, enabled: bool) -> Result { - self.set_restore_enabled_guarded(enabled, || true) + let window_id = self.active_window_id()?; + self.set_restore_enabled_guarded(&window_id, enabled, || true) } fn set_restore_enabled_guarded( &self, + window_id: &str, enabled: bool, access_valid: impl Fn() -> bool, ) -> Result { @@ -344,25 +580,40 @@ impl ClientState { { return Ok(false); } - self.mutate_and_write( + let result = self.mutate_and_write( + window_id, |state| { - state.restore_enabled = enabled; + let record = state.record_mut(window_id)?; + record.restore_enabled = enabled; if !enabled { - state.snapshot = None; - state.window = None; + record.snapshot = None; + record.window = None; + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; } - state.writes_enabled = enabled; + record.writes_enabled = enabled; + Ok(()) }, &access_valid, - ) + )?; + if !enabled { + self.collect_partitions(&access_valid); + } + Ok(result) } #[cfg(test)] fn clear(&self) -> Result { - self.clear_guarded(|| true) + let window_id = self.active_window_id()?; + self.clear_guarded(&window_id, || true) } - fn clear_guarded(&self, access_valid: impl Fn() -> bool) -> Result { + fn clear_guarded( + &self, + window_id: &str, + access_valid: impl Fn() -> bool, + ) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; if !access_valid() { return Err("Client state renderer authority changed before mutation".to_string()); @@ -370,33 +621,55 @@ impl ClientState { if !self.is_primary() { return Ok(false); } - self.mutate_and_write( + let result = self.mutate_and_write( + window_id, |state| { - if state.unsupported_future_envelope { - *state = PersistedClientState::default(); + let clearing_unsupported = state.unsupported_future_envelope; + state.unsupported_future_envelope = false; + let record = state.record_mut(window_id)?; + if clearing_unsupported { + record.snapshot = None; + record.window = None; + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; + record.writes_enabled = true; } else { - state.snapshot = None; - state.window = None; - state.writes_enabled = false; + record.snapshot = None; + record.window = None; + record.partition_protocol_version = None; + record.partition_keys = None; + record.partition_root_supported = false; + record.writes_enabled = false; } + Ok(()) }, &access_valid, - ) + )?; + self.collect_partitions(&access_valid); + Ok(result) } fn flush(&self) -> Result<(), String> { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; - if self.is_primary() && !self.normal_writes_suppressed()? { + let unsupported = self + .state + .lock() + .map_err(|err| err.to_string())? + .unsupported_future_envelope; + if self.is_primary() && !unsupported { self.write_current_state()?; } Ok(()) } - fn normal_writes_suppressed(&self) -> Result { - self.state - .lock() - .map(|state| !state.writes_enabled || state.unsupported_future_envelope) - .map_err(|err| err.to_string()) + fn normal_writes_suppressed(&self, window_id: &str) -> Result { + let state = self.state.lock().map_err(|err| err.to_string())?; + Ok(state.unsupported_future_envelope || !state.record(window_id)?.writes_enabled) + } + + fn partition_store(&self) -> partitions::PartitionStore { + partitions::PartitionStore::new(self.state_path.parent().unwrap_or(Path::new(""))) } fn write_current_state(&self) -> Result<(), String> { @@ -419,13 +692,14 @@ impl ClientState { fn mutate_and_write( &self, - mutate: impl FnOnce(&mut PersistedClientState), + _window_id: &str, + mutate: impl FnOnce(&mut PersistedClientState) -> Result<(), String>, replacement_valid: &dyn Fn() -> bool, ) -> Result { let previous_state = { let mut state = self.state.lock().map_err(|err| err.to_string())?; let previous = state.clone(); - mutate(&mut state); + mutate(&mut state)?; previous }; @@ -438,7 +712,76 @@ impl ClientState { } } + pub(crate) fn add_window(&self, window_id: String) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !self.is_primary() { + return Ok(false); + } + let previous = { + let mut state = self.state.lock().map_err(|err| err.to_string())?; + if state.unsupported_future_envelope { + return Ok(false); + } + let previous = state.clone(); + state.add_window(window_id.clone())?; + previous + }; + if let Err(err) = self.write_current_state() { + *self.state.lock().map_err(|lock_err| lock_err.to_string())? = previous; + return Err(err); + } + self.zoom_levels + .lock() + .map_err(|err| err.to_string())? + .insert(window_id, DEFAULT_ZOOM_LEVEL); + Ok(true) + } + + pub(crate) fn register_ephemeral_window(&self, window_id: String) { + if let Ok(mut windows) = self.ephemeral_windows.lock() { + windows.insert(window_id); + } + } + + pub(crate) fn unregister_window(&self, window_id: &str) { + self.renderer_access.remove(window_id); + if let Ok(mut windows) = self.ephemeral_windows.lock() { + windows.remove(window_id); + } + if let Ok(mut flushes) = self.renderer_flush.windows.lock() { + flushes.remove(window_id); + } + } + + pub(crate) fn remove_window(&self, window_id: &str) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !self.is_primary() { + return Ok(false); + } + let mut zoom_levels = self.zoom_levels.lock().map_err(|err| err.to_string())?; + let previous = { + let mut state = self.state.lock().map_err(|err| err.to_string())?; + if state.unsupported_future_envelope { + return Ok(false); + } + let previous = state.clone(); + if !state.remove_window(window_id)? { + return Ok(false); + } + previous + }; + if let Err(err) = self.write_current_state() { + *self.state.lock().map_err(|lock_err| lock_err.to_string())? = previous; + return Err(err); + } + self.renderer_access.remove(window_id); + zoom_levels.remove(window_id); + self.collect_partitions(&|| true); + Ok(true) + } + fn release_locks(&self) { + // Lock order fences takeover until root publication and partition GC leave write_lock. let _write = self .write_lock .lock() @@ -446,23 +789,34 @@ impl ClientState { self.process.release_locks(); } - pub(crate) fn wait_for_renderer_flush(&self, app: &AppHandle, require_claim: bool) { + pub(crate) fn wait_for_renderer_flush( + &self, + app: &AppHandle, + window_label: &str, + window_id: &str, + require_claim: bool, + ) { let _request = self .renderer_flush .request_lock .lock() .unwrap_or_else(|err| err.into_inner()); - if require_claim && !self.renderer_access.is_claimed() { + if require_claim && !self.renderer_access.is_claimed_for(window_id) { return; } - let Some(window) = app.get_webview_window("main") else { + let Some(window) = app.get_webview_window(window_label) else { return; }; - let generation = self - .renderer_flush - .next_generation - .fetch_add(1, Ordering::SeqCst) - + 1; + let generation = { + let mut windows = self + .renderer_flush + .windows + .lock() + .unwrap_or_else(|error| error.into_inner()); + let state = windows.entry(window_id.to_string()).or_default(); + state.0 += 1; + state.0 + }; if let Err(err) = window.emit( "client-state:navigation-flush-requested", RendererFlushRequest { generation }, @@ -472,23 +826,26 @@ impl ClientState { } let deadline = Instant::now() + RENDERER_FLUSH_TIMEOUT; - while self.renderer_flush.next_generation.load(Ordering::SeqCst) == generation - && self - .renderer_flush - .acknowledged_generation - .load(Ordering::SeqCst) - != generation + while self + .renderer_flush + .windows + .lock() + .ok() + .and_then(|windows| windows.get(window_id).copied()) + .is_some_and(|state| state.0 == generation && state.1 != generation) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); } } - fn acknowledge_renderer_flush(&self, generation: u64) { - if self.renderer_flush.next_generation.load(Ordering::SeqCst) == generation { - self.renderer_flush - .acknowledged_generation - .store(generation, Ordering::SeqCst); + fn acknowledge_renderer_flush(&self, window_id: &str, generation: u64) { + if let Ok(mut windows) = self.renderer_flush.windows.lock() { + if let Some(state) = windows.get_mut(window_id) { + if state.0 == generation { + state.1 = generation; + } + } } } } @@ -505,45 +862,13 @@ fn read_client_state(path: &Path) -> PersistedClientState { Err(err) if err.kind() == std::io::ErrorKind::NotFound => PersistedClientState::default(), Err(err) => { eprintln!("[client-state] failed to read state: {err}"); - PersistedClientState::default() + envelope::unsupported() } } } fn parse_client_state(bytes: &[u8]) -> PersistedClientState { - let Ok(Value::Object(value)) = serde_json::from_slice::(bytes) else { - return PersistedClientState::default(); - }; - let version = value.get("version").and_then(Value::as_u64); - if version.is_some_and(|version| version > CLIENT_STATE_VERSION) { - return PersistedClientState { - restore_enabled: false, - unsupported_future_envelope: true, - writes_enabled: false, - ..PersistedClientState::default() - }; - } - if version != Some(CLIENT_STATE_VERSION) { - return PersistedClientState::default(); - } - - let snapshot = value.get("snapshot").cloned().filter(|snapshot| { - serialized_value_size(snapshot) - .map(|size| size <= MAX_CLIENT_SNAPSHOT_BYTES) - .unwrap_or(false) - }); - let restore_enabled = value - .get("restoreEnabled") - .and_then(Value::as_bool) - .unwrap_or(true); - PersistedClientState { - version: CLIENT_STATE_VERSION, - restore_enabled, - snapshot, - window: value.get("window").and_then(window::normalize_window_state), - unsupported_future_envelope: false, - writes_enabled: restore_enabled, - } + envelope::parse(bytes) } fn legacy_candidate( @@ -554,7 +879,11 @@ fn legacy_candidate( let Value::Object(value) = serde_json::from_slice::(&bytes).ok()? else { return None; }; - if value.get("version").and_then(Value::as_u64) != Some(CLIENT_STATE_VERSION) { + if value + .get("version") + .and_then(envelope::exact_nonnegative_safe_integer) + != Some(envelope::LEGACY_MONOLITHIC_VERSION) + { return None; } let saved_at = value @@ -564,7 +893,10 @@ fn legacy_candidate( .and_then(Value::as_i64) .unwrap_or(-1); let mut parsed = parse_client_state(&bytes); - parsed.window = None; + if parsed.unsupported_future_envelope { + return None; + } + parsed.active_mut().window = None; Some((parsed, value.contains_key("snapshot"), saved_at, host)) } @@ -579,8 +911,12 @@ fn has_future_legacy_state(tauri_data_dir: &Path, electron_data_dir: Option<&Pat fs::read(path) .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| value.get("version").and_then(Value::as_u64)) - .is_some_and(|version| version > CLIENT_STATE_VERSION) + .and_then(|value| { + value + .get("version") + .and_then(envelope::exact_nonnegative_safe_integer) + }) + .is_some_and(|version| version > envelope::LEGACY_MONOLITHIC_VERSION) }) } @@ -600,8 +936,9 @@ fn migrate_legacy_state( .collect::>(); candidates.sort_by(|left, right| { left.0 + .active() .restore_enabled - .cmp(&right.0.restore_enabled) + .cmp(&right.0.active().restore_enabled) .then_with(|| left.1.cmp(&right.1)) .then_with(|| right.2.cmp(&left.2)) .then_with(|| right.3.cmp(left.3)) @@ -609,30 +946,76 @@ fn migrate_legacy_state( let Some((state, _, _, _)) = candidates.first() else { return Ok(()); }; - let bytes = serde_json::to_vec(state).map_err(|err| err.to_string())?; + let record = state.active(); + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct LegacyState<'a> { + version: u64, + restore_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + snapshot: Option<&'a Value>, + } + let bytes = serde_json::to_vec(&LegacyState { + version: envelope::LEGACY_MONOLITHIC_VERSION, + restore_enabled: record.restore_enabled, + snapshot: record.snapshot.as_ref(), + }) + .map_err(|err| err.to_string())?; if let Some(parent) = state_path.parent() { fs::create_dir_all(parent) .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() + 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 version = serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("version") + .and_then(envelope::exact_nonnegative_safe_integer) + }); + if version != Some(envelope::LEGACY_MONOLITHIC_VERSION) + || parse_client_state(&bytes).unsupported_future_envelope { - 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}" - )) - } - } + return Ok(()); + } + 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(_) => partitions::sync_directory(parent), + 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 + )), } - Ok(()) } fn serialized_value_size(value: &Value) -> Result { @@ -661,6 +1044,7 @@ fn write_atomically( temporary .persist(path) .map_err(|err| format!("failed to replace state file: {}", err.error))?; + partitions::sync_directory(parent)?; Ok(()) } @@ -670,12 +1054,11 @@ pub fn release(app: &AppHandle) { } } -pub fn flush_and_release_without_window_capture(app: &AppHandle) { +pub fn flush_without_window_capture(app: &AppHandle) { if let Some(state) = app.try_state::() { if let Err(err) = state.flush() { eprintln!("[client-state] failed to flush state: {err}"); } - state.release_locks(); } } diff --git a/packages/tauri-app/src-tauri/src/client_state/access.rs b/packages/tauri-app/src-tauri/src/client_state/access.rs index 31181c62e..17329c510 100644 --- a/packages/tauri-app/src-tauri/src/client_state/access.rs +++ b/packages/tauri-app/src-tauri/src/client_state/access.rs @@ -1,9 +1,10 @@ +use std::collections::HashMap; use std::sync::Mutex; use url::Url; #[derive(Default)] pub(super) struct RendererAccess { - state: Mutex, + state: Mutex>, } #[derive(Default)] @@ -15,6 +16,7 @@ struct RendererAccessState { } pub(super) struct PendingNavigation { + window_id: String, previous_origin: Option, staged_origin: Option, } @@ -29,12 +31,22 @@ fn origin_key(url: &Url) -> Result { impl RendererAccess { pub(super) fn claim(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { + self.claim_for("test-window", access_token, renderer_url) + } + + pub(super) fn claim_for( + &self, + window_id: &str, + access_token: &str, + renderer_url: &Url, + ) -> Result<(), 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 mut state = self.state.lock().map_err(|err| err.to_string())?; + let mut states = self.state.lock().map_err(|err| err.to_string())?; + let state = states.entry(window_id.to_string()).or_default(); if state.token.is_none() || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) { @@ -55,12 +67,24 @@ impl RendererAccess { } pub(super) fn validate(&self, access_token: &str, renderer_url: &Url) -> Result { + self.validate_for("test-window", access_token, renderer_url) + } + + pub(super) fn validate_for( + &self, + window_id: &str, + access_token: &str, + renderer_url: &Url, + ) -> Result { 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())?; + let states = self.state.lock().map_err(|err| err.to_string())?; + let Some(state) = states.get(window_id) else { + return Err("Client state access has not been claimed by this renderer".to_string()); + }; if state.token.as_deref() == Some(access_token) && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) { @@ -76,21 +100,31 @@ impl RendererAccess { } pub(super) fn is_generation_current(&self, generation: u64) -> bool { + self.is_generation_current_for("test-window", generation) + } + + pub(super) fn is_generation_current_for(&self, window_id: &str, generation: u64) -> bool { self.state .lock() - .map(|state| state.generation == generation) + .map(|state| { + state + .get(window_id) + .is_some_and(|state| state.generation == generation) + }) .unwrap_or(false) } - pub(super) fn allows_claim_origin(&self, renderer_url: &Url) -> bool { + pub(super) fn allows_claim_origin_for(&self, window_id: &str, renderer_url: &Url) -> bool { let Ok(renderer_origin) = origin_key(renderer_url) else { return false; }; self.state .lock() - .map(|state| { - state.committed_origin.as_deref() == Some(renderer_origin.as_str()) - || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + .map(|states| { + states.get(window_id).is_some_and(|state| { + state.committed_origin.as_deref() == Some(renderer_origin.as_str()) + || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + }) }) .unwrap_or(false) } @@ -99,7 +133,16 @@ impl RendererAccess { &self, target_url: Option<&Url>, ) -> Result { - let mut state = self.state.lock().map_err(|err| err.to_string())?; + self.begin_navigation_for("test-window", target_url) + } + + pub(super) fn begin_navigation_for( + &self, + window_id: &str, + target_url: Option<&Url>, + ) -> Result { + let mut states = self.state.lock().map_err(|err| err.to_string())?; + let state = states.entry(window_id.to_string()).or_default(); let previous_origin = state.pending_origin.clone(); let staged_origin = match target_url { Some(url) => Some(origin_key(url)?), @@ -109,22 +152,35 @@ impl RendererAccess { }; state.pending_origin = staged_origin.clone(); Ok(PendingNavigation { + window_id: window_id.to_string(), previous_origin, staged_origin, }) } pub(super) fn cancel_navigation(&self, navigation: PendingNavigation) { - let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - if state.pending_origin == navigation.staged_origin { - state.pending_origin = navigation.previous_origin; + let mut states = self.state.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(state) = states.get_mut(&navigation.window_id) { + if state.pending_origin == navigation.staged_origin { + state.pending_origin = navigation.previous_origin; + } } } - pub(super) fn is_claimed(&self) -> bool { + pub(super) fn is_claimed_for(&self, window_id: &str) -> bool { self.state .lock() - .map(|state| state.token.is_some()) + .map(|state| { + state + .get(window_id) + .is_some_and(|state| state.token.is_some()) + }) .unwrap_or(false) } + + pub(super) fn remove(&self, window_id: &str) { + if let Ok(mut states) = self.state.lock() { + states.remove(window_id); + } + } } diff --git a/packages/tauri-app/src-tauri/src/client_state/commands.rs b/packages/tauri-app/src-tauri/src/client_state/commands.rs index f22b64232..1bbf096c6 100644 --- a/packages/tauri-app/src-tauri/src/client_state/commands.rs +++ b/packages/tauri-app/src-tauri/src/client_state/commands.rs @@ -1,4 +1,4 @@ -use super::{ClientState, ClientStateLoadResult}; +use super::{partitions::PartitionCommit, ClientState, ClientStateLoadResult}; use crate::AppState; use serde_json::Value; use tauri::{AppHandle, State, WebviewWindow}; @@ -33,25 +33,27 @@ pub(super) fn is_allowed_client_state_origin(url: &Url, managed_cli_url: Option< || is_dev_renderer_origin(url) } -fn main_window_url(window: &WebviewWindow) -> Result { - if window.label() != "main" { - return Err( - "Client state commands are only available to the local main window".to_string(), - ); - } - +fn local_window_url(window: &WebviewWindow) -> Result { + crate::identity::local_window_id(window.label())?; window .url() .map_err(|err| format!("failed to inspect current renderer URL: {err}")) } +fn trusted_window_id(window: &WebviewWindow) -> Result { + crate::identity::local_window_id(window.label()) +} + fn validate_claim_origin( current_url: &Url, + window_id: &str, app_state: &AppState, state: &ClientState, ) -> Result<(), String> { let status = app_state.manager.status(); - if state.renderer_access.allows_claim_origin(current_url) + if state + .renderer_access + .allows_claim_origin_for(window_id, current_url) || is_allowed_client_state_origin(current_url, status.url.as_deref()) { Ok(()) @@ -64,9 +66,13 @@ fn validate_access( window: &WebviewWindow, state: &ClientState, access_token: &str, -) -> Result { - let current_url = main_window_url(window)?; - state.renderer_access.validate(access_token, ¤t_url) +) -> Result<(String, u64), String> { + let current_url = local_window_url(window)?; + let window_id = trusted_window_id(window)?; + state + .renderer_access + .validate_for(&window_id, access_token, ¤t_url) + .map(|generation| (window_id, generation)) } #[tauri::command] @@ -76,9 +82,10 @@ pub fn client_state_claim_access( state: State<'_, ClientState>, access_token: String, ) -> Result<(), String> { - let current_url = main_window_url(&window)?; - validate_claim_origin(¤t_url, &app_state, &state)?; - state.claim_renderer_access(&access_token, ¤t_url) + let current_url = local_window_url(&window)?; + let window_id = trusted_window_id(&window)?; + validate_claim_origin(¤t_url, &window_id, &app_state, &state)?; + state.claim_renderer_access(&window_id, &access_token, ¤t_url) } #[tauri::command] @@ -87,8 +94,23 @@ pub fn client_state_load( state: State<'_, ClientState>, access_token: String, ) -> Result { - validate_access(&window, &state, &access_token)?; - state.load() + let (window_id, _) = validate_access(&window, &state, &access_token)?; + state.load_window(&window_id) +} + +#[tauri::command] +pub fn client_state_load_partition( + window: WebviewWindow, + state: State<'_, ClientState>, + access_token: String, + key: String, +) -> Result, String> { + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.load_partition_guarded_for(&window_id, &key, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) + }) } #[tauri::command] @@ -98,9 +120,26 @@ pub fn client_state_save( access_token: String, snapshot: Value, ) -> Result { - let generation = validate_access(&window, &state, &access_token)?; - state.save_snapshot_guarded(snapshot, || { - state.renderer_access.is_generation_current(generation) + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.save_snapshot_guarded_for(&window_id, snapshot, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) + }) +} + +#[tauri::command] +pub fn client_state_commit_partitions( + window: WebviewWindow, + state: State<'_, ClientState>, + access_token: String, + payload: PartitionCommit, +) -> Result { + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.commit_partitions_guarded_for(&window_id, payload, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) }) } @@ -111,9 +150,11 @@ pub fn client_state_set_restore_enabled( access_token: String, enabled: bool, ) -> Result { - let generation = validate_access(&window, &state, &access_token)?; - state.set_restore_enabled_guarded(enabled, || { - state.renderer_access.is_generation_current(generation) + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.set_restore_enabled_guarded(&window_id, enabled, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) }) } @@ -123,8 +164,12 @@ pub fn client_state_clear( state: State<'_, ClientState>, access_token: String, ) -> Result { - let generation = validate_access(&window, &state, &access_token)?; - state.clear_guarded(|| state.renderer_access.is_generation_current(generation)) + let (window_id, generation) = validate_access(&window, &state, &access_token)?; + state.clear_guarded(&window_id, || { + state + .renderer_access + .is_generation_current_for(&window_id, generation) + }) } #[tauri::command] @@ -135,8 +180,8 @@ pub fn client_state_renderer_flushed( access_token: String, generation: u64, ) -> Result<(), String> { - validate_access(&window, &state, &access_token)?; - crate::shutdown::renderer_flushed(app, generation); + let (window_id, _) = validate_access(&window, &state, &access_token)?; + crate::shutdown::renderer_flushed(app, window.label().to_string(), window_id, generation); Ok(()) } @@ -147,7 +192,7 @@ pub fn client_state_navigation_flushed( access_token: String, generation: u64, ) -> Result<(), String> { - validate_access(&window, &state, &access_token)?; - state.acknowledge_renderer_flush(generation); + let (window_id, _) = validate_access(&window, &state, &access_token)?; + state.acknowledge_renderer_flush(&window_id, generation); Ok(()) } diff --git a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs index afeaeedce..749d60960 100644 --- a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs +++ b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs @@ -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, fallback_home: Option<&Path>, +) -> Option { + 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 { + 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, + fallback_home: Option<&Path>, ) -> Option { 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 + ); + } } } diff --git a/packages/tauri-app/src-tauri/src/client_state/envelope.rs b/packages/tauri-app/src-tauri/src/client_state/envelope.rs new file mode 100644 index 000000000..7d946f5e2 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/client_state/envelope.rs @@ -0,0 +1,378 @@ +use super::{partitions, window}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; + +pub(super) const VERSION: u64 = 3; +pub(super) const LEGACY_MONOLITHIC_VERSION: u64 = 1; +const LEGACY_PARTITION_VERSION: u64 = 2; +const MAX_WINDOWS: usize = 16; +pub(super) const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WindowRecord { + pub(super) restore_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) partition_protocol_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) partition_keys: Option>, + #[serde(skip)] + pub(super) partition_root_supported: bool, + #[serde(skip)] + pub(super) writes_enabled: bool, +} + +impl Default for WindowRecord { + fn default() -> Self { + Self { + restore_enabled: true, + snapshot: None, + window: None, + partition_protocol_version: None, + partition_keys: None, + partition_root_supported: false, + writes_enabled: true, + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PersistedClientState { + version: u64, + pub(super) active_window_id: String, + pub(super) window_order: Vec, + pub(super) windows: HashMap, + #[serde(skip)] + pub(super) unsupported_future_envelope: bool, +} + +impl Default for PersistedClientState { + fn default() -> Self { + Self::with_window_id(uuid::Uuid::new_v4().to_string()) + } +} + +impl PersistedClientState { + fn with_window_id(window_id: String) -> Self { + Self { + version: VERSION, + active_window_id: window_id.clone(), + window_order: vec![window_id.clone()], + windows: HashMap::from([(window_id, WindowRecord::default())]), + unsupported_future_envelope: false, + } + } + + pub(super) fn active(&self) -> &WindowRecord { + self.windows + .get(&self.active_window_id) + .expect("validated client state has an active window") + } + + pub(super) fn active_mut(&mut self) -> &mut WindowRecord { + self.windows + .get_mut(&self.active_window_id) + .expect("validated client state has an active window") + } + + pub(super) fn record(&self, window_id: &str) -> Result<&WindowRecord, String> { + self.windows + .get(window_id) + .ok_or_else(|| "Unknown client state window".to_string()) + } + + pub(super) fn record_mut(&mut self, window_id: &str) -> Result<&mut WindowRecord, String> { + self.windows + .get_mut(window_id) + .ok_or_else(|| "Unknown client state window".to_string()) + } + + pub(super) fn retained_partition_keys(&self) -> Vec { + let mut keys = self + .window_order + .iter() + .filter_map(|id| self.windows.get(id)) + .flat_map(|record| record.partition_keys.iter().flatten().cloned()) + .collect::>() + .into_iter() + .collect::>(); + keys.sort(); + keys + } + + pub(super) fn add_window(&mut self, window_id: String) -> Result<(), String> { + if !valid_window_id(&window_id) { + return Err("Invalid client state window ID".to_string()); + } + if self.window_order.len() >= MAX_WINDOWS { + return Err("Too many client state windows".to_string()); + } + if self.windows.contains_key(&window_id) { + return Err("Client state window already exists".to_string()); + } + if self.window_order.is_empty() { + self.active_window_id = window_id.clone(); + } + self.window_order.push(window_id.clone()); + self.windows.insert(window_id, WindowRecord::default()); + Ok(()) + } + + pub(super) fn remove_window(&mut self, window_id: &str) -> Result { + if !self.windows.contains_key(window_id) { + return Err("Unknown client state window".to_string()); + } + self.window_order.retain(|id| id != window_id); + self.windows.remove(window_id); + if self.active_window_id == window_id && !self.window_order.is_empty() { + self.active_window_id = self.window_order[0].clone(); + } + Ok(true) + } +} + +pub(super) fn valid_window_id(value: &str) -> bool { + value.len() == 36 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) || byte == b'-') + && uuid::Uuid::parse_str(value) + .map(|uuid| uuid.to_string() == value) + .unwrap_or(false) +} + +fn has_only_keys(value: &serde_json::Map, allowed: &[&str]) -> bool { + value.keys().all(|key| allowed.contains(&key.as_str())) +} + +fn value_size(value: &Value) -> Option { + serde_json::to_vec(value).ok().map(|bytes| bytes.len()) +} + +pub(super) fn exact_nonnegative_safe_integer(value: &Value) -> Option { + let number = value.as_number()?.as_f64()?; + (number.is_finite() && number >= 0.0 && number <= MAX_SAFE_INTEGER && number.fract() == 0.0) + .then_some(number as u64) +} + +fn parse_record(value: &Value) -> Option { + let value = value.as_object()?; + if !has_only_keys( + value, + &[ + "restoreEnabled", + "snapshot", + "window", + "partitionProtocolVersion", + "partitionKeys", + ], + ) { + return None; + } + let restore_enabled = value.get("restoreEnabled")?.as_bool()?; + let snapshot = value.get("snapshot").cloned(); + let window = if value.contains_key("window") { + Some(window::normalize_window_state(value.get("window")?)?) + } else { + None + }; + let has_protocol = value.contains_key("partitionProtocolVersion"); + let has_keys = value.contains_key("partitionKeys"); + let (partition_protocol_version, partition_keys, partition_root_supported) = if has_protocol + || has_keys + { + if !has_protocol + || !has_keys + || exact_nonnegative_safe_integer(value.get("partitionProtocolVersion")?)? + != partitions::PROTOCOL_VERSION + { + return None; + } + let keys = partitions::validate_keys(value.get("partitionKeys")?)?; + let root = snapshot.as_ref().and_then(partitions::validate_root)?; + if root != keys || snapshot.as_ref().and_then(value_size)? > partitions::MAX_ROOT_BYTES { + return None; + } + (Some(partitions::PROTOCOL_VERSION), Some(keys), true) + } else { + if snapshot + .as_ref() + .and_then(value_size) + .is_some_and(|size| size > MAX_SNAPSHOT_BYTES) + { + return None; + } + (None, None, false) + }; + Some(WindowRecord { + restore_enabled, + snapshot, + window, + partition_protocol_version, + partition_keys, + partition_root_supported, + writes_enabled: restore_enabled, + }) +} + +fn parse_v3(value: &serde_json::Map) -> Option { + if !has_only_keys( + value, + &["version", "activeWindowId", "windowOrder", "windows"], + ) { + return None; + } + let active_window_id = value.get("activeWindowId")?.as_str()?.to_string(); + if !valid_window_id(&active_window_id) { + return None; + } + let order = value.get("windowOrder")?.as_array()?; + if order.len() > MAX_WINDOWS { + return None; + } + let mut seen = HashSet::new(); + let mut window_order = Vec::with_capacity(order.len()); + for id in order { + let id = id.as_str()?; + if !valid_window_id(id) || !seen.insert(id) { + return None; + } + window_order.push(id.to_string()); + } + if !window_order.is_empty() && !seen.contains(active_window_id.as_str()) { + return None; + } + let source = value.get("windows")?.as_object()?; + if source.len() != window_order.len() || source.keys().any(|id| !seen.contains(id.as_str())) { + return None; + } + let mut windows = HashMap::with_capacity(window_order.len()); + for id in &window_order { + windows.insert(id.clone(), parse_record(source.get(id)?)?); + } + Some(PersistedClientState { + version: VERSION, + active_window_id, + window_order, + windows, + unsupported_future_envelope: false, + }) +} + +fn parse_legacy( + value: &serde_json::Map, + bytes: &[u8], +) -> Option { + let version = exact_nonnegative_safe_integer(value.get("version")?)?; + let mut state = PersistedClientState::with_window_id(deterministic_legacy_window_id(bytes)); + let record = if version == LEGACY_PARTITION_VERSION { + if !has_only_keys( + value, + &[ + "version", + "restoreEnabled", + "snapshot", + "window", + "protocolVersion", + "partitionKeys", + ], + ) { + return None; + } + let restore_enabled = value.get("restoreEnabled")?.as_bool()?; + let snapshot = value.get("snapshot")?.clone(); + let keys = partitions::validate_keys(value.get("partitionKeys")?)?; + if exact_nonnegative_safe_integer(value.get("protocolVersion")?)? + != partitions::PROTOCOL_VERSION + || partitions::validate_root(&snapshot)? != keys + || value_size(&snapshot)? > partitions::MAX_ROOT_BYTES + { + return None; + } + WindowRecord { + restore_enabled, + snapshot: Some(snapshot), + window: if value.contains_key("window") { + Some(window::normalize_window_state(value.get("window")?)?) + } else { + None + }, + partition_protocol_version: Some(partitions::PROTOCOL_VERSION), + partition_keys: Some(keys), + partition_root_supported: true, + writes_enabled: restore_enabled, + } + } else if version == LEGACY_MONOLITHIC_VERSION { + if !has_only_keys(value, &["version", "restoreEnabled", "snapshot", "window"]) { + return None; + } + let restore_enabled = match value.get("restoreEnabled") { + Some(value) => value.as_bool()?, + None => true, + }; + let snapshot = value.get("snapshot").cloned(); + if snapshot + .as_ref() + .and_then(value_size) + .is_some_and(|size| size > MAX_SNAPSHOT_BYTES) + { + return None; + } + WindowRecord { + restore_enabled, + snapshot, + window: if value.contains_key("window") { + Some(window::normalize_window_state(value.get("window")?)?) + } else { + None + }, + writes_enabled: restore_enabled, + ..WindowRecord::default() + } + } else { + return None; + }; + *state.active_mut() = record; + Some(state) +} + +pub(super) fn deterministic_legacy_window_id(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut id = [0_u8; 16]; + id.copy_from_slice(&digest[..16]); + id[6] = (id[6] & 0x0f) | 0x50; + id[8] = (id[8] & 0x3f) | 0x80; + uuid::Uuid::from_bytes(id).to_string() +} + +pub(super) fn parse(bytes: &[u8]) -> PersistedClientState { + let Ok(Value::Object(value)) = serde_json::from_slice::(bytes) else { + return unsupported(); + }; + let version = value + .get("version") + .and_then(exact_nonnegative_safe_integer); + let parsed = if version == Some(VERSION) { + parse_v3(&value) + } else { + parse_legacy(&value, bytes) + }; + parsed.unwrap_or_else(unsupported) +} + +pub(super) fn unsupported() -> PersistedClientState { + let mut state = PersistedClientState::default(); + let record = state.active_mut(); + record.restore_enabled = false; + record.writes_enabled = false; + state.unsupported_future_envelope = true; + state +} diff --git a/packages/tauri-app/src-tauri/src/client_state/navigation.rs b/packages/tauri-app/src-tauri/src/client_state/navigation.rs index ef49dbc01..3e4388c17 100644 --- a/packages/tauri-app/src-tauri/src/client_state/navigation.rs +++ b/packages/tauri-app/src-tauri/src/client_state/navigation.rs @@ -1,11 +1,11 @@ use super::ClientState; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::sync::{LazyLock, Mutex}; use tauri::{AppHandle, Manager}; use url::Url; -static NAVIGATIONS: LazyLock>> = - LazyLock::new(|| Mutex::new(NavigationQueue::default())); +static NAVIGATIONS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); type Operation = Box Result<(), String> + Send + 'static>; type NavigationGuard = Box bool + Send + 'static>; @@ -19,6 +19,7 @@ pub(crate) enum NavigationKind { struct NavigationOperation { app: AppHandle, + window_label: String, target_url: Option, is_current: NavigationGuard, navigate: Operation, @@ -108,26 +109,30 @@ impl NavigationQueue { } } -pub(crate) fn before_main_window_navigation( +pub(crate) fn before_window_navigation( app: &AppHandle, + window_label: String, kind: NavigationKind, target_url: Option, navigate: impl FnOnce(AppHandle) -> Result<(), String> + Send + 'static, ) { - before_main_window_navigation_if(app, kind, target_url, || true, navigate); + before_window_navigation_if(app, window_label, kind, target_url, || true, navigate); } -pub(crate) fn before_main_window_navigation_if( +pub(crate) fn before_window_navigation_if( app: &AppHandle, + window_label: String, kind: NavigationKind, target_url: Option, is_current: impl Fn() -> bool + Send + 'static, navigate: impl FnOnce(AppHandle) -> Result<(), String> + Send + 'static, ) { + let queue_label = window_label.clone(); let request = QueuedNavigation::new( kind, NavigationOperation { app: app.clone(), + window_label, target_url, is_current: Box::new(is_current), navigate: Box::new(navigate), @@ -136,59 +141,75 @@ pub(crate) fn before_main_window_navigation_if( let start_worker = NAVIGATIONS .lock() .unwrap_or_else(|err| err.into_inner()) + .entry(queue_label.clone()) + .or_default() .enqueue(request); if start_worker { - std::thread::spawn(run_navigation_queue); + std::thread::spawn(move || run_navigation_queue(queue_label)); } } -fn run_navigation_queue() { +fn complete_active(window_label: &str) { + if let Some(queue) = NAVIGATIONS + .lock() + .unwrap_or_else(|err| err.into_inner()) + .get_mut(window_label) + { + queue.complete_active(); + } +} + +fn run_navigation_queue(window_label: String) { loop { - let request = NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .next(); + let request = { + let mut queues = NAVIGATIONS.lock().unwrap_or_else(|err| err.into_inner()); + let request = queues + .get_mut(&window_label) + .and_then(NavigationQueue::next); + if request.is_none() { + queues.remove(&window_label); + } + request + }; let Some(request) = request else { return; }; let NavigationOperation { app, + window_label, target_url, is_current, navigate, } = request.value; if !is_current() { - NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .complete_active(); + complete_active(&window_label); continue; } - if let Some(state) = app.try_state::() { - state.wait_for_renderer_flush(&app, true); + let window_id = crate::identity::local_window_id(&window_label).ok(); + if let (Some(state), Some(window_id)) = + (app.try_state::(), window_id.as_deref()) + { + state.wait_for_renderer_flush(&app, &window_label, window_id, true); } if !is_current() { - NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .complete_active(); + complete_active(&window_label); continue; } let result = crate::shutdown::with_navigation_authority(&app, || { let state = app.try_state::(); - execute_navigation(state.as_deref(), target_url.as_ref(), || { - navigate(app.clone()) - }) + execute_navigation_for( + state.as_deref(), + window_id.as_deref(), + target_url.as_ref(), + || navigate(app.clone()), + ) }); if let Some(Err(err)) = result { eprintln!("[client-state] navigation failed: {err}"); } - NAVIGATIONS - .lock() - .unwrap_or_else(|err| err.into_inner()) - .complete_active(); + complete_active(&window_label); } } @@ -196,9 +217,24 @@ fn execute_navigation( state: Option<&ClientState>, target_url: Option<&Url>, navigate: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let window_id = state.map(ClientState::active_window_id).transpose()?; + execute_navigation_for(state, window_id.as_deref(), target_url, navigate) +} + +fn execute_navigation_for( + state: Option<&ClientState>, + window_id: Option<&str>, + target_url: Option<&Url>, + navigate: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { let pending = state - .map(|state| state.renderer_access.begin_navigation(target_url)) + .zip(window_id) + .map(|(state, window_id)| { + state + .renderer_access + .begin_navigation_for(window_id, target_url) + }) .transpose()?; let result = navigate(); if result.is_err() { @@ -262,15 +298,37 @@ mod tests { assert_eq!(queue.next().unwrap().value, "reload"); } + #[test] + fn identical_actions_in_different_window_queues_do_not_coalesce() { + let mut queues: HashMap<&str, NavigationQueue<&str>> = HashMap::new(); + assert!(queues + .entry("local-one") + .or_default() + .enqueue(queued(NavigationKind::Reload, "one"))); + assert!(queues + .entry("local-two") + .or_default() + .enqueue(queued(NavigationKind::Reload, "two"))); + assert_eq!( + queues.get_mut("local-one").unwrap().next().unwrap().value, + "one" + ); + assert_eq!( + queues.get_mut("local-two").unwrap().next().unwrap().value, + "two" + ); + } + #[test] fn failed_navigation_preserves_renderer_access_and_runs_once() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); let renderer_url = url::Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let window_id = state.active_window_id().unwrap(); let calls = Arc::new(AtomicUsize::new(0)); state .renderer_access - .claim("current-renderer", &renderer_url) + .claim_for(&window_id, "current-renderer", &renderer_url) .unwrap(); let calls_for_navigation = Arc::clone(&calls); @@ -283,7 +341,7 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); state .renderer_access - .validate("current-renderer", &renderer_url) + .validate_for(&window_id, "current-renderer", &renderer_url) .unwrap(); } @@ -293,29 +351,32 @@ mod tests { let state = ClientState::initialize_at(directory.path()).unwrap(); let outgoing_url = url::Url::parse("http://127.0.0.1:43123/workspace").unwrap(); let incoming_url = url::Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let window_id = state.active_window_id().unwrap(); state .renderer_access - .claim("outgoing-renderer", &outgoing_url) + .claim_for(&window_id, "outgoing-renderer", &outgoing_url) .unwrap(); execute_navigation(Some(&state), Some(&incoming_url), || Ok(())).unwrap(); state .renderer_access - .validate("outgoing-renderer", &outgoing_url) + .validate_for(&window_id, "outgoing-renderer", &outgoing_url) .unwrap(); - assert!(state.renderer_access.allows_claim_origin(&outgoing_url)); + assert!(state + .renderer_access + .allows_claim_origin_for(&window_id, &outgoing_url)); state .renderer_access - .claim("incoming-renderer", &incoming_url) + .claim_for(&window_id, "incoming-renderer", &incoming_url) .unwrap(); assert!(state .renderer_access - .validate("outgoing-renderer", &outgoing_url) + .validate_for(&window_id, "outgoing-renderer", &outgoing_url) .is_err()); state .renderer_access - .validate("incoming-renderer", &incoming_url) + .validate_for(&window_id, "incoming-renderer", &incoming_url) .unwrap(); } } diff --git a/packages/tauri-app/src-tauri/src/client_state/partitions.rs b/packages/tauri-app/src-tauri/src/client_state/partitions.rs new file mode 100644 index 000000000..37796d2b1 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/client_state/partitions.rs @@ -0,0 +1,324 @@ +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::fs; +#[cfg(not(windows))] +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub(super) const PROTOCOL_VERSION: u64 = 1; +pub(super) const MAX_ROOT_BYTES: usize = 1024 * 1024; +const MAX_PARTITION_BYTES: usize = 1024 * 1024; +pub(super) const MAX_COMMIT_BYTES: usize = 256 * 1024 * 1024; +const MAX_PARTITION_KEYS: usize = 4096; +const PARTITION_DIRECTORY: &str = "partitions"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartitionCommit { + protocol_version: Value, + pub(super) snapshot: Value, + partitions: HashMap, + partition_keys: Vec, +} + +pub(super) struct ValidatedCommit { + pub(super) snapshot: Value, + partitions: HashMap, + pub(super) partition_keys: Vec, +} + +pub(super) fn valid_key(key: &str) -> bool { + key.len() == 64 + && key + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +pub(super) fn validate_keys(value: &Value) -> Option> { + let values = value.as_array()?; + if values.len() > MAX_PARTITION_KEYS { + return None; + } + let mut keys: Vec = Vec::with_capacity(values.len()); + for value in values { + let key = value.as_str()?; + if !valid_key(key) || keys.last().is_some_and(|previous| previous.as_str() >= key) { + return None; + } + keys.push(key.to_string()); + } + Some(keys) +} + +pub(super) fn validate_root(value: &Value) -> Option> { + let root = value.as_object()?; + if super::envelope::exact_nonnegative_safe_integer(root.get("version")?) != Some(2) { + return None; + } + let session_partition = root.get("sessionPartition")?.as_str()?; + if !valid_key(session_partition) { + return None; + } + let partition_keys = validate_keys(root.get("partitionKeys")?)?; + partition_keys + .iter() + .any(|key| key == session_partition) + .then_some(partition_keys) +} + +impl PartitionCommit { + pub(super) fn validate(self) -> Result { + if super::envelope::exact_nonnegative_safe_integer(&self.protocol_version) + != Some(PROTOCOL_VERSION) + { + return Err("Unsupported client state partition protocol".to_string()); + } + if self.partition_keys.len() > MAX_PARTITION_KEYS + || self.partitions.len() > MAX_PARTITION_KEYS + { + return Err("Too many client state partitions".to_string()); + } + let partition_keys_value = Value::Array( + self.partition_keys + .iter() + .cloned() + .map(Value::String) + .collect(), + ); + let Some(payload_keys) = validate_keys(&partition_keys_value) else { + return Err("Invalid client state partition keys".to_string()); + }; + let Some(root_keys) = validate_root(&self.snapshot) else { + return Err("Invalid client state partition root".to_string()); + }; + if root_keys != payload_keys { + return Err("Client state root partition keys do not match the commit".to_string()); + } + let mut supplied_keys = self.partitions.keys().collect::>(); + supplied_keys.sort(); + if supplied_keys.len() != root_keys.len() + || supplied_keys + .iter() + .zip(&root_keys) + .any(|(supplied, expected)| supplied.as_str() != expected) + { + return Err("Client state partitions do not match the root".to_string()); + } + let root_size = serde_json::to_vec(&self.snapshot) + .map_err(|err| err.to_string())? + .len(); + if root_size > MAX_ROOT_BYTES { + return Err("Client state root exceeds the 1 MiB limit".to_string()); + } + let mut commit_size = root_size; + for (key, content) in &self.partitions { + if !valid_key(key) { + return Err("Invalid client state partition reference".to_string()); + } + if content.len() > MAX_PARTITION_BYTES { + return Err("Client state partition exceeds the 1 MiB limit".to_string()); + } + commit_size = commit_size.checked_add(content.len()).ok_or_else(|| { + "Client state partition commit exceeds the 256 MiB limit".to_string() + })?; + if commit_size > MAX_COMMIT_BYTES { + return Err("Client state partition commit exceeds the 256 MiB limit".to_string()); + } + if hex_digest(content.as_bytes()) != *key { + return Err("Client state partition digest mismatch".to_string()); + } + } + Ok(ValidatedCommit { + snapshot: self.snapshot, + partitions: self.partitions, + partition_keys: root_keys, + }) + } +} + +pub(super) struct PartitionStore { + directory: PathBuf, +} + +impl PartitionStore { + pub(super) fn new(root: &Path) -> Self { + Self { + directory: root.join(PARTITION_DIRECTORY), + } + } + + pub(super) fn prepare( + &self, + commit: &ValidatedCommit, + authority_valid: &dyn Fn() -> bool, + ) -> Result<(), String> { + fs::create_dir_all(&self.directory).map_err(|err| err.to_string())?; + self.validate_directory(false)?; + let mut published = false; + for (key, content) in &commit.partitions { + published |= self.write_immutable(key, content.as_bytes(), authority_valid)?; + } + if published { + self.validate_directory(false)?; + sync_directory(&self.directory)?; + authority(authority_valid)?; + } + for key in &commit.partition_keys { + let content = self.read_verified(key)?; + authority(authority_valid)?; + if content.is_none() { + return Err(format!("Missing client state partition {key}")); + } + } + Ok(()) + } + + pub(super) fn load( + &self, + key: &str, + authority_valid: &dyn Fn() -> bool, + ) -> Result, String> { + if !self.validate_directory(true)? { + return Ok(None); + } + let result = self.read_verified(key)?; + authority(authority_valid)?; + Ok(result) + } + + pub(super) fn sweep( + &self, + partition_keys: &[String], + authority_valid: &dyn Fn() -> bool, + ) -> Result<(), String> { + if !self.validate_directory(true)? { + return Ok(()); + } + let entries = match fs::read_dir(&self.directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.to_string()), + }; + authority(authority_valid)?; + let retained = partition_keys + .iter() + .map(String::as_str) + .collect::>(); + for entry in entries { + let entry = entry.map_err(|err| err.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !valid_key(name) || retained.contains(name) { + continue; + } + let metadata = fs::symlink_metadata(entry.path()).map_err(|err| err.to_string())?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + continue; + } + fs::remove_file(entry.path()).map_err(|err| err.to_string())?; + authority(authority_valid)?; + } + Ok(()) + } + + fn validate_directory(&self, allow_missing: bool) -> Result { + match fs::symlink_metadata(&self.directory) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + Ok(true) + } + Ok(_) => Err("Invalid client state partition directory".to_string()), + Err(err) if allow_missing && err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err.to_string()), + } + } + + fn read_verified(&self, key: &str) -> Result, String> { + let path = self.directory.join(key); + match fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.file_type().is_file() => return Ok(None), + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.to_string()), + } + match fs::read(path) { + Ok(bytes) if bytes.len() <= MAX_PARTITION_BYTES && hex_digest(&bytes) == key => { + String::from_utf8(bytes) + .map(Some) + .map_err(|err| err.to_string()) + } + Ok(_) => Ok(None), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.to_string()), + } + } + + fn write_immutable( + &self, + key: &str, + bytes: &[u8], + authority_valid: &dyn Fn() -> bool, + ) -> Result { + if self.read_verified(key)?.is_some() { + authority(authority_valid)?; + return Ok(false); + } + let path = self.directory.join(key); + match fs::symlink_metadata(&path) { + Ok(_) => return Err(format!("Invalid existing client state partition {key}")), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.to_string()), + } + let mut temporary = + tempfile::NamedTempFile::new_in(&self.directory).map_err(|err| err.to_string())?; + temporary + .write_all(bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|err| err.to_string())?; + authority(authority_valid)?; + let published = match temporary.persist_noclobber(&path) { + Ok(_) => true, + Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => { + if self.read_verified(key)?.is_none() { + return Err(format!("Invalid existing client state partition {key}")); + } + false + } + Err(err) => return Err(err.error.to_string()), + }; + authority(authority_valid)?; + Ok(published) + } +} + +fn authority(authority_valid: &dyn Fn() -> bool) -> Result<(), String> { + if authority_valid() { + Ok(()) + } else { + Err("Client state authority changed during partition I/O".to_string()) + } +} + +fn hex_digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(not(windows))] +pub(super) fn sync_directory(path: &Path) -> Result<(), String> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|err| err.to_string()) +} + +#[cfg(windows)] +pub(super) fn sync_directory(_path: &Path) -> Result<(), String> { + // Windows std::fs cannot open directories; synced files plus atomic publication are the stdlib limit. + Ok(()) +} diff --git a/packages/tauri-app/src-tauri/src/client_state/tests.rs b/packages/tauri-app/src-tauri/src/client_state/tests.rs index a2a7d6187..7503af75d 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -1,4 +1,5 @@ use super::commands::is_allowed_client_state_origin; +use super::partitions::MAX_COMMIT_BYTES; use super::process::{PRIMARY_LOCK_FILENAME, RUNNING_MARKER_PREFIX, RUNNING_MARKER_SUFFIX}; use super::window::{ clamp_window_bounds, normalize_native_zoom_level, DisplayArea, NativeWindowState, WindowBounds, @@ -10,11 +11,32 @@ use super::{ }; use serde_json::{json, Value}; use std::fs; +use std::fs::OpenOptions; use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Barrier}; use std::thread; + +#[test] +fn legacy_migration_uuids_match_the_cross_host_exact_byte_vectors() { + for (content, expected) in [ + ( + br#"{"version":1}"#.as_slice(), + "2430f1a2-ad29-52d0-8678-85488a4c89e2", + ), + ( + br#"{ "version": 1, "restoreEnabled": false }"#.as_slice(), + "e6a1425a-ebb1-502f-b79c-d92772fa763b", + ), + ] { + assert_eq!( + super::envelope::deterministic_legacy_window_id(content), + expected + ); + assert_eq!(parse_client_state(content).active_window_id, expected); + } +} use std::time::Duration; use tempfile::TempDir; use url::Url; @@ -24,6 +46,7 @@ fn load(is_primary: bool, restore_enabled: bool, snapshot: Value) -> ClientState is_primary, restore_enabled, snapshot, + partition_protocol_version: Some(1), } } @@ -32,10 +55,15 @@ fn assert_access_rejected(state: &ClientState, token: &str, url: &Url) { } fn acknowledged_generation(state: &ClientState) -> u64 { + let window_id = state.active_window_id().unwrap(); state .renderer_flush - .acknowledged_generation - .load(Ordering::SeqCst) + .windows + .lock() + .unwrap() + .get(&window_id) + .map(|state| state.1) + .unwrap_or(0) } fn assert_receive_timeout(result: Result) { @@ -48,8 +76,8 @@ fn enable_restore(state: &ClientState) { fn enable_restore_in_memory(state: &ClientState) { let mut persisted = state.state.lock().unwrap(); - persisted.restore_enabled = true; - persisted.writes_enabled = true; + persisted.active_mut().restore_enabled = true; + persisted.active_mut().writes_enabled = true; } fn failing_state(initially_failing: bool) -> (TempDir, ClientState, Arc) { @@ -91,6 +119,7 @@ fn display(x: i32, y: i32, width: u32, height: u32) -> DisplayArea { y, width, height, + scale_factor: 1.0, } } fn concurrent_roles(path: &std::path::Path, count: usize) -> Vec { @@ -182,23 +211,67 @@ fn restore_defaults_on_unless_explicitly_disabled() { } #[test] -fn parses_envelopes_and_normalizes_zoom() { +fn parses_exact_envelopes_and_fences_malformed_values() { for bytes in [ br#"not json"#.as_slice(), br#"{"version":0,"restoreEnabled":false}"#.as_slice(), br#"{"version":1,"restoreEnabled":"no"}"#.as_slice(), + br#"{"version":1.5,"restoreEnabled":true}"#.as_slice(), + br#"{"version":"1","restoreEnabled":true}"#.as_slice(), + br#"{"version":1,"restoreEnabled":true,"protocolVersion":1}"#.as_slice(), ] { let state = parse_client_state(bytes); - assert!(state.restore_enabled); - assert_eq!(state.snapshot, None); - assert!(!state.unsupported_future_envelope); + assert!(!state.active().restore_enabled); + assert_eq!(state.active().snapshot, None); + assert!(state.unsupported_future_envelope); } let state = parse_client_state( - br#"{"version":1,"restoreEnabled":false,"snapshot":{"folder":"work"},"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":true,"fullscreen":false,"zoomFactor":20}}"#, + br#"{"version":1,"restoreEnabled":false,"snapshot":{"folder":"work"},"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":true,"fullscreen":false,"zoomFactor":5}}"#, + ); + assert!(!state.active().restore_enabled); + assert_eq!(state.active().snapshot, Some(json!({ "folder": "work" }))); + assert_eq!( + state.active().window.as_ref().unwrap().zoom_factor, + MAX_ZOOM_LEVEL + ); + let v1_with_partition_metadata = parse_client_state( + br#"{"version":1,"restoreEnabled":true,"snapshot":{},"protocolVersion":1,"partitionKeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + ); + assert!(v1_with_partition_metadata.unsupported_future_envelope); + let key = "a".repeat(64); + let window_id = "11111111-1111-4111-8111-111111111111"; + let envelope_for = |version: &str| { + if version.starts_with('1') { + format!(r#"{{"version":{version},"restoreEnabled":true}}"#) + } else if version.starts_with('2') { + format!( + r#"{{"version":{version},"restoreEnabled":true,"snapshot":{{"version":2.0,"sessionPartition":"{key}","partitionKeys":["{key}"]}},"protocolVersion":1.0,"partitionKeys":["{key}"]}}"# + ) + } else { + format!( + r#"{{"version":{version},"activeWindowId":"{window_id}","windowOrder":["{window_id}"],"windows":{{"{window_id}":{{"restoreEnabled":true}}}}}}"# + ) + } + }; + for version in ["1", "1.0", "2", "2.0", "3", "3.0"] { + let envelope = envelope_for(version); + assert!(!parse_client_state(envelope.as_bytes()).unsupported_future_envelope); + } + for version in ["1.5", "2.5", "3.5"] { + let envelope = envelope_for(version); + assert!(parse_client_state(envelope.as_bytes()).unsupported_future_envelope); + } + assert!(parse_client_state( + format!(r#"{{"version":2,"restoreEnabled":true,"snapshot":{{"version":2,"sessionPartition":"{key}","partitionKeys":["{key}"]}},"protocolVersion":1.5,"partitionKeys":["{key}"]}}"#).as_bytes(), + ).unsupported_future_envelope); + assert_eq!( + super::envelope::exact_nonnegative_safe_integer(&json!(9_007_199_254_740_991_u64)), + Some(9_007_199_254_740_991) + ); + assert_eq!( + super::envelope::exact_nonnegative_safe_integer(&json!(9_007_199_254_740_992_u64)), + None ); - assert!(!state.restore_enabled); - assert_eq!(state.snapshot, Some(json!({ "folder": "work" }))); - assert_eq!(state.window.unwrap().zoom_factor, MAX_ZOOM_LEVEL); for (input, expected) in [ (1.25, Some(1.25)), (0.01, Some(0.25)), @@ -250,14 +323,19 @@ 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!( + !parse_client_state(&fs::read(&shared).unwrap()) + .active() + .restore_enabled + ); + assert!(electron.join(CLIENT_STATE_FILENAME).exists()); + assert!(tauri.join(CLIENT_STATE_FILENAME).exists()); } let root = tempfile::tempdir().unwrap(); @@ -286,6 +364,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 +372,97 @@ 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" } +}"#; + 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 unshipped_partitioned_v2_shared_state_is_not_copied_over_shipped_v1_migration() { + 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 v2_state = v2.join(CLIENT_STATE_FILENAME); + fs::create_dir_all(&shared).unwrap(); + fs::create_dir_all(&tauri).unwrap(); + fs::write(&legacy_shared, br#"{"version":2,"restoreEnabled":true}"#).unwrap(); + fs::write( + tauri.join(CLIENT_STATE_FILENAME), + br#"{"version":1,"restoreEnabled":true,"snapshot":{"source":"shipped-v1"}}"#, + ) + .unwrap(); + + let state = ClientState::initialize_at_with_writer_and_election( + &tauri, + &v2.join("election"), + &v2_state, + Some(&legacy_shared), + None, + Arc::new(super::write_atomically), + ) + .unwrap(); + assert_eq!( + state.load().unwrap().snapshot, + json!({ "source": "shipped-v1" }) + ); + assert_eq!( + serde_json::from_slice::(&fs::read(v2_state).unwrap()).unwrap()["version"], + 1 + ); +} + #[test] fn electron_and_tauri_share_the_complete_envelope_across_handoffs() { let root = tempfile::tempdir().unwrap(); @@ -316,6 +486,7 @@ fn electron_and_tauri_share_the_complete_envelope_across_handoffs() { &tauri, &election, &shared, + None, Some(&electron), Arc::new(super::write_atomically), ) @@ -349,6 +520,18 @@ fn normalizes_window_bounds_against_displays() { } } #[test] +fn mixed_dpi_restore_selects_displays_in_physical_coordinates() { + let mut high_dpi = display(1920, 0, 2560, 1440); + high_dpi.scale_factor = 2.0; + assert_eq!( + clamp_window_bounds( + &bounds(1000, 500, 700, 600), + &[display(0, 0, 1920, 1080), high_dpi], + ), + Some(bounds(1000, 120, 800, 600)) + ); +} +#[test] fn stale_files_recover_without_trusting_pid_identity() { for lock_contents in [ b"{\"pid\":999999}".as_slice(), @@ -402,7 +585,7 @@ fn secondary_and_failed_initialization_are_isolated() { let state_path = directory.path().join(CLIENT_STATE_FILENAME); let original = fs::read(&state_path).unwrap(); let secondary = ClientState::initialize_at(directory.path()).unwrap(); - assert_eq!(secondary.load().unwrap(), load(false, true, Value::Null)); + assert_eq!(secondary.load().unwrap(), load(false, false, Value::Null)); assert!(!secondary.save_snapshot(json!({ "replace": true })).unwrap()); assert!(!secondary.set_restore_enabled(false).unwrap()); assert!(!secondary.clear().unwrap()); @@ -420,19 +603,23 @@ fn disable_and_clear_suppress_later_writes() { let state = ClientState::initialize_at(directory.path()).unwrap(); enable_restore(&state); assert!(state.save_snapshot(json!({ "removed": true })).unwrap()); - state.state.lock().unwrap().window = Some(window()); + state.state.lock().unwrap().active_mut().window = Some(window()); if clear { assert!(state.clear().unwrap()); assert_eq!(state.load().unwrap(), load(true, true, Value::Null)); } else { assert!(state.set_restore_enabled(false).unwrap()); assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); - assert_eq!(*state.zoom_level.lock().unwrap(), DEFAULT_ZOOM_LEVEL); + let window_id = state.active_window_id().unwrap(); + assert_eq!( + state.zoom_levels.lock().unwrap()[&window_id], + DEFAULT_ZOOM_LEVEL + ); } let path = directory.path().join(CLIENT_STATE_FILENAME); let persisted = parse_client_state(&fs::read(&path).unwrap()); - assert_eq!(persisted.snapshot, None); - assert_eq!(persisted.window, None); + assert_eq!(persisted.active().snapshot, None); + assert_eq!(persisted.active().window, None); let bytes = fs::read(&path).unwrap(); assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); assert_eq!(fs::read(path).unwrap(), bytes); @@ -444,7 +631,7 @@ fn failed_writes_restore_memory_and_suppression_state() { let (_directory, state, fail) = failing_state(false); enable_restore(&state); assert!(state.save_snapshot(json!({ "kept": true })).unwrap()); - state.state.lock().unwrap().window = Some(window()); + state.state.lock().unwrap().active_mut().window = Some(window()); fail.store(true, Ordering::SeqCst); let error = match operation { "snapshot" => state.save_snapshot(json!({ "lost": true })).unwrap_err(), @@ -456,8 +643,8 @@ fn failed_writes_restore_memory_and_suppression_state() { state.load().unwrap(), load(true, true, json!({ "kept": true })) ); - assert!(state.state.lock().unwrap().window.is_some()); - assert!(state.state.lock().unwrap().writes_enabled); + assert!(state.state.lock().unwrap().active().window.is_some()); + assert!(state.state.lock().unwrap().active().writes_enabled); fail.store(false, Ordering::SeqCst); assert!(state.save_snapshot(json!({ "replacement": true })).unwrap()); } @@ -557,6 +744,40 @@ fn renderer_tokens_and_origins_are_isolated_across_navigation() { } } +#[test] +fn accepted_page_load_stages_renderer_rotation_and_preserves_generation_fencing() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + let window_id = state.active_window_id().unwrap(); + let url = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + state + .renderer_access + .claim_for(&window_id, "outgoing", &url) + .unwrap(); + let generation = state + .renderer_access + .validate_for(&window_id, "outgoing", &url) + .unwrap(); + + state.stage_renderer_page_load(&window_id, &url).unwrap(); + state + .renderer_access + .claim_for(&window_id, "incoming", &url) + .unwrap(); + + assert!(!state + .renderer_access + .is_generation_current_for(&window_id, generation)); + assert!(state + .renderer_access + .validate_for(&window_id, "outgoing", &url) + .is_err()); + state + .renderer_access + .validate_for(&window_id, "incoming", &url) + .unwrap(); +} + #[test] fn reload_preserves_pending_cross_origin_authority_until_incoming_claim() { let directory = tempfile::tempdir().unwrap(); @@ -607,13 +828,16 @@ fn failed_follow_up_navigation_restores_previous_pending_authority() { fn flush_generation_and_request_order_are_strict() { let directory = tempfile::tempdir().unwrap(); let state = Arc::new(ClientState::initialize_at(directory.path()).unwrap()); + let window_id = state.active_window_id().unwrap(); state .renderer_flush - .next_generation - .store(2, Ordering::SeqCst); - state.acknowledge_renderer_flush(1); + .windows + .lock() + .unwrap() + .insert(window_id.clone(), (2, 0)); + state.acknowledge_renderer_flush(&window_id, 1); assert_eq!(acknowledged_generation(&state), 0); - state.acknowledge_renderer_flush(2); + state.acknowledge_renderer_flush(&window_id, 2); assert_eq!(acknowledged_generation(&state), 2); let first = state.renderer_flush.request_lock.lock().unwrap(); let waiting = Arc::clone(&state); @@ -648,6 +872,13 @@ fn ownership_release_drains_active_write_and_blocks_later_writes() { let writing = Arc::clone(&state); let writer = thread::spawn(move || writing.save_snapshot(json!({ "first": true }))); started_rx.recv().unwrap(); + let lock_path = directory.path().join(PRIMARY_LOCK_FILENAME); + let contender = OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&contender).is_err()); let releasing = Arc::clone(&state); let (released_tx, released_rx) = mpsc::channel(); let releaser = thread::spawn(move || { @@ -655,10 +886,13 @@ fn ownership_release_drains_active_write_and_blocks_later_writes() { released_tx.send(()).unwrap(); }); assert_receive_timeout(released_rx.recv_timeout(Duration::from_millis(50))); + assert!(fs2::FileExt::try_lock_exclusive(&contender).is_err()); allow_tx.send(()).unwrap(); assert!(writer.join().unwrap().unwrap()); released_rx.recv().unwrap(); releaser.join().unwrap(); + fs2::FileExt::try_lock_exclusive(&contender).unwrap(); + fs2::FileExt::unlock(&contender).unwrap(); assert!(!state.is_primary()); assert!(!state.save_snapshot(json!({ "tooLate": true })).unwrap()); } @@ -685,7 +919,7 @@ fn ownership_loss_blocks_the_final_atomic_replacement() { "Client state ownership changed before atomic replacement" ); assert!(!state.is_primary()); - assert_eq!(state.load().unwrap(), load(false, true, Value::Null)); + assert_eq!(state.load().unwrap(), load(false, false, Value::Null)); assert!(!directory.path().join(CLIENT_STATE_FILENAME).exists()); } @@ -740,3 +974,504 @@ fn oversized_snapshot_does_not_replace_state() { .is_err()); assert_eq!(state.load().unwrap().snapshot, json!({ "small": true })); } + +fn partition_commit(value: Value) -> super::partitions::PartitionCommit { + serde_json::from_value(value).unwrap() +} + +fn partition_key(content: &str) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(content.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn partition_root(keys: &[String], fields: Value) -> Value { + let Value::Object(mut root) = fields else { + panic!("partition root fields must be an object"); + }; + root.insert("version".to_string(), json!(2)); + root.insert("sessionPartition".to_string(), json!(keys[0])); + root.insert("partitionKeys".to_string(), json!(keys)); + Value::Object(root) +} + +#[test] +fn v1_and_v2_migrate_in_memory_without_load_rewrites() { + let content = "legacy partition"; + let key = partition_key(content); + for initial in [ + json!({ "version": 1, "restoreEnabled": true, "snapshot": { "legacy": 1 } }), + json!({ + "version": 2, + "restoreEnabled": true, + "snapshot": partition_root(std::slice::from_ref(&key), json!({})), + "protocolVersion": 1, + "partitionKeys": [key.clone()] + }), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let original = serde_json::to_vec(&initial).unwrap(); + fs::write(&path, &original).unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(state.save_snapshot(json!({ "mutated": true })).unwrap()); + let persisted: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(persisted["version"], 3); + assert_eq!(persisted["windowOrder"][0], persisted["activeWindowId"]); + } +} + +#[test] +fn v3_records_isolate_tokens_partitions_clear_and_removal() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let window_a = state.active_window_id().unwrap(); + let window_b = "11111111-1111-4111-8111-111111111111".to_string(); + assert!(state.add_window(window_b.clone()).unwrap()); + + let url = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + state + .renderer_access + .claim_for(&window_a, "token-a", &url) + .unwrap(); + state + .renderer_access + .claim_for(&window_b, "token-b", &url) + .unwrap(); + assert!(state + .renderer_access + .validate_for(&window_b, "token-a", &url) + .is_err()); + assert!(state + .save_snapshot_guarded_for(&window_a, json!({ "window": "a" }), || true) + .unwrap()); + assert!(state + .save_snapshot_guarded_for(&window_b, json!({ "window": "b" }), || true) + .unwrap()); + state + .state + .lock() + .unwrap() + .record_mut(&window_a) + .unwrap() + .window = Some(window()); + state + .state + .lock() + .unwrap() + .record_mut(&window_b) + .unwrap() + .window = Some(NativeWindowState { + bounds: bounds(40, 50, 1000, 700), + maximized: false, + fullscreen: true, + zoom_factor: 1.25, + }); + assert_eq!( + state.load_window(&window_a).unwrap().snapshot, + json!({ "window": "a" }) + ); + assert_eq!( + state.load_window(&window_b).unwrap().snapshot, + json!({ "window": "b" }) + ); + + let content_a = "partition a"; + let key_a = partition_key(content_a); + let content_b = "partition b"; + let key_b = partition_key(content_b); + assert!(state + .commit_partitions_guarded_for( + &window_a, + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key_a), json!({})), + "partitions": { key_a.clone(): content_a }, + "partitionKeys": [key_a.clone()] + })), + || true, + ) + .unwrap()); + assert!(state + .commit_partitions_guarded_for( + &window_b, + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key_b), json!({})), + "partitions": { key_b.clone(): content_b }, + "partitionKeys": [key_b.clone()] + })), + || true, + ) + .unwrap()); + assert_eq!( + state + .load_partition_guarded_for(&window_a, &key_b, || true) + .unwrap(), + None + ); + assert!(state.clear_guarded(&window_a, || true).unwrap()); + assert!(!directory.path().join("partitions").join(key_a).exists()); + assert!(directory.path().join("partitions").join(&key_b).exists()); + assert_eq!( + state + .load_partition_guarded_for(&window_b, &key_b, || true) + .unwrap() + .as_deref(), + Some(content_b) + ); + + assert!(state.remove_window(&window_a).unwrap()); + let persisted: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + assert_eq!(persisted["activeWindowId"], window_b); + assert_eq!(persisted["windowOrder"], json!([window_b])); + assert!(state.remove_window(&window_b).unwrap()); + assert!(state.window_ids().is_empty()); + let persisted: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + assert_eq!(persisted["activeWindowId"], window_b); + assert_eq!(persisted["windowOrder"], json!([])); + assert_eq!(persisted["windows"], json!({})); + assert!(!directory.path().join("partitions").join(key_b).exists()); +} + +#[test] +fn failed_window_removal_preserves_renderer_zoom_and_partitions() { + let (directory, state, fail) = failing_state(false); + enable_restore(&state); + let window_id = state.active_window_id().unwrap(); + let renderer_url = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + state + .renderer_access + .claim_for(&window_id, "renderer-token", &renderer_url) + .unwrap(); + state + .zoom_levels + .lock() + .unwrap() + .insert(window_id.clone(), 1.5); + let content = "retained partition"; + let key = partition_key(content); + assert!(state + .commit_partitions_guarded_for( + &window_id, + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key), json!({})), + "partitions": { key.clone(): content }, + "partitionKeys": [key.clone()] + })), + || true, + ) + .unwrap()); + fail.store(true, Ordering::SeqCst); + + assert!(state.remove_window(&window_id).is_err()); + + assert!(state.window_ids().contains(&window_id)); + state + .renderer_access + .validate_for(&window_id, "renderer-token", &renderer_url) + .unwrap(); + assert_eq!(state.zoom_levels.lock().unwrap()[&window_id], 1.5); + assert!(directory.path().join("partitions").join(key).exists()); +} + +#[test] +fn invalid_v3_is_frozen_until_explicit_clear() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let window_id = "11111111-1111-4111-8111-111111111111"; + let original = format!( + " {{ \"version\": 3, \"activeWindowId\": \"{window_id}\", \"windowOrder\": [\"{window_id}\"], \"windows\": {{ \"{window_id}\": {{ \"restoreEnabled\": true, \"unknown\": 1 }} }} }} " + ); + fs::write(&path, original.as_bytes()).unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + assert!(state.clear().unwrap()); + let persisted: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(persisted["version"], 3); +} + +#[test] +fn partition_protocol_and_hashes_are_validated() { + assert_eq!(MAX_COMMIT_BYTES, 256 * 1024 * 1024); + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let content = "partition"; + let key = partition_key(content); + for payload in [ + json!({ "protocolVersion": 2, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content }, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): "wrong" }, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content }, "partitionKeys": [key.clone(), key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": {}, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content, partition_key("extra"): "extra" }, "partitionKeys": [key.clone()] }), + json!({ "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "partitions": { key.clone(): content }, "partitionKeys": [partition_key("other")] }), + ] { + assert!(state + .commit_partitions_guarded(partition_commit(payload), || true) + .is_err()); + } + + let mut partitions = serde_json::Map::new(); + let mut partition_keys = Vec::new(); + for index in 0..9 { + let content = format!("{index}{}", "x".repeat(1024 * 1024 - 1)); + let key = partition_key(&content); + partitions.insert(key.clone(), Value::String(content)); + partition_keys.push(key); + } + partition_keys.sort(); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(&partition_keys, json!({})), + "partitions": partitions, "partitionKeys": partition_keys + })), + || true, + ) + .unwrap()); +} + +#[test] +fn partition_commit_read_and_failure_safety() { + let (directory, state, fail) = failing_state(false); + enable_restore(&state); + let old_content = "old partition"; + let old_key = partition_key(old_content); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&old_key), json!({ "root": "old" })), + "partitions": { old_key.clone(): old_content }, "partitionKeys": [old_key.clone()] + })), + || true, + ) + .unwrap()); + let root: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + let window_id = root["activeWindowId"].as_str().unwrap(); + assert_eq!(root["version"], 3); + assert_eq!(root["windows"][window_id]["partitionProtocolVersion"], 1); + assert!(root["windows"][window_id].get("protocolVersion").is_none()); + assert_eq!( + state + .load_partition_guarded(&old_key, || true) + .unwrap() + .as_deref(), + Some(old_content) + ); + + let next_content = "next partition"; + let next_key = partition_key(next_content); + fail.store(true, Ordering::SeqCst); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&next_key), json!({ "root": "next" })), + "partitions": { next_key.clone(): next_content }, "partitionKeys": [next_key.clone()] + })), + || true, + ) + .is_err()); + assert_eq!( + state.load().unwrap().snapshot, + partition_root(std::slice::from_ref(&old_key), json!({ "root": "old" })) + ); + assert!(directory.path().join("partitions").join(&old_key).exists()); + assert!(directory.path().join("partitions").join(&next_key).exists()); + fail.store(false, Ordering::SeqCst); + assert!(state.clear().unwrap()); + assert!(!directory.path().join("partitions").join(&old_key).exists()); + assert!(!directory.path().join("partitions").join(&next_key).exists()); +} + +#[test] +fn v2_window_persistence_and_partition_to_monolithic_cleanup() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let content = "window partition"; + let key = partition_key(content); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, "snapshot": partition_root(std::slice::from_ref(&key), json!({ "root": true })), + "partitions": { key.clone(): content }, "partitionKeys": [key.clone()] + })), + || true, + ) + .unwrap()); + state.state.lock().unwrap().active_mut().window = Some(window()); + state.flush().unwrap(); + let root: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + let window_id = root["activeWindowId"].as_str().unwrap(); + assert_eq!(root["version"], 3); + assert_eq!(root["windows"][window_id]["partitionProtocolVersion"], 1); + assert_eq!( + root["windows"][window_id]["partitionKeys"], + json!([key.clone()]) + ); + state.release_locks(); + + let restarted = ClientState::initialize_at(directory.path()).unwrap(); + assert_eq!( + restarted.state.lock().unwrap().active().window, + Some(window()) + ); + assert_eq!( + restarted + .load_partition_guarded(&key, || true) + .unwrap() + .as_deref(), + Some(content) + ); + assert!(restarted + .save_snapshot(json!({ "monolithic": true })) + .unwrap()); + let root: Value = + serde_json::from_slice(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) + .unwrap(); + let window_id = root["activeWindowId"].as_str().unwrap(); + assert_eq!(root["version"], 3); + assert!(root["windows"][window_id] + .get("partitionProtocolVersion") + .is_none()); + assert!(!directory.path().join("partitions").join(key).exists()); +} + +#[test] +fn malformed_and_future_v2_roots_fence_writes_and_gc_until_clear() { + let content = "keep orphan"; + let key = partition_key(content); + for root in [ + json!({ "version": 2, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 2, "partitionKeys": [key.clone()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.clone(), key.clone()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.to_uppercase()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": "x".repeat(1024 * 1024), "protocolVersion": 1, "partitionKeys": [key.clone()] }), + json!({ "version": 2, "restoreEnabled": "yes", "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.clone()] }), + json!({ "version": 3, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1, "partitionKeys": [key.clone()] }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "protocolVersion": 1, "partitionKeys": [key.clone()], "extra": true }), + json!({ "version": 2, "restoreEnabled": true, "snapshot": partition_root(std::slice::from_ref(&key), json!({})), "protocolVersion": 1, "partitionKeys": [partition_key("other")] }), + json!({ "version": 1, "restoreEnabled": true, "snapshot": {}, "protocolVersion": 1 }), + json!({ "version": 1.5, "restoreEnabled": true }), + json!({ "version": "2", "restoreEnabled": true }), + json!("not an envelope"), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let original = serde_json::to_vec(&root).unwrap(); + fs::write(&path, &original).unwrap(); + fs::create_dir(directory.path().join("partitions")).unwrap(); + let partition_path = directory.path().join("partitions").join(&key); + fs::write(&partition_path, content).unwrap(); + let writes = Arc::new(AtomicUsize::new(0)); + let writer_writes = Arc::clone(&writes); + let state = ClientState::initialize_at_with_writer( + directory.path(), + Arc::new(move |path, bytes, ownership_valid| { + writer_writes.fetch_add(1, Ordering::SeqCst); + super::write_atomically(path, bytes, ownership_valid) + }), + ) + .unwrap(); + assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); + assert_eq!(state.load_partition_guarded(&key, || true).unwrap(), None); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + assert!(!state.set_restore_enabled(false).unwrap()); + state.flush().unwrap(); + assert_eq!(writes.load(Ordering::SeqCst), 0); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(partition_path.exists()); + + assert!(state.clear().unwrap()); + assert_eq!(writes.load(Ordering::SeqCst), 1); + assert_eq!( + serde_json::from_slice::(&fs::read(path).unwrap()).unwrap()["version"], + 3 + ); + assert!(!partition_path.exists()); + } + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let original = b"{not json"; + fs::write(&path, original).unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + state.flush().unwrap(); + assert_eq!(fs::read(path).unwrap(), original); +} + +#[test] +fn partition_directory_and_gc_reject_unsafe_entries() { + let occupied = tempfile::tempdir().unwrap(); + let occupied_state = ClientState::initialize_at(occupied.path()).unwrap(); + enable_restore(&occupied_state); + fs::write(occupied.path().join("partitions"), "not a directory").unwrap(); + let occupied_content = "occupied"; + let occupied_key = partition_key(occupied_content); + assert!(occupied_state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&occupied_key), json!({})), + "partitions": { occupied_key.clone(): occupied_content }, + "partitionKeys": [occupied_key] + })), + || true, + ) + .is_err()); + + let gc = tempfile::tempdir().unwrap(); + let gc_state = ClientState::initialize_at(gc.path()).unwrap(); + enable_restore(&gc_state); + let partitions = gc.path().join("partitions"); + let removable = partition_key("orphan"); + fs::create_dir(&partitions).unwrap(); + fs::write(partitions.join(&removable), "orphan").unwrap(); + fs::write(partitions.join("unrelated.txt"), "keep").unwrap(); + fs::create_dir(partitions.join("f".repeat(64))).unwrap(); + assert!(gc_state.clear().unwrap()); + assert!(!partitions.join(removable).exists()); + assert!(partitions.join("unrelated.txt").exists()); + assert!(partitions.join("f".repeat(64)).exists()); +} + +#[cfg(unix)] +#[test] +fn partition_directory_symlink_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + let target = directory.path().join("partition-target"); + fs::create_dir(&target).unwrap(); + symlink(target, directory.path().join("partitions")).unwrap(); + let content = "linked"; + let key = partition_key(content); + assert!(state + .commit_partitions_guarded( + partition_commit(json!({ + "protocolVersion": 1, + "snapshot": partition_root(std::slice::from_ref(&key), json!({})), + "partitions": { key.clone(): content }, + "partitionKeys": [key] + })), + || true, + ) + .is_err()); +} diff --git a/packages/tauri-app/src-tauri/src/client_state/window.rs b/packages/tauri-app/src-tauri/src/client_state/window.rs index fbcad4f87..4d6bc662c 100644 --- a/packages/tauri-app/src-tauri/src/client_state/window.rs +++ b/packages/tauri-app/src-tauri/src/client_state/window.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::atomic::Ordering; use std::time::Duration; -use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, WindowEvent}; +use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, WindowEvent}; const MIN_WINDOW_WIDTH: i32 = 800; const MIN_WINDOW_HEIGHT: i32 = 600; @@ -37,20 +37,47 @@ pub(super) struct DisplayArea { pub(super) y: i32, pub(super) width: u32, pub(super) height: u32, + pub(super) scale_factor: f64, +} + +struct ClampedBounds { + logical: WindowBounds, + physical: WindowBounds, } pub(super) fn normalize_window_state(value: &Value) -> Option { let value = value.as_object()?; + if value.len() != 4 + || !["bounds", "maximized", "fullscreen", "zoomFactor"] + .iter() + .all(|key| value.contains_key(*key)) + { + return None; + } + let bounds_value = value.get("bounds")?.as_object()?; + if bounds_value.len() != 4 + || !["x", "y", "width", "height"] + .iter() + .all(|key| bounds_value.contains_key(*key)) + { + return None; + } let bounds: WindowBounds = serde_json::from_value(value.get("bounds")?.clone()).ok()?; if bounds.width <= 0 || bounds.height <= 0 { return None; } + let maximized = value.get("maximized")?.as_bool()?; + let fullscreen = value.get("fullscreen")?.as_bool()?; + let zoom_factor = value.get("zoomFactor")?.as_f64()?; + if !zoom_factor.is_finite() || !(MIN_ZOOM_LEVEL..=MAX_ZOOM_LEVEL).contains(&zoom_factor) { + return None; + } Some(NativeWindowState { bounds, - maximized: value.get("maximized").and_then(Value::as_bool) == Some(true), - fullscreen: value.get("fullscreen").and_then(Value::as_bool) == Some(true), - zoom_factor: normalize_zoom_level(value.get("zoomFactor").and_then(Value::as_f64)), + maximized, + fullscreen, + zoom_factor, }) } @@ -71,10 +98,22 @@ pub(super) fn clamp_window_bounds( bounds: &WindowBounds, displays: &[DisplayArea], ) -> Option { + clamp_window_bounds_for_restore(bounds, displays).map(|bounds| bounds.logical) +} + +fn clamp_window_bounds_for_restore( + bounds: &WindowBounds, + displays: &[DisplayArea], +) -> Option { let display = displays .iter() .copied() - .filter(|display| display.width > 0 && display.height > 0) + .filter(|display| { + display.width > 0 + && display.height > 0 + && display.scale_factor.is_finite() + && display.scale_factor > 0.0 + }) .reduce(|best, candidate| { let best_intersection = intersection_area(bounds, best); let candidate_intersection = intersection_area(bounds, candidate); @@ -88,46 +127,63 @@ pub(super) fn clamp_window_bounds( best } })?; + let scale = display.scale_factor; let maximum_width = display.width.min(i32::MAX as u32) as i32; let maximum_height = display.height.min(i32::MAX as u32) as i32; - let width = bounds - .width - .clamp(MIN_WINDOW_WIDTH.min(maximum_width), maximum_width); - let height = bounds - .height - .clamp(MIN_WINDOW_HEIGHT.min(maximum_height), maximum_height); + let requested_width = (f64::from(bounds.width) * scale).round() as i32; + let requested_height = (f64::from(bounds.height) * scale).round() as i32; + let minimum_width = (f64::from(MIN_WINDOW_WIDTH) * scale).round() as i32; + let minimum_height = (f64::from(MIN_WINDOW_HEIGHT) * scale).round() as i32; + let width = requested_width.clamp(minimum_width.min(maximum_width), maximum_width); + let height = requested_height.clamp(minimum_height.min(maximum_height), maximum_height); let minimum_x = i64::from(display.x); let minimum_y = i64::from(display.y); let maximum_x = (minimum_x + i64::from(maximum_width - width)).min(i64::from(i32::MAX)); let maximum_y = (minimum_y + i64::from(maximum_height - height)).min(i64::from(i32::MAX)); - Some(WindowBounds { - x: i64::from(bounds.x).clamp(minimum_x, maximum_x) as i32, - y: i64::from(bounds.y).clamp(minimum_y, maximum_y) as i32, + let physical = WindowBounds { + x: ((f64::from(bounds.x) * scale).round() as i64).clamp(minimum_x, maximum_x) as i32, + y: ((f64::from(bounds.y) * scale).round() as i64).clamp(minimum_y, maximum_y) as i32, width, height, + }; + Some(ClampedBounds { + logical: WindowBounds { + x: (f64::from(physical.x) / scale).round() as i32, + y: (f64::from(physical.y) / scale).round() as i32, + width: (f64::from(physical.width) / scale).round() as i32, + height: (f64::from(physical.height) / scale).round() as i32, + }, + physical, }) } fn intersection_area(bounds: &WindowBounds, display: DisplayArea) -> i64 { - let left = i64::from(bounds.x).max(i64::from(display.x)); - let top = i64::from(bounds.y).max(i64::from(display.y)); - let right = (i64::from(bounds.x) + i64::from(bounds.width)) - .min(i64::from(display.x) + i64::from(display.width)); - let bottom = (i64::from(bounds.y) + i64::from(bounds.height)) - .min(i64::from(display.y) + i64::from(display.height)); + let scale = display.scale_factor; + let x = (f64::from(bounds.x) * scale).round() as i64; + let y = (f64::from(bounds.y) * scale).round() as i64; + let width = (f64::from(bounds.width) * scale).round() as i64; + let height = (f64::from(bounds.height) * scale).round() as i64; + let left = x.max(i64::from(display.x)); + let top = y.max(i64::from(display.y)); + let right = (x + width).min(i64::from(display.x) + i64::from(display.width)); + let bottom = (y + height).min(i64::from(display.y) + i64::from(display.height)); (right - left).max(0) * (bottom - top).max(0) } fn center_distance_squared(bounds: &WindowBounds, display: DisplayArea) -> i128 { - let bounds_x = i128::from(bounds.x) * 2 + i128::from(bounds.width); - let bounds_y = i128::from(bounds.y) * 2 + i128::from(bounds.height); + let scale = display.scale_factor; + let bounds_x = ((f64::from(bounds.x) * 2.0 + f64::from(bounds.width)) * scale).round() as i128; + let bounds_y = ((f64::from(bounds.y) * 2.0 + f64::from(bounds.height)) * scale).round() as i128; let display_x = i128::from(display.x) * 2 + i128::from(display.width); let display_y = i128::from(display.y) * 2 + i128::from(display.height); (bounds_x - display_x).pow(2) + (bounds_y - display_y).pow(2) } -fn capture_main_window_in_memory(app: &AppHandle) { +fn capture_window_in_memory(app: &AppHandle, window_label: &str, window_id: &str, persisted: bool) { + if !persisted { + return; + } let Some(client_state) = app.try_state::() else { return; }; @@ -137,10 +193,13 @@ fn capture_main_window_in_memory(app: &AppHandle) { if !client_state.is_primary() { return; } - if client_state.normal_writes_suppressed().unwrap_or(true) { + if client_state + .normal_writes_suppressed(window_id) + .unwrap_or(true) + { return; } - let Some(window) = app.get_webview_window("main") else { + let Some(window) = app.get_webview_window(window_label) else { return; }; @@ -168,17 +227,21 @@ fn capture_main_window_in_memory(app: &AppHandle) { None }; let zoom_factor = client_state - .zoom_level + .zoom_levels .lock() - .map(|zoom| *zoom) + .ok() + .and_then(|zoom| zoom.get(window_id).copied()) .unwrap_or(DEFAULT_ZOOM_LEVEL); let Ok(mut state) = client_state.state.lock() else { return; }; + let Ok(record) = state.record_mut(window_id) else { + return; + }; let bounds = - current_bounds.or_else(|| state.window.as_ref().map(|window| window.bounds.clone())); + current_bounds.or_else(|| record.window.as_ref().map(|window| window.bounds.clone())); if let Some(bounds) = bounds { - state.window = Some(NativeWindowState { + record.window = Some(NativeWindowState { bounds, maximized, fullscreen, @@ -207,10 +270,16 @@ fn schedule_flush(app: &AppHandle) { } #[cfg(windows)] -fn register_native_zoom_handler(window: &tauri::WebviewWindow, app: &AppHandle) { +fn register_native_zoom_handler( + window: &tauri::WebviewWindow, + app: &AppHandle, + window_id: String, + persisted: bool, +) { use webview2_com::ZoomFactorChangedEventHandler; let app = app.clone(); + let window_label = window.label().to_string(); if let Err(err) = window.with_webview(move |webview| { let controller = webview.controller(); let callback_app = app.clone(); @@ -230,16 +299,16 @@ fn register_native_zoom_handler(window: &tauri::WebviewWindow, app: &AppHandle) let Some(client_state) = callback_app.try_state::() else { return Ok(()); }; - let Ok(mut zoom_level) = client_state.zoom_level.lock() else { + let Ok(mut zoom_levels) = client_state.zoom_levels.lock() else { return Ok(()); }; - *zoom_level = normalized; - drop(zoom_level); + zoom_levels.insert(window_id.clone(), normalized); + drop(zoom_levels); if client_state.is_primary() - && client_state.normal_writes_suppressed().ok() == Some(false) + && client_state.normal_writes_suppressed(&window_id).ok() == Some(false) { - capture_main_window_in_memory(&callback_app); + capture_window_in_memory(&callback_app, &window_label, &window_id, persisted); schedule_flush(&callback_app); } Ok(()) @@ -253,53 +322,62 @@ fn register_native_zoom_handler(window: &tauri::WebviewWindow, app: &AppHandle) } } -pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { +pub fn setup_local_window( + app: &AppHandle, + window: &tauri::WebviewWindow, + window_id: &str, + persisted: bool, +) -> Result<(), String> { let client_state = app.state::(); - let window = app - .get_webview_window("main") - .ok_or_else(|| "main window was not created".to_string())?; + if !persisted { + client_state.register_ephemeral_window(window_id.to_string()); + } let initial_zoom = client_state - .zoom_level + .zoom_levels .lock() - .map(|zoom| *zoom) + .ok() + .and_then(|zoom| zoom.get(window_id).copied()) .unwrap_or(DEFAULT_ZOOM_LEVEL); let _ = window.set_zoom(initial_zoom); #[cfg(windows)] - register_native_zoom_handler(&window, app); - if !client_state.is_primary() { + register_native_zoom_handler(window, app, window_id.to_string(), persisted); + if !client_state.is_primary() || !persisted { let _ = window.show(); return Ok(()); } let saved_window = { let state = client_state.state.lock().map_err(|err| err.to_string())?; - state + let record = state.record(window_id)?; + record .restore_enabled - .then(|| state.window.clone()) + .then(|| record.window.clone()) .flatten() }; if let Some(mut saved_window) = saved_window { - let startup_scale = window.scale_factor().unwrap_or(1.0); let displays = window .available_monitors() .unwrap_or_default() .into_iter() .map(|monitor| { let work_area = monitor.work_area(); - let position = work_area.position.to_logical::(startup_scale); - let size = work_area.size.to_logical::(startup_scale); DisplayArea { - x: position.x, - y: position.y, - width: size.width, - height: size.height, + x: work_area.position.x, + y: work_area.position.y, + width: work_area.size.width, + height: work_area.size.height, + scale_factor: monitor.scale_factor(), } }) .collect::>(); - if let Some(bounds) = clamp_window_bounds(&saved_window.bounds, &displays) { - let _ = window.set_size(LogicalSize::new(bounds.width as u32, bounds.height as u32)); - let _ = window.set_position(LogicalPosition::new(bounds.x, bounds.y)); - saved_window.bounds = bounds; + if let Some(bounds) = clamp_window_bounds_for_restore(&saved_window.bounds, &displays) { + let _ = window.set_size(PhysicalSize::new( + bounds.physical.width as u32, + bounds.physical.height as u32, + )); + let _ = + window.set_position(PhysicalPosition::new(bounds.physical.x, bounds.physical.y)); + saved_window.bounds = bounds.logical; } else if let Ok(position) = window.outer_position() { if let Ok(size) = window.inner_size() { let scale_factor = window.scale_factor().unwrap_or(1.0); @@ -314,7 +392,9 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { } } if let Ok(mut state) = client_state.state.lock() { - state.window = Some(saved_window.clone()); + if let Ok(record) = state.record_mut(window_id) { + record.window = Some(saved_window.clone()); + } } let _ = window.set_zoom(saved_window.zoom_factor); if saved_window.maximized { @@ -328,13 +408,15 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { } } - capture_main_window_in_memory(app); + capture_window_in_memory(app, window.label(), window_id, persisted); let app_handle = app.clone(); + let window_label = window.label().to_string(); + let window_id = window_id.to_string(); window.on_window_event(move |event| match event { WindowEvent::Resized(_) | WindowEvent::Moved(_) | WindowEvent::ScaleFactorChanged { .. } => { - capture_main_window_in_memory(&app_handle); + capture_window_in_memory(&app_handle, &window_label, &window_id, persisted); schedule_flush(&app_handle); } _ => {} @@ -343,8 +425,8 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { Ok(()) } -pub fn set_main_window_zoom(app: &AppHandle, next_zoom: f64) { - let Some(window) = app.get_webview_window("main") else { +pub fn set_local_window_zoom(app: &AppHandle, window_label: &str, next_zoom: f64) { + let Some(window) = app.get_webview_window(window_label) else { return; }; let normalized = normalize_zoom_level(Some(next_zoom)); @@ -354,26 +436,59 @@ pub fn set_main_window_zoom(app: &AppHandle, next_zoom: f64) { let Some(client_state) = app.try_state::() else { return; }; - if let Ok(mut zoom_level) = client_state.zoom_level.lock() { - *zoom_level = normalized; + let Ok(window_id) = crate::identity::local_window_id(window_label) else { + return; + }; + if let Ok(mut zoom_levels) = client_state.zoom_levels.lock() { + zoom_levels.insert(window_id.clone(), normalized); } - capture_main_window_in_memory(app); + let persisted = app + .try_state::() + .and_then(|windows| windows.record(window_label)) + .is_some_and(|record| record.persisted); + capture_window_in_memory(app, window_label, &window_id, persisted); if let Err(err) = client_state.flush() { eprintln!("[client-state] failed to save zoom level: {err}"); } } -pub fn main_window_zoom(app: &AppHandle) -> f64 { +pub fn local_window_zoom(app: &AppHandle, window_label: &str) -> f64 { app.try_state::() - .and_then(|state| state.zoom_level.lock().ok().map(|zoom| *zoom)) + .and_then(|state| { + let window_id = crate::identity::local_window_id(window_label).ok()?; + state + .zoom_levels + .lock() + .ok() + .and_then(|zoom| zoom.get(&window_id).copied()) + }) .unwrap_or(DEFAULT_ZOOM_LEVEL) } -pub fn capture_and_flush_main_window(app: &AppHandle) { - capture_main_window_in_memory(app); +pub fn capture_and_flush_window(app: &AppHandle, window_label: &str) { + let Some(record) = app + .try_state::() + .and_then(|windows| windows.record(window_label)) + else { + return; + }; + capture_window_in_memory(app, window_label, &record.id, record.persisted); + if let Some(state) = app.try_state::() { + if let Err(err) = state.flush() { + eprintln!("[client-state] failed to flush local window state: {err}"); + } + } +} + +pub fn capture_and_flush_all_windows(app: &AppHandle) { + if let Some(windows) = app.try_state::() { + for record in windows.records() { + capture_window_in_memory(app, &record.label, &record.id, record.persisted); + } + } if let Some(state) = app.try_state::() { if let Err(err) = state.flush() { - eprintln!("[client-state] failed to flush main window state: {err}"); + eprintln!("[client-state] failed to flush local window state: {err}"); } } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs deleted file mode 100644 index 372ca37c6..000000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ /dev/null @@ -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, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct DesktopEventsStartRequest { - pub reconnect: Option, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct DesktopEventReconnectPolicy { - pub initial_delay_ms: Option, - pub max_delay_ms: Option, - pub multiplier: Option, - pub max_attempts: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopEventsStartResult { - pub started: bool, - pub generation: Option, - pub reason: Option, -} - -#[derive(Clone, Debug, PartialEq)] -struct ResolvedDesktopEventReconnectPolicy { - initial_delay_ms: u64, - max_delay_ms: u64, - multiplier: f64, - max_attempts: Option, -} - -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, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopEventStreamStatusPayload { - generation: u64, - state: &'static str, - reconnect_attempt: u32, - terminal: bool, - reason: Option, - next_delay_ms: Option, - status_code: Option, - 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>, - config: Option, -} - -pub struct DesktopEventTransportManager { - state: Arc>, - generation: Arc, -} - -enum ReaderMessage { - Activity, - Event(Value), - Ping(Value), - End(Option), -} - -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, -} - -#[derive(Default)] -struct PendingBatch { - events: Vec, -} - -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, - request: Option, - ) -> 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 { - 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 { - 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 { - 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 { - 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> { - 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 { - 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; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs deleted file mode 100644 index f91bcb760..000000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs +++ /dev/null @@ -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 { - 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) - ) - } -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs deleted file mode 100644 index 33737f9c8..000000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ /dev/null @@ -1,325 +0,0 @@ -use super::*; -use reqwest::blocking::RequestBuilder; - -pub(super) fn build_stream_client() -> Result { - 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 { - 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 { - 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 { - 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, - stop: Arc, - generation_atomic: Arc, - generation: u64, -) { - let mut reader = BufReader::new(response); - let mut line = String::new(); - let mut event_name: Option = None; - let mut data_lines: Vec = 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, - data_lines: &mut Vec, -) -> 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, - event_name: &Option, - 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 { - if lines.is_empty() { - return None; - } - - let payload = lines.join("\n").trim().to_string(); - if payload.is_empty() { - return None; - } - - serde_json::from_str::(&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") - ); - } -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs deleted file mode 100644 index f2440a201..000000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ /dev/null @@ -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)); -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs deleted file mode 100644 index 5f0ed2314..000000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ /dev/null @@ -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, - generation: u64, - stop: Arc, - 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, - generation: u64, - stop: Arc, - policy: &ResolvedDesktopEventReconnectPolicy, - reconnect_attempt: &mut u32, - state_name: &'static str, - reason: Option, - status_code: Option, - 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, - generation: u64, - stop: Arc, - 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, - generation: u64, - stop: Arc, - stats: &mut DesktopEventTransportStats, -) -> Option { - let (tx, rx) = mpsc::sync_channel::(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, - 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, - 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, - next_delay_ms: Option, - status_code: Option, - 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, 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) -} diff --git a/packages/tauri-app/src-tauri/src/identity.rs b/packages/tauri-app/src-tauri/src/identity.rs new file mode 100644 index 000000000..5c2840921 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/identity.rs @@ -0,0 +1,241 @@ +use sha2::{Digest, Sha256}; +use std::path::{Component, Path, PathBuf}; + +pub(crate) const STABLE_IDENTIFIER: &str = "ai.neuralnomads.codenomad.client"; +pub(crate) const LOCAL_WINDOW_PREFIX: &str = "local-"; +const DEFAULT_CONFIG: &str = "~/.config/codenomad/config.json"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct IdentityScope { + pub(crate) channel: String, + pub(crate) config_identity: String, + pub(crate) suffix: String, + pub(crate) scoped: bool, + pub(crate) identifier: String, + pub(crate) client_state_directory: Option, + pub(crate) webview_data_directory: PathBuf, +} + +pub(crate) fn local_window_label(window_id: &str) -> Result { + let uuid = + uuid::Uuid::parse_str(window_id).map_err(|_| "Invalid local window UUID".to_string())?; + let normalized = uuid.to_string(); + if normalized != window_id { + return Err("Local window UUID must be lowercase".to_string()); + } + Ok(format!("{LOCAL_WINDOW_PREFIX}{normalized}")) +} + +pub(crate) fn local_window_id(label: &str) -> Result { + let id = label + .strip_prefix(LOCAL_WINDOW_PREFIX) + .ok_or_else(|| "Native operation is limited to local windows".to_string())?; + local_window_label(id)?; + Ok(id.to_string()) +} + +pub(crate) fn resolve_update_channel( + explicit: Option<&str>, + version: &str, + packaged: bool, +) -> String { + if let Some(value) = explicit.map(str::trim).filter(|value| !value.is_empty()) { + let mut normalized = String::new(); + for character in value.to_ascii_lowercase().chars() { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + normalized.push(character); + } else if !normalized.ends_with('-') { + normalized.push('-'); + } + } + return normalized; + } + if !packaged { + return "dev".to_string(); + } + let lower = version.to_ascii_lowercase(); + if lower.contains("-dev.") || lower.contains("-dev-") { + "dev".to_string() + } else { + "stable".to_string() + } +} + +fn lexical_normalize(path: PathBuf) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if normalized.file_name().is_some() { + normalized.pop(); + } else if !normalized.has_root() { + normalized.push(".."); + } + } + value => normalized.push(value.as_os_str()), + } + } + normalized +} + +pub(crate) fn normalize_config_identity(raw: Option<&str>, cwd: &Path, home: &Path) -> String { + let value = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_CONFIG); + let mut path = if value == "~" { + home.to_path_buf() + } else if let Some(rest) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + home.join(rest) + } else { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }; + path = lexical_normalize(path); + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + path.set_file_name("config.yaml"); + } else if !path.extension().is_some_and(|extension| { + extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") + }) { + path.push("config.yaml"); + } + let identity = path.to_string_lossy().into_owned(); + if cfg!(windows) { + identity.replace('/', "\\").to_ascii_lowercase() + } else { + identity + } +} + +fn electron_user_data_base(home: &Path) -> PathBuf { + if cfg!(windows) { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Roaming")) + .join("CodeNomad") + } else if cfg!(target_os = "macos") { + home.join("Library/Application Support/CodeNomad") + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")) + .join("CodeNomad") + } +} + +pub(crate) fn resolve_scope( + explicit_channel: Option<&str>, + cli_config: Option<&str>, + version: &str, + packaged: bool, + cwd: &Path, + home: &Path, + local_data: &Path, +) -> IdentityScope { + let channel = resolve_update_channel(explicit_channel, version, packaged); + let config_identity = normalize_config_identity(cli_config, cwd, home); + let default_identity = normalize_config_identity(None, cwd, home); + let scoped = channel != "stable" || config_identity != default_identity; + let digest = Sha256::digest(format!("{channel}\0{config_identity}").as_bytes()); + let suffix = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let scope_name = format!("{channel}-{suffix}"); + let identifier = if scoped { + format!("{STABLE_IDENTIFIER}.scope.s{suffix}") + } else { + STABLE_IDENTIFIER.to_string() + }; + let webview_root = local_data.join(format!("{STABLE_IDENTIFIER}-v2")); + IdentityScope { + channel, + config_identity, + suffix, + scoped, + identifier, + client_state_directory: scoped.then(|| { + electron_user_data_base(home) + .join("scopes") + .join(&scope_name) + .join("client-state") + }), + webview_data_directory: if scoped { + webview_root.join("scopes").join(scope_name) + } else { + webview_root + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_and_default_compatibility_match_electron() { + assert_eq!( + resolve_update_channel(Some("Beta Channel"), "1.0.0", false), + "beta-channel" + ); + assert_eq!(resolve_update_channel(None, "1.0.0", false), "dev"); + assert_eq!(resolve_update_channel(None, "1.0.0-dev.2", true), "dev"); + assert_eq!(resolve_update_channel(None, "1.0.0", true), "stable"); + let root = Path::new("/home/dev"); + let stable = resolve_scope(None, None, "1.0.0", true, root, root, Path::new("/local")); + assert!(!stable.scoped); + assert_eq!(stable.identifier, STABLE_IDENTIFIER); + assert_eq!(stable.client_state_directory, None); + let alternate = resolve_scope( + None, + Some("other/config.json"), + "1.0.0", + true, + root, + root, + Path::new("/local"), + ); + assert!(alternate.scoped); + assert!(alternate + .identifier + .starts_with(&format!("{STABLE_IDENTIFIER}.scope.s"))); + assert!(alternate + .client_state_directory + .unwrap() + .ends_with(Path::new("client-state"))); + } + + #[test] + fn config_json_and_yaml_have_one_semantic_identity() { + let cwd = Path::new("/work"); + let home = Path::new("/home/dev"); + assert_eq!( + normalize_config_identity(Some("config.json"), cwd, home), + normalize_config_identity(Some("config.yaml"), cwd, home) + ); + assert_eq!( + normalize_config_identity(Some("../work/./config.json"), cwd, home), + normalize_config_identity(Some("config.yaml"), cwd, home) + ); + } + + #[test] + fn local_labels_are_lowercase_uuid_backed() { + let id = "11111111-2222-4333-8444-555555555555"; + assert_eq!(local_window_label(id).unwrap(), format!("local-{id}")); + assert_eq!(local_window_id(&format!("local-{id}")).unwrap(), id); + assert!(local_window_id("remote-11111111-2222-4333-8444-555555555555").is_err()); + assert!(local_window_id("local-11111111-2222-4333-8444-AAAAAAAAAAAA").is_err()); + } +} diff --git a/packages/tauri-app/src-tauri/src/launch.rs b/packages/tauri-app/src-tauri/src/launch.rs new file mode 100644 index 000000000..339154d0a --- /dev/null +++ b/packages/tauri-app/src-tauri/src/launch.rs @@ -0,0 +1,177 @@ +use std::collections::{HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct LaunchIntent { + pub(crate) new_window: bool, + pub(crate) folders: Vec, +} + +fn directory(value: &str, cwd: &Path) -> Option { + let path = if Path::new(value).is_absolute() { + PathBuf::from(value) + } else { + cwd.join(value) + }; + path.is_dir().then(|| path.to_string_lossy().into_owned()) +} + +pub(crate) fn parse_launch_intent(arguments: &[String], cwd: &Path) -> LaunchIntent { + let mut intent = LaunchIntent::default(); + let mut seen = HashSet::new(); + let mut index = 0; + while index < arguments.len() { + let value = &arguments[index]; + if value == "--new-window" { + intent.new_window = true; + } else if value == "--folder" { + if let Some(folder) = arguments + .get(index + 1) + .filter(|folder| !folder.starts_with('-')) + { + index += 1; + if let Some(folder) = directory(folder, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } + } else if let Some(value) = value.strip_prefix("--folder=") { + if let Some(folder) = directory(value, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } else if !value.starts_with('-') { + if let Some(folder) = directory(value, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } + index += 1; + } + intent +} + +pub(crate) fn parse_windows_forwarded_launch_intent( + arguments: &[String], + cwd: &Path, +) -> LaunchIntent { + let mut intent = LaunchIntent::default(); + let mut seen = HashSet::new(); + let mut unknown_fragment = false; + let mut index = 0; + while index < arguments.len() { + let value = &arguments[index]; + if value == "--new-window" { + intent.new_window = true; + } else if value == "--folder" { + if let Some(folder) = arguments + .get(index + 1) + .filter(|folder| !folder.starts_with('-')) + { + index += 1; + if let Some(folder) = directory(folder, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } + } else if let Some(value) = value.strip_prefix("--folder=") { + if let Some(folder) = directory(value, cwd) { + if seen.insert(folder.clone()) { + intent.folders.push(folder); + } + } + } else if !value.starts_with('-') { + unknown_fragment = true; + } + index += 1; + } + if unknown_fragment { + intent.folders.clear(); + } + intent +} + +#[derive(Default)] +pub(crate) struct LaunchQueue { + pending: Mutex>, +} + +impl LaunchQueue { + pub(crate) fn enqueue(&self, intent: LaunchIntent) { + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push_back(intent); + } + + pub(crate) fn drain(&self) -> Vec { + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .drain(..) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_arguments_use_callback_cwd_and_ignore_flags() { + let root = tempfile::tempdir().unwrap(); + let folder = root.path().join("workspace"); + std::fs::create_dir(&folder).unwrap(); + let args = [ + "--ignored", + "--new-window", + "--folder", + "workspace", + "missing", + "workspace", + ] + .map(str::to_string); + assert_eq!( + parse_launch_intent(&args, root.path()), + LaunchIntent { + new_window: true, + folders: vec![folder.to_string_lossy().into_owned()] + } + ); + } + + #[test] + fn windows_forwarding_requires_explicit_valid_folders_and_rejects_fragments() { + let root = tempfile::tempdir().unwrap(); + let folder = root.path().join("workspace"); + std::fs::create_dir(&folder).unwrap(); + assert_eq!( + parse_windows_forwarded_launch_intent( + &["--folder".into(), "workspace".into()], + root.path(), + ) + .folders, + vec![folder.to_string_lossy().into_owned()] + ); + assert!( + parse_windows_forwarded_launch_intent(&["workspace".into()], root.path()) + .folders + .is_empty() + ); + assert!(parse_windows_forwarded_launch_intent( + &[ + "--folder".into(), + "workspace".into(), + "pipe-fragment".into() + ], + root.path(), + ) + .folders + .is_empty()); + } +} diff --git a/packages/tauri-app/src-tauri/src/linux_tls.rs b/packages/tauri-app/src-tauri/src/linux_tls.rs index 6228d079c..057020e36 100644 --- a/packages/tauri-app/src-tauri/src/linux_tls.rs +++ b/packages/tauri-app/src-tauri/src/linux_tls.rs @@ -1,4 +1,4 @@ -use crate::AppState; +use crate::{clear_remote_tls_handler, AppState}; use tauri::{AppHandle, Manager, WebviewWindow}; use url::Url; use webkit2gtk::{WebContextExt, WebView, WebViewExt}; @@ -11,6 +11,7 @@ pub fn ensure_remote_window_tls_handler( window: &WebviewWindow, app_handle: &AppHandle, window_label: &str, + window_generation: u64, ) -> Result<(), String> { { let state = app_handle.state::(); @@ -18,23 +19,25 @@ pub fn ensure_remote_window_tls_handler( .remote_tls_handlers .lock() .map_err(|err| err.to_string())?; - if !handlers.insert(window_label.to_string()) { + if handlers.get(window_label).copied() == Some(window_generation) { return Ok(()); } + handlers.insert(window_label.to_string(), window_generation); } - let app_handle = app_handle.clone(); - let window_label = window_label.to_string(); + let handler_app = app_handle.clone(); + let handler_label = window_label.to_string(); window .with_webview(move |platform_webview| { let webview = platform_webview.inner(); - let app_handle = app_handle.clone(); - let window_label = window_label.clone(); + let app_handle = handler_app.clone(); + let window_label = handler_label.clone(); webview.connect_load_failed_with_tls_errors( move |view, failing_uri, certificate, _| { allow_remote_tls_certificate( &app_handle, &window_label, + window_generation, view, failing_uri, certificate, @@ -42,12 +45,18 @@ pub fn ensure_remote_window_tls_handler( }, ); }) - .map_err(|err| err.to_string()) + .map_err(|err| { + if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { + clear_remote_tls_handler(&mut handlers, &window_label, window_generation); + } + err.to_string() + }) } fn allow_remote_tls_certificate( app_handle: &AppHandle, window_label: &str, + window_generation: u64, view: &WebView, failing_uri: &str, certificate: &webkit2gtk::gio::TlsCertificate, @@ -60,23 +69,28 @@ fn allow_remote_tls_certificate( }; let state = app_handle.state::(); - let skip_tls_verify = state - .remote_skip_tls_verify + if state.remote_tls_handlers.lock().map_or(true, |handlers| { + handlers.get(window_label).copied() != Some(window_generation) + }) { + return false; + } + let metadata = state + .remote_navigation .lock() .ok() - .and_then(|values| values.get(window_label).copied()) - .unwrap_or(false); - if !skip_tls_verify { + .and_then(|values| values.get(window_label).cloned()); + let Some(metadata) = metadata else { + return false; + }; + if !metadata.allow_linux_tls_certificate { + return false; + } + if metadata.window_generation != window_generation { return false; } - let expected_origin = state - .remote_origins - .lock() - .ok() - .and_then(|origins| origins.get(window_label).cloned()); let parsed_origin = parsed_uri.origin().ascii_serialization(); - if expected_origin.as_deref() != Some(parsed_origin.as_str()) { + if metadata.origin != parsed_origin { return false; } diff --git a/packages/tauri-app/src-tauri/src/local_windows.rs b/packages/tauri-app/src-tauri/src/local_windows.rs new file mode 100644 index 000000000..9d0e3c57e --- /dev/null +++ b/packages/tauri-app/src-tauri/src/local_windows.rs @@ -0,0 +1,503 @@ +use crate::client_state::{self, ClientState, NavigationKind}; +use crate::identity::{local_window_id, local_window_label}; +use crate::launch::LaunchIntent; +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use url::Url; + +const MAX_LOCAL_WINDOWS: usize = 16; +const MAX_FOLDER_ATTEMPTS: u8 = 3; +const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = + "window.__CODENOMAD_RUNTIME_HOST__ = 'tauri'; window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';"; + +#[derive(Clone, Debug)] +pub(crate) struct LocalWindowRecord { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) persisted: bool, + pending_folders: VecDeque, + renderer_ready: bool, + workspace_menu_enabled: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PendingFolder { + path: String, + attempts: u8, +} + +#[derive(Default)] +struct Registry { + records: HashMap, + mru: Vec, + backend_target: Option, +} + +impl Registry { + fn add(&mut self, id: String, persisted: bool) -> Result { + let label = local_window_label(&id)?; + if self.records.contains_key(&label) { + return Err("Local window is already registered".to_string()); + } + if self.records.len() >= MAX_LOCAL_WINDOWS { + return Err("Too many local windows".to_string()); + } + let record = LocalWindowRecord { + id, + label: label.clone(), + persisted, + pending_folders: VecDeque::new(), + renderer_ready: false, + workspace_menu_enabled: false, + }; + self.records.insert(label.clone(), record.clone()); + self.mark_focused(&label); + Ok(record) + } + + fn remove(&mut self, label: &str) -> Option { + self.mru.retain(|candidate| candidate != label); + self.records.remove(label) + } + + fn mark_focused(&mut self, label: &str) -> Option { + let id = self.records.get(label)?.id.clone(); + self.mru.retain(|candidate| candidate != label); + self.mru.insert(0, label.to_string()); + Some(id) + } + + fn mru_label(&self) -> Option { + self.mru + .iter() + .find(|label| self.records.contains_key(*label)) + .cloned() + } +} + +#[derive(Default)] +pub(crate) struct LocalWindows { + registry: Mutex, +} + +impl LocalWindows { + pub(crate) fn records(&self) -> Vec { + self.registry + .lock() + .unwrap_or_else(|error| error.into_inner()) + .records + .values() + .cloned() + .collect() + } + + pub(crate) fn record(&self, label: &str) -> Option { + self.registry.lock().ok()?.records.get(label).cloned() + } + + pub(crate) fn contains_id(&self, id: &str) -> bool { + local_window_label(id).ok().is_some_and(|label| { + self.registry + .lock() + .ok() + .is_some_and(|registry| registry.records.contains_key(&label)) + }) + } + + pub(crate) fn mru_label(&self) -> Option { + self.registry.lock().ok()?.mru_label() + } + + pub(crate) fn mark_focused(&self, app: &AppHandle, label: &str) { + let id = self + .registry + .lock() + .ok() + .and_then(|mut registry| registry.mark_focused(label)); + if let (Some(id), Some(state)) = (id, app.try_state::()) { + if let Err(error) = state.set_active_window(&id) { + eprintln!("[client-state] failed to persist active window: {error}"); + } + } + } + + pub(crate) fn remove_runtime(&self, label: &str) -> Option { + self.registry.lock().ok()?.remove(label) + } + + pub(crate) fn set_workspace_menu_enabled( + &self, + label: &str, + enabled: bool, + ) -> Result<(), String> { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + record.workspace_menu_enabled = enabled; + Ok(()) + } + + pub(crate) fn workspace_menu_enabled(&self, label: &str) -> bool { + self.registry + .lock() + .ok() + .and_then(|registry| { + registry + .records + .get(label) + .map(|record| record.workspace_menu_enabled) + }) + .unwrap_or(false) + } + + pub(crate) fn set_backend_target(&self, target: Option) { + if let Ok(mut registry) = self.registry.lock() { + registry.backend_target = target; + } + } + + fn backend_target(&self) -> Option { + self.registry.lock().ok()?.backend_target.clone() + } + + pub(crate) fn queue_folder( + &self, + app: &AppHandle, + label: &str, + folder: String, + ) -> Result<(), String> { + let ready = { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + record.pending_folders.push_back(PendingFolder { + path: folder, + attempts: 0, + }); + record.renderer_ready + }; + if ready { + if let Some(window) = app.get_webview_window(label) { + let _ = window.emit("desktop:folders-pending", ()); + } + } + Ok(()) + } + + pub(crate) fn renderer_ready(&self, app: &AppHandle, label: &str) -> Result<(), String> { + let pending = { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + record.renderer_ready = true; + !record.pending_folders.is_empty() + }; + if pending { + if let Some(window) = app.get_webview_window(label) { + let _ = window.emit("desktop:folders-pending", ()); + } + } + Ok(()) + } + + pub(crate) fn next_folder(&self, label: &str) -> Result, String> { + let registry = self.registry.lock().map_err(|error| error.to_string())?; + Ok(registry + .records + .get(label) + .ok_or_else(|| "Unknown local window".to_string())? + .pending_folders + .front() + .map(|pending| pending.path.clone())) + } + + pub(crate) fn acknowledge_folder( + &self, + label: &str, + folder: &str, + opened: bool, + ) -> Result<(), String> { + let mut registry = self.registry.lock().map_err(|error| error.to_string())?; + let record = registry + .records + .get_mut(label) + .ok_or_else(|| "Unknown local window".to_string())?; + if record + .pending_folders + .front() + .map(|pending| pending.path.as_str()) + != Some(folder) + { + return Err("Pending folder acknowledgement is out of order".to_string()); + } + let mut pending = record.pending_folders.pop_front().unwrap(); + if !opened { + pending.attempts += 1; + if pending.attempts < MAX_FOLDER_ATTEMPTS { + record.pending_folders.push_back(pending); + } + } + Ok(()) + } +} + +pub(crate) fn focused_window(app: &AppHandle) -> Option { + app.webview_windows() + .into_values() + .find(|window| window.is_focused().unwrap_or(false)) +} + +fn select_local_label(focused: Option<&str>, mru: Option<&str>) -> Option { + match focused { + Some(label) if local_window_id(label).is_ok() => Some(label.to_string()), + Some(_) => None, + None => mru + .filter(|label| local_window_id(label).is_ok()) + .map(str::to_string), + } +} + +pub(crate) fn focused_local_window(app: &AppHandle) -> Option { + let focused = focused_window(app); + let mru = app.state::().mru_label(); + let label = select_local_label(focused.as_ref().map(WebviewWindow::label), mru.as_deref())?; + app.get_webview_window(&label) +} + +pub(crate) fn targeted_window(app: &AppHandle) -> Option { + focused_window(app).or_else(|| { + app.state::() + .mru_label() + .and_then(|label| app.get_webview_window(&label)) + }) +} + +pub(crate) fn focus(app: &AppHandle, label: &str) -> bool { + let Some(window) = app.get_webview_window(label) else { + return false; + }; + if window.is_minimized().unwrap_or(false) { + let _ = window.unminimize(); + } + let _ = window.show(); + let _ = window.set_focus(); + app.state::().mark_focused(app, label); + true +} + +fn register(app: &AppHandle, id: String, persisted: bool) -> Result { + app.state::() + .registry + .lock() + .map_err(|error| error.to_string())? + .add(id, persisted) +} + +pub(crate) fn create_local_window( + app: &AppHandle, + id: String, + persisted: bool, +) -> Result { + let record = register(app, id.clone(), persisted)?; + let script = format!( + "{LOCAL_WINDOW_CONTEXT_SCRIPT} window.__CODENOMAD_WINDOW_ID__ = {};", + serde_json::to_string(&id).unwrap() + ); + let data_directory = app + .state::() + .webview_data_directory + .join("local"); + let builder = + WebviewWindowBuilder::new(app, &record.label, WebviewUrl::App("loading.html".into())) + .data_directory(data_directory) + .initialization_script(script); + #[cfg(target_os = "macos")] + let builder = if app.state::().scoped_profile { + builder.data_store_identifier(crate::profile_identifier("local")) + } else { + builder + }; + let result = builder + .title("CodeNomad") + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .resizable(true) + .fullscreen(false) + .decorations(true) + .background_color(tauri::window::Color(26, 26, 26, 255)) + .zoom_hotkeys_enabled(true) + .visible(false) + .build(); + let window = match result { + Ok(window) => window, + Err(error) => { + app.state::().remove_runtime(&record.label); + return Err(error.to_string()); + } + }; + if let Err(error) = client_state::setup_local_window(app, &window, &id, persisted) { + app.state::().remove_runtime(&record.label); + if let Some(state) = app.try_state::() { + state.unregister_window(&id); + } + let _ = window.destroy(); + return Err(error); + } + #[cfg(windows)] + if let Err(error) = crate::shutdown::schedule_windows_session_end_handler(&window) { + app.state::().remove_runtime(&record.label); + if let Some(state) = app.try_state::() { + state.unregister_window(&id); + } + let _ = window.destroy(); + return Err(error); + } + if let Some(target) = app.state::().backend_target() { + navigate_window(app, &record.label, &target, NavigationKind::Cli); + } + Ok(record) +} + +pub(crate) fn create_new_window(app: &AppHandle) -> Result { + let state = app.state::(); + if let Some(id) = state + .window_ids() + .into_iter() + .find(|id| !app.state::().contains_id(id)) + { + return create_local_window(app, id, state.is_primary_process()); + } + if app.state::().records().len() >= MAX_LOCAL_WINDOWS { + return Err("Too many local windows".to_string()); + } + let id = uuid::Uuid::new_v4().to_string(); + let persisted = match state.add_window(id.clone()) { + Ok(persisted) => persisted, + Err(error) => { + eprintln!( + "[client-state] failed to persist a new window; using an ephemeral window: {error}" + ); + false + } + }; + create_local_window(app, id, persisted) +} + +pub(crate) fn restore_windows(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let persisted = state.is_primary_process(); + let ids = state.window_ids(); + let active = state.active_window_id()?; + drop(state); + if ids.is_empty() { + create_new_window(app)?; + return Ok(()); + } + for id in ids { + create_local_window(app, id, persisted)?; + } + let label = local_window_label(&active)?; + focus(app, &label); + Ok(()) +} + +pub(crate) fn handle_intent(app: &AppHandle, intent: LaunchIntent) -> Result<(), String> { + let record = if intent.new_window { + create_new_window(app)? + } else if let Some(label) = app.state::().mru_label() { + app.state::() + .record(&label) + .ok_or_else(|| "MRU local window disappeared".to_string())? + } else { + create_new_window(app)? + }; + for folder in intent.folders { + app.state::() + .queue_folder(app, &record.label, folder)?; + } + focus(app, &record.label); + Ok(()) +} + +pub(crate) fn navigate_window(app: &AppHandle, label: &str, target: &str, kind: NavigationKind) { + let Ok(url) = Url::parse(target) else { + return; + }; + let label_for_navigation = label.to_string(); + client_state::before_window_navigation( + app, + label.to_string(), + kind, + Some(url.clone()), + move |app| { + app.get_webview_window(&label_for_navigation) + .ok_or_else(|| "local window not found for navigation".to_string())? + .navigate(url) + .map_err(|error| error.to_string()) + }, + ); +} + +pub(crate) fn show_loading_all(app: &AppHandle) { + app.state::().set_backend_target(None); + for record in app.state::().records() { + navigate_window( + app, + &record.label, + "tauri://localhost/loading.html", + NavigationKind::Cli, + ); + } +} + +pub(crate) fn emit_all(app: &AppHandle, event: &str, payload: impl serde::Serialize + Clone) { + for record in app.state::().records() { + if let Some(window) = app.get_webview_window(&record.label) { + let _ = window.emit(event, payload.clone()); + } + } +} + +#[tauri::command] +pub(crate) fn desktop_launch_ready( + window: WebviewWindow, + app: AppHandle, + windows: tauri::State<'_, LocalWindows>, + state: tauri::State<'_, crate::AppState>, +) -> Result<(), String> { + crate::require_local_app_window(&window, &state)?; + windows.renderer_ready(&app, window.label()) +} + +#[tauri::command] +pub(crate) fn desktop_launch_next_folder( + window: WebviewWindow, + windows: tauri::State<'_, LocalWindows>, + state: tauri::State<'_, crate::AppState>, +) -> Result, String> { + crate::require_local_app_window(&window, &state)?; + windows.next_folder(window.label()) +} + +#[tauri::command] +pub(crate) fn desktop_launch_acknowledge_folder( + window: WebviewWindow, + windows: tauri::State<'_, LocalWindows>, + state: tauri::State<'_, crate::AppState>, + folder: String, + opened: bool, +) -> Result<(), String> { + crate::require_local_app_window(&window, &state)?; + windows.acknowledge_folder(window.label(), &folder, opened) +} + +#[cfg(test)] +#[path = "local_windows_tests.rs"] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/local_windows_tests.rs b/packages/tauri-app/src-tauri/src/local_windows_tests.rs new file mode 100644 index 000000000..7c1636ffb --- /dev/null +++ b/packages/tauri-app/src-tauri/src/local_windows_tests.rs @@ -0,0 +1,56 @@ +use super::*; + +fn id(value: u8) -> String { + format!("00000000-0000-4000-8000-{value:012}") +} + +#[test] +fn registry_tracks_uuid_labels_mru_and_folder_ack_order() { + let mut registry = Registry::default(); + let first = registry.add(id(1), true).unwrap(); + let second = registry.add(id(2), true).unwrap(); + assert_eq!(registry.mru_label(), Some(second.label.clone())); + assert_eq!(registry.mark_focused(&first.label), Some(first.id.clone())); + assert_eq!(registry.mru_label(), Some(first.label.clone())); + let record = registry.records.get_mut(&first.label).unwrap(); + record.pending_folders.push_back(PendingFolder { + path: "one".to_string(), + attempts: 0, + }); + record.pending_folders.push_back(PendingFolder { + path: "two".to_string(), + attempts: 0, + }); + assert_eq!( + record + .pending_folders + .front() + .map(|pending| pending.path.as_str()), + Some("one") + ); + assert_eq!(registry.remove(&first.label).unwrap().id, first.id); + assert_eq!(registry.mru_label(), Some(second.label)); +} + +#[test] +fn registry_enforces_maximum_and_lowercase_labels() { + let mut registry = Registry::default(); + for value in 0..MAX_LOCAL_WINDOWS { + registry.add(id(value as u8), true).unwrap(); + } + assert_eq!( + registry.add(id(99), false).unwrap_err(), + "Too many local windows" + ); +} + +#[test] +fn remote_focus_never_falls_through_to_background_local() { + let local = format!("local-{}", id(1)); + assert_eq!( + select_local_label(Some("remote-profile"), Some(&local)), + None + ); + assert_eq!(select_local_label(None, Some(&local)), Some(local.clone())); + assert_eq!(select_local_label(Some(&local), None), Some(local)); +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index b327d2481..7143e6f0e 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -4,26 +4,29 @@ mod cert_manager; mod cli_manager; mod client_state; -mod desktop_event_transport; +mod identity; +mod launch; #[cfg(target_os = "linux")] mod linux_tls; +mod local_windows; mod managed_node; mod shutdown; mod windows_update; 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; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; #[cfg(any(windows, test))] use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::async_runtime::Mutex as AsyncMutex; use tauri::menu::{ AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, }; @@ -49,24 +52,88 @@ 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_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>, - pub remote_origins: Mutex>, - pub remote_proxy_sessions: Mutex>, - pub remote_skip_tls_verify: Mutex>, - pub remote_tls_handlers: Mutex>, - pub remote_titles: Mutex>, + pub wake_lock: Mutex, + remote_navigation: Mutex>, + remote_navigation_generation: AtomicU64, + remote_profiles: Mutex>, + remote_window_operations: RemoteWindowOperationLocks, + remote_proxy_cleanup_claims: Mutex>, + pub remote_tls_handlers: Mutex>, + pub remote_zoom_levels: Mutex>, pub workspace_menu_items: Mutex>, - pub workspace_menu_requested_enabled: Mutex, + pub webview_data_directory: std::path::PathBuf, + pub scoped_profile: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteWindowMetadata { + origin: String, + title: String, + allow_linux_tls_certificate: bool, + generation: u64, + window_generation: u64, +} + +struct StagedRemoteWindowMetadata { + generation: u64, + window_generation: u64, + previous: Option, +} + +#[derive(Default)] +struct RemoteWindowOperationLocks { + values: Mutex>>>, +} + +impl RemoteWindowOperationLocks { + fn for_label(&self, label: &str) -> Result>, String> { + Ok(self + .values + .lock() + .map_err(|err| err.to_string())? + .entry(label.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RemoteProfileIdentity { + Direct, + Proxy(String), +} + +impl RemoteProfileIdentity { + fn new(proxy_session_id: Option<&str>) -> Self { + proxy_session_id + .map(|value| Self::Proxy(value.to_string())) + .unwrap_or(Self::Direct) + } + + fn proxy_session_id(&self) -> Option<&str> { + match self { + Self::Direct => None, + Self::Proxy(value) => Some(value), + } + } +} + +fn should_recreate_remote_window( + existing: Option<&RemoteProfileIdentity>, + requested: &RemoteProfileIdentity, +) -> bool { + existing != Some(requested) +} + +#[derive(Default)] +pub struct WakeLockState { + labels: HashSet, + handle: Option, } pub struct WorkspaceMenuItems { @@ -77,18 +144,14 @@ pub struct WorkspaceMenuItems { fn update_workspace_menu_state(app: &AppHandle) { let state = app.state::(); - 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); + let enabled = local_windows::focused_window(app) + .filter(|window| identity::local_window_id(window.label()).is_ok()) + .is_some_and(|window| { + app.state::() + .workspace_menu_enabled(window.label()) + }); 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); @@ -96,6 +159,55 @@ fn update_workspace_menu_state(app: &AppHandle) { }; } +fn is_asset_renderer_origin(url: &Url) -> bool { + match url.scheme() { + "tauri" | "asset" => url.host_str() == Some("localhost") && url.port().is_none(), + "http" | "https" => url.host_str() == Some("tauri.localhost") && url.port().is_none(), + _ => false, + } +} + +fn is_dev_renderer_origin(url: &Url) -> bool { + cfg!(debug_assertions) + && url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost")) + && url.port() == Some(1420) +} + +fn same_origin(url: &Url, expected: Option<&str>) -> bool { + expected + .and_then(|value| Url::parse(value).ok()) + .is_some_and(|expected| url.origin() == expected.origin()) +} + +fn is_allowed_local_origin(url: &Url, managed_backend: Option<&str>) -> bool { + is_asset_renderer_origin(url) + || is_dev_renderer_origin(url) + || same_origin(url, managed_backend) +} + +pub(crate) fn require_local_app_window( + window: &tauri::WebviewWindow, + state: &AppState, +) -> Result { + identity::local_window_id(window.label())?; + let current = window.url().map_err(|error| error.to_string())?; + let status = state.manager.status(); + if is_allowed_local_origin(¤t, status.url.as_deref()) { + Ok(current) + } else { + Err("Native application commands require a trusted local renderer origin".into()) + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn profile_identifier(identity: &str) -> [u8; 16] { + let digest = Sha256::digest(identity.as_bytes()); + let mut identifier = [0_u8; 16]; + identifier.copy_from_slice(&digest[..16]); + identifier +} + #[tauri::command] fn set_workspace_menu_enabled( window: tauri::WebviewWindow, @@ -103,30 +215,24 @@ fn set_workspace_menu_enabled( 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()); - } + require_local_app_window(&window, &state)?; if !enabled { - *state - .workspace_menu_requested_enabled - .lock() - .map_err(|error| error.to_string())? = false; + app.state::() + .set_workspace_menu_enabled(window.label(), false)?; update_workspace_menu_state(&app); return Ok(()); } let config = state .manager - .desktop_event_stream_config() + .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; + app.state::() + .set_workspace_menu_enabled(window.label(), enabled)?; update_workspace_menu_state(&app); Ok(()) } @@ -143,13 +249,83 @@ struct RemoteWindowPayload { skip_tls_verify: bool, } -fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) { +fn require_http_url(value: &str, name: &str) -> Result { + let url = Url::parse(value).map_err(|error| error.to_string())?; + if !matches!(url.scheme(), "http" | "https") { + return Err(format!("{name} must use HTTP or HTTPS")); + } + Ok(url) +} + +fn claim_unowned_remote_proxy_session( + profiles: &HashMap, + claims: &mut HashSet, + session_id: &str, +) -> bool { + if profiles + .values() + .any(|profile| profile.proxy_session_id() == Some(session_id)) + { + return false; + } + claims.insert(session_id.to_string()) +} + +fn claim_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) -> bool { + let state = app.state::(); + let Ok(profiles) = state.remote_profiles.lock() else { + return false; + }; + let Ok(mut claims) = state.remote_proxy_cleanup_claims.lock() else { + return false; + }; + claim_unowned_remote_proxy_session(&profiles, &mut claims, session_id) +} + +async fn cleanup_remote_proxy_session_if_unowned(app: &AppHandle, session_id: &str) { + if !claim_remote_proxy_session_cleanup(app, session_id) { + return; + } + if let Err(err) = cleanup_remote_proxy_session(app, session_id).await { + eprintln!( + "[tauri] failed to clean up remote proxy session {}: {}", + session_id, err + ); + } +} + +fn schedule_remote_proxy_session_cleanup(app: AppHandle, label: String, session_id: String) { tauri::async_runtime::spawn(async move { - if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await { - eprintln!( - "[tauri] failed to clean up remote proxy session {}: {}", - session_id, err - ); + let Ok(operation) = app + .state::() + .remote_window_operations + .for_label(&label) + else { + return; + }; + let _guard = operation.lock().await; + cleanup_remote_proxy_session_if_unowned(&app, &session_id).await; + }); +} + +fn schedule_remote_window_destroyed_cleanup( + app: AppHandle, + label: String, + profile: RemoteProfileIdentity, + window_generation: u64, +) { + tauri::async_runtime::spawn(async move { + let Ok(operation) = app + .state::() + .remote_window_operations + .for_label(&label) + else { + return; + }; + let _guard = operation.lock().await; + clear_remote_window_metadata(&app, &label, &profile, window_generation); + if let Some(session_id) = profile.proxy_session_id() { + cleanup_remote_proxy_session_if_unowned(&app, session_id).await; } }); } @@ -199,42 +375,43 @@ struct WakeLockConfig { } #[tauri::command] -fn cli_get_status(state: tauri::State) -> CliStatus { - state.manager.status() -} - -#[tauri::command] -fn cli_restart(app: AppHandle, state: tauri::State) -> Result { - let dev_mode = is_dev_mode(); - state.desktop_events.stop(); - state.manager.stop().map_err(|e| e.to_string())?; - state - .manager - .start(app, dev_mode) - .map_err(|e| e.to_string())?; +fn cli_get_status( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result { + require_local_app_window(&window, &state)?; Ok(state.manager.status()) } #[tauri::command] -fn desktop_events_start( +fn cli_restart( + window: tauri::WebviewWindow, app: AppHandle, state: tauri::State, - request: Option, -) -> 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) { - state.desktop_events.stop(); +) -> Result { + require_local_app_window(&window, &state)?; + shutdown::with_navigation_authority(&app, || { + let dev_mode = is_dev_mode(); + state + .manager + .stop_until(Instant::now() + Duration::from_secs(2)) + .map_err(|e| e.to_string())?; + state + .manager + .start(app.clone(), dev_mode) + .map_err(|e| e.to_string())?; + Ok(state.manager.status()) + }) + .unwrap_or_else(|| Err("Application shutdown is in progress".to_string())) } #[tauri::command] fn wake_lock_start( + window: tauri::WebviewWindow, state: tauri::State, config: Option, ) -> Result<(), String> { + require_local_app_window(&window, &state)?; let config = config.unwrap_or(WakeLockConfig { display: false, idle: true, @@ -250,16 +427,25 @@ fn wake_lock_start( .app_name("CodeNomad") .app_reverse_domain("ai.neuralnomads.codenomad.client"); - let wake_lock = builder.create().map_err(|err| err.to_string())?; let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?; - *state_lock = Some(wake_lock); + state_lock.labels.insert(window.label().to_string()); + if state_lock.handle.is_none() { + state_lock.handle = Some(builder.create().map_err(|err| err.to_string())?); + } Ok(()) } #[tauri::command] -fn wake_lock_stop(state: tauri::State) -> Result<(), String> { +fn wake_lock_stop( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result<(), String> { + require_local_app_window(&window, &state)?; let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?; - state_lock.take(); + state_lock.labels.remove(window.label()); + if state_lock.labels.is_empty() { + state_lock.handle.take(); + } Ok(()) } @@ -268,17 +454,7 @@ fn is_dev_mode() -> bool { } fn should_allow_internal(url: &Url) -> bool { - match url.scheme() { - "tauri" | "asset" | "file" | "about" => true, - // On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`. - // This must be treated as an internal origin or the navigation guard will - // redirect it to the system browser and the app will appear blank. - "http" | "https" => matches!( - url.host_str(), - Some("127.0.0.1" | "localhost" | "tauri.localhost") - ), - _ => false, - } + is_asset_renderer_origin(url) || is_dev_renderer_origin(url) || url.as_str() == "about:blank" } fn should_allow_window_origin( @@ -287,33 +463,48 @@ fn should_allow_window_origin( url: &Url, ) -> bool { let state = app_handle.state::(); - let Ok(allowed) = state.remote_origins.lock() else { + if identity::local_window_id(window_label).is_ok() { + let status = state.manager.status(); + return is_allowed_local_origin(url, status.url.as_deref()); + } + let Ok(allowed) = state.remote_navigation.lock() else { return false; }; - should_allow_registered_origin(allowed.get(window_label).map(String::as_str), url) + should_allow_registered_origin( + allowed + .get(window_label) + .map(|metadata| metadata.origin.as_str()), + url, + ) } 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(); - } + return (matches!(url.scheme(), "http" | "https") + && origin == url.origin().ascii_serialization()) + || url.as_str() == "about:blank"; } should_allow_internal(url) } +fn should_open_external_url(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https" | "mailto") +} + fn intercept_navigation(webview: &Webview, url: &Url) -> bool { let window_label = webview.label().to_string(); if should_allow_window_origin(&webview.app_handle(), &window_label, url) { return true; } - if let Err(err) = webview - .app_handle() - .opener() - .open_url(url.as_str(), None::<&str>) - { - eprintln!("[tauri] failed to open external link {}: {}", url, err); + if should_open_external_url(url) { + if let Err(err) = webview + .app_handle() + .opener() + .open_url(url.as_str(), None::<&str>) + { + eprintln!("[tauri] failed to open external link {}: {}", url, err); + } } false } @@ -321,10 +512,14 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { fn apply_remote_window_title(app_handle: &AppHandle, window_label: &str) { let Some(title) = app_handle .state::() - .remote_titles + .remote_navigation .lock() .ok() - .and_then(|titles| titles.get(window_label).cloned()) + .and_then(|values| { + values + .get(window_label) + .map(|metadata| metadata.title.clone()) + }) else { return; }; @@ -338,12 +533,39 @@ async fn open_remote_window_impl( app: AppHandle, payload: RemoteWindowPayload, ) -> Result<(), String> { + let label = format!("remote-{}", payload.id); + let requested_profile = RemoteProfileIdentity::new(payload.proxy_session_id.as_deref()); + let operation = app + .state::() + .remote_window_operations + .for_label(&label)?; + let _guard = operation.lock().await; + let result = open_remote_window_locked( + app.clone(), + payload, + label.clone(), + requested_profile.clone(), + ); + if result.is_err() { + if let Some(session_id) = requested_profile.proxy_session_id() { + schedule_remote_proxy_session_cleanup(app, label, session_id.to_string()); + } + } + result +} + +fn open_remote_window_locked( + app: AppHandle, + payload: RemoteWindowPayload, + label: String, + requested_profile: RemoteProfileIdentity, +) -> Result<(), String> { + require_http_url(&payload.base_url, "baseUrl")?; let entry_url = payload .entry_url .as_deref() .unwrap_or(payload.base_url.as_str()); - let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; - let label = format!("remote-{}", payload.id); + let parsed = require_http_url(entry_url, "entryUrl")?; let title = format!("{} - {}", payload.name, payload.base_url); let window_url = parsed.clone(); @@ -351,57 +573,109 @@ async fn open_remote_window_impl( let allow_linux_tls_certificate = parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); - app.state::() - .remote_origins - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), window_url.origin().ascii_serialization()); - app.state::() - .remote_skip_tls_verify - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), allow_linux_tls_certificate); - app.state::() - .remote_titles - .lock() - .map_err(|err| err.to_string())? - .insert(label.clone(), title.clone()); + let mut previous_profile = None; - let replaced_session = { - let state = app.state::(); - let mut sessions = state - .remote_proxy_sessions + if let Some(existing) = app.get_webview_window(&label) { + previous_profile = app + .state::() + .remote_profiles .lock() - .map_err(|err| err.to_string())?; - match payload.proxy_session_id.clone() { - Some(session_id) => sessions.insert(label.clone(), session_id), - None => sessions.remove(&label), + .map_err(|err| err.to_string())? + .get(&label) + .cloned(); + if should_recreate_remote_window(previous_profile.as_ref(), &requested_profile) { + app.state::() + .remote_profiles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), requested_profile.clone()); + if let Err(error) = existing.destroy() { + let state = app.state::(); + let mut profiles = state + .remote_profiles + .lock() + .map_err(|err| err.to_string())?; + if let Some(previous) = previous_profile.as_ref() { + profiles.insert(label.clone(), previous.clone()); + } else { + profiles.remove(&label); + } + return Err(error.to_string()); + } + if let Ok(mut handlers) = app.state::().remote_tls_handlers.lock() { + handlers.remove(&label); + } + } else { + let staged = set_remote_window_metadata( + &app, + &label, + &window_url, + &title, + allow_linux_tls_certificate, + false, + )?; + #[cfg(target_os = "linux")] + if let Err(error) = linux_tls::ensure_remote_window_tls_handler( + &existing, + &app, + &label, + staged.window_generation, + ) { + restore_remote_window_metadata(&app, &label, staged); + return Err(error); + } + apply_remote_window_title(&app, &label); + if let Err(error) = existing.navigate(window_url.clone()) { + if restore_remote_window_metadata(&app, &label, staged) { + apply_remote_window_title(&app, &label); + } + return Err(error.to_string()); + } + apply_remote_window_title(&app, &label); + let _ = existing.show(); + let _ = existing.unminimize(); + let _ = existing.set_focus(); + return Ok(()); } - }; + } else { + app.state::() + .remote_profiles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), requested_profile.clone()); + } - if let Some(previous) = replaced_session { - if payload.proxy_session_id.as_deref() != Some(previous.as_str()) { - schedule_remote_proxy_session_cleanup(app.clone(), previous); + let staged = match set_remote_window_metadata( + &app, + &label, + &window_url, + &title, + allow_linux_tls_certificate, + true, + ) { + Ok(staged) => staged, + Err(error) => { + clear_remote_profile(&app, &label, &requested_profile); + if let Some(session_id) = previous_profile + .as_ref() + .and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup( + app.clone(), + label.clone(), + session_id.to_string(), + ); + } + return Err(error); } - } + }; - if let Some(existing) = app.get_webview_window(&label) { - #[cfg(target_os = "linux")] - linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?; - - let _ = existing.set_title(&title); - let _ = existing.navigate(window_url.clone()); - apply_remote_window_title(&app, &label); - let _ = existing.show(); - let _ = existing.unminimize(); - let _ = existing.set_focus(); - return Ok(()); - } + let window_generation = staged.window_generation; #[cfg(target_os = "linux")] let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, allow_linux_tls_certificate) { - Url::parse("about:blank").map_err(|err| err.to_string())? + Url::parse("about:blank").expect("about:blank is a valid URL") } else { window_url.clone() }; @@ -409,61 +683,275 @@ async fn open_remote_window_impl( #[cfg(not(target_os = "linux"))] let initial_url = window_url.clone(); - let window = WebviewWindowBuilder::new( + let profile_key = match requested_profile.proxy_session_id() { + Some(session_id) => format!("{}\0{session_id}", payload.id), + None => payload.id.clone(), + }; + let profile_hash = Sha256::digest(profile_key.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let data_directory = app + .state::() + .webview_data_directory + .join("remote") + .join(profile_hash); + let builder = WebviewWindowBuilder::new( &app, label.clone(), WebviewUrl::External(initial_url.clone()), ) + .data_directory(data_directory) + .incognito(requested_profile.proxy_session_id().is_some()) .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) .title(title) .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0) - .build() - .map_err(|err| err.to_string())?; + .min_inner_size(800.0, 600.0); + #[cfg(target_os = "macos")] + let builder = builder.data_store_identifier(profile_identifier(&profile_key)); + let window = match builder.build() { + Ok(window) => window, + Err(error) => { + cleanup_failed_remote_window( + &app, + None, + &label, + &requested_profile, + previous_profile.as_ref(), + window_generation, + ); + return Err(error.to_string()); + } + }; #[cfg(windows)] - shutdown::schedule_windows_session_end_handler(&window)?; + if let Err(error) = shutdown::schedule_windows_session_end_handler(&window) { + cleanup_failed_remote_window( + &app, + Some(&window), + &label, + &requested_profile, + previous_profile.as_ref(), + window_generation, + ); + return Err(error); + } #[cfg(target_os = "linux")] { - linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?; - if initial_url != window_url { - let _ = window.navigate(window_url.clone()); + let setup = + linux_tls::ensure_remote_window_tls_handler(&window, &app, &label, window_generation) + .and_then(|()| { + if initial_url == window_url { + Ok(()) + } else { + window + .navigate(window_url.clone()) + .map_err(|err| err.to_string()) + } + }); + if let Err(error) = setup { + cleanup_failed_remote_window( + &app, + Some(&window), + &label, + &requested_profile, + previous_profile.as_ref(), + window_generation, + ); + return Err(error); } } + if let Some(session_id) = previous_profile + .as_ref() + .filter(|profile| *profile != &requested_profile) + .and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup(app.clone(), label.clone(), session_id.to_string()); + } + let app_handle = app.clone(); let label_for_cleanup = label.clone(); + let profile_for_cleanup = requested_profile.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::().remote_origins.lock() { - origins.remove(&label_for_cleanup); - } - if let Ok(mut sessions) = app_handle.state::().remote_proxy_sessions.lock() { - if let Some(session_id) = sessions.remove(&label_for_cleanup) { - schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id); - } - } - if let Ok(mut values) = app_handle.state::().remote_skip_tls_verify.lock() { - values.remove(&label_for_cleanup); - } - if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { - handlers.remove(&label_for_cleanup); - } - if let Ok(mut titles) = app_handle.state::().remote_titles.lock() { - titles.remove(&label_for_cleanup); - } + schedule_remote_window_destroyed_cleanup( + app_handle.clone(), + label_for_cleanup.clone(), + profile_for_cleanup.clone(), + window_generation, + ); } }); Ok(()) } +fn set_remote_window_metadata( + app: &AppHandle, + label: &str, + url: &Url, + title: &str, + allow_linux_tls_certificate: bool, + new_window: bool, +) -> Result { + let state = app.state::(); + let generation = state + .remote_navigation_generation + .fetch_add(1, Ordering::SeqCst) + + 1; + let mut values = state + .remote_navigation + .lock() + .map_err(|err| err.to_string())?; + let previous = values.get(label).cloned(); + let window_generation = if new_window { + generation + } else { + previous + .as_ref() + .map(|metadata| metadata.window_generation) + .unwrap_or(generation) + }; + values.insert( + label.to_string(), + RemoteWindowMetadata { + origin: url.origin().ascii_serialization(), + title: title.to_string(), + allow_linux_tls_certificate, + generation, + window_generation, + }, + ); + Ok(StagedRemoteWindowMetadata { + generation, + window_generation, + previous, + }) +} + +fn clear_remote_tls_handler( + handlers: &mut HashMap, + label: &str, + window_generation: u64, +) -> bool { + if handlers.get(label).copied() != Some(window_generation) { + return false; + } + handlers.remove(label); + true +} + +fn rollback_remote_window_metadata( + values: &mut HashMap, + label: &str, + failed_generation: u64, + previous: Option, +) -> bool { + if values.get(label).map(|metadata| metadata.generation) != Some(failed_generation) { + return false; + } + match previous { + Some(previous) => { + values.insert(label.to_string(), previous); + } + None => { + values.remove(label); + } + } + true +} + +fn restore_remote_window_metadata( + app: &AppHandle, + label: &str, + staged: StagedRemoteWindowMetadata, +) -> bool { + app.state::() + .remote_navigation + .lock() + .ok() + .is_some_and(|mut values| { + rollback_remote_window_metadata(&mut values, label, staged.generation, staged.previous) + }) +} + +fn clear_remote_profile(app: &AppHandle, label: &str, profile: &RemoteProfileIdentity) -> bool { + let state = app.state::(); + let Ok(mut profiles) = state.remote_profiles.lock() else { + return false; + }; + if profiles.get(label) != Some(profile) { + return false; + } + profiles.remove(label); + true +} + +fn cleanup_failed_remote_window( + app: &AppHandle, + window: Option<&tauri::WebviewWindow>, + label: &str, + profile: &RemoteProfileIdentity, + previous_profile: Option<&RemoteProfileIdentity>, + window_generation: u64, +) { + if let Some(window) = window { + let _ = window.destroy(); + } + if clear_remote_window_metadata(app, label, profile, window_generation) { + if let Some(session_id) = previous_profile.and_then(RemoteProfileIdentity::proxy_session_id) + { + schedule_remote_proxy_session_cleanup( + app.clone(), + label.to_string(), + session_id.to_string(), + ); + } + } +} + +fn clear_remote_window_metadata( + app: &AppHandle, + label: &str, + profile: &RemoteProfileIdentity, + window_generation: u64, +) -> bool { + let state = app.state::(); + let Ok(mut navigation) = state.remote_navigation.lock() else { + return false; + }; + if navigation + .get(label) + .map(|metadata| metadata.window_generation) + != Some(window_generation) + { + return false; + } + let Ok(mut profiles) = state.remote_profiles.lock() else { + return false; + }; + if profiles.get(label) != Some(profile) { + return false; + } + profiles.remove(label); + navigation.remove(label); + if let Ok(mut values) = state.remote_tls_handlers.lock() { + clear_remote_tls_handler(&mut values, label, window_generation); + } + true +} + #[tauri::command] -fn needs_local_certificate_install() -> Result { +fn needs_local_certificate_install( + window: tauri::WebviewWindow, + state: tauri::State, +) -> Result { + require_local_app_window(&window, &state)?; #[cfg(not(target_os = "linux"))] { let local_cert = cert_manager::ensure_local_cert().map_err(|err| { @@ -481,14 +969,21 @@ fn needs_local_certificate_install() -> Result { } #[tauri::command] -async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { +async fn open_remote_window( + window: tauri::WebviewWindow, + app: AppHandle, + state: tauri::State<'_, AppState>, + payload: RemoteWindowPayload, +) -> Result<(), String> { + require_local_app_window(&window, &state)?; #[cfg(not(target_os = "linux"))] { let entry_url = payload .entry_url .as_deref() .unwrap_or(payload.base_url.as_str()); - let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; + require_http_url(&payload.base_url, "baseUrl")?; + let parsed = require_http_url(entry_url, "entryUrl")?; if payload.proxy_session_id.is_some() && parsed.scheme() == "https" { let local_cert = cert_manager::ensure_local_cert().map_err(|err| { format!( @@ -539,31 +1034,49 @@ fn emit_folder_drop_event( } } -fn reload_main_window(app_handle: &AppHandle) { - client_state::before_main_window_navigation( +fn reload_target_window(app_handle: &AppHandle) { + let Some(window) = local_windows::targeted_window(app_handle) else { + return; + }; + let label = window.label().to_string(); + if identity::local_window_id(&label).is_err() { + let _ = window.reload(); + return; + } + let target_label = label.clone(); + client_state::before_window_navigation( app_handle, + label, client_state::NavigationKind::Reload, None, - |app| { - let window = app - .get_webview_window("main") - .ok_or_else(|| "main window not found for reload".to_string())?; - window + move |app| { + app.get_webview_window(&target_label) + .ok_or_else(|| "local window not found for reload".to_string())? .reload() - .map_err(|err| format!("failed to reload main window: {err}")) + .map_err(|err| format!("failed to reload local window: {err}")) }, ); } -fn force_reload_main_window(app_handle: &AppHandle) { - client_state::before_main_window_navigation( +fn force_reload_target_window(app_handle: &AppHandle) { + let Some(window) = local_windows::targeted_window(app_handle) else { + return; + }; + let label = window.label().to_string(); + if identity::local_window_id(&label).is_err() { + let _ = window.reload(); + return; + } + let target_label = label.clone(); + client_state::before_window_navigation( app_handle, + label, client_state::NavigationKind::ForceReload, None, - |app| { + move |app| { let window = app - .get_webview_window("main") - .ok_or_else(|| "main window not found for force reload".to_string())?; + .get_webview_window(&target_label) + .ok_or_else(|| "local window not found for force reload".to_string())?; if let Ok(mut url) = window.url() { if should_allow_internal(&url) { let reload_token = SystemTime::now() @@ -601,7 +1114,7 @@ fn force_reload_main_window(app_handle: &AppHandle) { } fn toggle_fullscreen_window(app_handle: &AppHandle) { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let next_fullscreen = !window.is_fullscreen().unwrap_or(false); let _ = window.set_fullscreen(next_fullscreen); if cfg!(not(target_os = "macos")) { @@ -614,6 +1127,32 @@ fn toggle_fullscreen_window(app_handle: &AppHandle) { } } +fn set_target_zoom(app: &AppHandle, window: &tauri::WebviewWindow, zoom: f64) { + if identity::local_window_id(window.label()).is_ok() { + client_state::set_local_window_zoom(app, window.label(), zoom); + return; + } + let zoom = zoom.clamp(0.25, 5.0); + if window.set_zoom(zoom).is_ok() { + if let Ok(mut levels) = app.state::().remote_zoom_levels.lock() { + levels.insert(window.label().to_string(), zoom); + } + } +} + +fn target_zoom(app: &AppHandle, window: &tauri::WebviewWindow) -> f64 { + if identity::local_window_id(window.label()).is_ok() { + client_state::local_window_zoom(app, window.label()) + } else { + app.state::() + .remote_zoom_levels + .lock() + .ok() + .and_then(|levels| levels.get(window.label()).copied()) + .unwrap_or(client_state::DEFAULT_ZOOM_LEVEL) + } +} + fn fullscreen_shortcut() -> Option { if cfg!(target_os = "macos") { None @@ -622,9 +1161,23 @@ fn fullscreen_shortcut() -> Option { } } +fn update_fullscreen_shortcut(app: &AppHandle) { + let Some(shortcut) = fullscreen_shortcut() else { + return; + }; + let local_focused = app.webview_windows().into_values().any(|window| { + identity::local_window_id(window.label()).is_ok() && window.is_focused().unwrap_or(false) + }); + if local_focused { + let _ = app.global_shortcut().register(shortcut); + } else { + let _ = app.global_shortcut().unregister(shortcut); + } +} + #[cfg(windows)] -fn set_windows_app_user_model_id() { - let app_id: Vec = OsStr::new(WINDOWS_APP_USER_MODEL_ID) +fn set_windows_app_user_model_id(identifier: &str) { + let app_id: Vec = OsStr::new(identifier) .encode_wide() .chain(iter::once(0)) .collect(); @@ -636,7 +1189,26 @@ fn set_windows_app_user_model_id() { } #[cfg(not(windows))] -fn set_windows_app_user_model_id() {} +fn set_windows_app_user_model_id(_identifier: &str) {} + +#[cfg(windows)] +fn isolate_windows_webview_profile(scope: &identity::IdentityScope) { + if std::env::var_os("WEBVIEW2_USER_DATA_FOLDER").is_some() { + return; + } + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &scope.webview_data_directory); +} + +fn schedule_launch_drain(app: AppHandle, queue: Arc) { + let dispatch = app.clone(); + let _ = app.run_on_main_thread(move || { + for intent in queue.drain() { + if let Err(error) = local_windows::handle_intent(&dispatch, intent) { + eprintln!("[tauri-startup] launch intent failed: {error}"); + } + } + }); +} fn main() { #[cfg(windows)] @@ -645,12 +1217,49 @@ fn main() { } let _ = rustls::crypto::ring::default_provider().install_default(); + let cwd = std::env::current_dir().unwrap_or_default(); + let home = dirs::home_dir().unwrap_or_else(|| cwd.clone()); + let local_data = dirs::data_local_dir().unwrap_or_else(|| home.clone()); + let scope = identity::resolve_scope( + std::env::var("CODENOMAD_UPDATE_CHANNEL").ok().as_deref(), + std::env::var("CLI_CONFIG").ok().as_deref(), + env!("CARGO_PKG_VERSION"), + !is_dev_mode(), + &cwd, + &home, + &local_data, + ); + #[cfg(windows)] + isolate_windows_webview_profile(&scope); + + let launch_queue = Arc::new(launch::LaunchQueue::default()); + launch_queue.enqueue(launch::parse_launch_intent( + &std::env::args().skip(1).collect::>(), + &cwd, + )); + let singleton_queue = Arc::clone(&launch_queue); + let single_instance = tauri_plugin_single_instance::init(move |app, args, callback_cwd| { + let cwd = std::path::PathBuf::from(callback_cwd); + let arguments = args.into_iter().skip(1).collect::>(); + #[cfg(windows)] + let intent = launch::parse_windows_forwarded_launch_intent(&arguments, &cwd); + #[cfg(not(windows))] + let intent = launch::parse_launch_intent(&arguments, &cwd); + singleton_queue.enqueue(intent); + schedule_launch_drain(app.clone(), Arc::clone(&singleton_queue)); + }); let navigation_guard: TauriPlugin = PluginBuilder::new("external-link-guard") .on_navigation(|webview, url| intercept_navigation(webview, url)) .build(); + let mut context = tauri::generate_context!(); + context.config_mut().identifier = scope.identifier.clone(); + let setup_scope = scope.clone(); + let setup_queue = Arc::clone(&launch_queue); + tauri::Builder::default() + .plugin(single_instance) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin( @@ -668,28 +1277,43 @@ fn main() { ) .plugin(tauri_plugin_notification::init()) .plugin(navigation_guard) + .manage(local_windows::LocalWindows::default()) .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()), + wake_lock: Mutex::new(WakeLockState::default()), + remote_navigation: Mutex::new(HashMap::new()), + remote_navigation_generation: AtomicU64::new(0), + remote_profiles: Mutex::new(HashMap::new()), + remote_window_operations: RemoteWindowOperationLocks::default(), + remote_proxy_cleanup_claims: Mutex::new(HashSet::new()), + remote_tls_handlers: Mutex::new(HashMap::new()), + remote_zoom_levels: Mutex::new(HashMap::new()), workspace_menu_items: Mutex::new(None), - workspace_menu_requested_enabled: Mutex::new(false), + webview_data_directory: setup_scope.webview_data_directory.clone(), + scoped_profile: setup_scope.scoped, }) .on_page_load(|webview, payload| { - if webview.label() == "main" && payload.event() == PageLoadEvent::Started { - if let Ok(mut enabled) = webview - .app_handle() - .state::() - .workspace_menu_requested_enabled - .lock() - { - *enabled = false; + if identity::local_window_id(webview.label()).is_ok() + && payload.event() == PageLoadEvent::Started + { + let app = webview.app_handle(); + let managed_backend = app.state::().manager.status().url; + if is_allowed_local_origin(payload.url(), managed_backend.as_deref()) { + if let (Ok(window_id), Some(state)) = ( + identity::local_window_id(webview.label()), + app.try_state::(), + ) { + if let Err(error) = + state.stage_renderer_page_load(&window_id, payload.url()) + { + eprintln!("[client-state] failed to stage renderer page load: {error}"); + } + } } + let _ = webview + .app_handle() + .state::() + .set_workspace_menu_enabled(webview.label(), false); update_workspace_menu_state(&webview.app_handle()); } if matches!( @@ -699,51 +1323,30 @@ fn main() { apply_remote_window_title(&webview.app_handle(), webview.label()); } }) - .setup(|app| { - set_windows_app_user_model_id(); - let client_state = client_state::ClientState::initialize(&app.handle()); + .setup(move |app| { + set_windows_app_user_model_id(&setup_scope.identifier); + let client_state = client_state::ClientState::initialize( + &app.handle(), + setup_scope.client_state_directory.as_deref(), + ); app.manage(client_state); app.manage(shutdown::ShutdownCoordinator::default()); build_menu(&app.handle())?; - client_state::setup_main_window(&app.handle()) + local_windows::restore_windows(&app.handle()) .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) - .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(); - let _ = shortcut_manager.register(shortcut.clone()); - - if let Some(window) = app.get_webview_window("main") { - let app_handle = app.handle().clone(); - window.on_window_event(move |event| { - if let WindowEvent::Focused(focused) = event { - let shortcut_manager = app_handle.global_shortcut(); - if *focused { - let _ = shortcut_manager.register(shortcut.clone()); - } else { - let _ = shortcut_manager.unregister(shortcut.clone()); - } - } - }); - } - } + schedule_launch_drain(app.handle().clone(), Arc::clone(&setup_queue)); + update_fullscreen_shortcut(&app.handle()); let dev_mode = is_dev_mode(); let app_handle = app.handle().clone(); let manager = app.state::().manager.clone(); std::thread::spawn(move || { if let Err(err) = manager.start(app_handle.clone(), dev_mode) { - let _ = app_handle.emit("cli:error", json!({"message": err.to_string()})); + local_windows::emit_all( + &app_handle, + "cli:error", + json!({"message": err.to_string()}), + ); } }); Ok(()) @@ -751,8 +1354,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, @@ -760,10 +1361,15 @@ fn main() { client_state::client_state_claim_access, client_state::client_state_load, client_state::client_state_save, + client_state::client_state_commit_partitions, + client_state::client_state_load_partition, client_state::client_state_set_restore_enabled, client_state::client_state_clear, client_state::client_state_renderer_flushed, client_state::client_state_navigation_flushed, + local_windows::desktop_launch_ready, + local_windows::desktop_launch_next_folder, + local_windows::desktop_launch_acknowledge_folder, windows_update::install_stable_update, workspace_open::open_workspace_target, set_workspace_menu_enabled @@ -778,28 +1384,28 @@ fn main() { | "open-workspace-editor-cursor" | "open-workspace-editor-zed" | "open-workspace-editor-vscodium") => { - if let Some(window) = app_handle.get_webview_window("main") { - if action.starts_with("open-workspace-") - && !window.is_focused().unwrap_or(false) - { - return; - } + if let Some(window) = local_windows::focused_local_window(app_handle) { let _ = window.emit("menu:action", action); } } + "new-window" => { + if let Err(error) = local_windows::create_new_window(app_handle) { + eprintln!("[tauri] failed to create local window: {error}"); + } + } "quit" => { - app_handle.exit(0); + shutdown::request(app_handle.clone()); } // View menu "reload" => { - reload_main_window(app_handle); + reload_target_window(app_handle); } "force_reload" => { - force_reload_main_window(app_handle); + force_reload_target_window(app_handle); } "toggle_devtools" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { if window.is_devtools_open() { window.close_devtools(); } else { @@ -808,18 +1414,21 @@ fn main() { } } "reset_zoom" => { - client_state::set_main_window_zoom( - app_handle, - client_state::DEFAULT_ZOOM_LEVEL, - ); + if let Some(window) = local_windows::targeted_window(app_handle) { + set_target_zoom(app_handle, &window, client_state::DEFAULT_ZOOM_LEVEL); + } } "zoom_in" => { - let zoom_level = client_state::main_window_zoom(app_handle); - client_state::set_main_window_zoom(app_handle, zoom_level + ZOOM_STEP); + if let Some(window) = local_windows::targeted_window(app_handle) { + let zoom = target_zoom(app_handle, &window); + set_target_zoom(app_handle, &window, zoom + ZOOM_STEP); + } } "zoom_out" => { - let zoom_level = client_state::main_window_zoom(app_handle); - client_state::set_main_window_zoom(app_handle, zoom_level - ZOOM_STEP); + if let Some(window) = local_windows::targeted_window(app_handle) { + let zoom = target_zoom(app_handle, &window); + set_target_zoom(app_handle, &window, zoom - ZOOM_STEP); + } } "toggle_fullscreen" => { @@ -828,17 +1437,17 @@ fn main() { // Window menu "minimize" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.minimize(); } } "zoom" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.maximize(); } } "close_window" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.close(); } } @@ -848,7 +1457,7 @@ fn main() { { let app_handle = app_handle.clone(); tauri::async_runtime::spawn(run_update_with_fallback( - windows_update::install_stable_update(), + windows_update::install_stable_update_impl(), move || open_releases_page(&app_handle), )); } @@ -858,7 +1467,7 @@ fn main() { } // App menu (macOS) "hide" => { - if let Some(window) = app_handle.get_webview_window("main") { + if let Some(window) = local_windows::targeted_window(app_handle) { let _ = window.hide(); } } @@ -876,7 +1485,7 @@ fn main() { } } }) - .build(tauri::generate_context!()) + .build(context) .expect("error while building tauri application") .run(|app_handle, event| match event { tauri::RunEvent::ExitRequested { api, .. } => { @@ -907,38 +1516,73 @@ fn main() { } => { emit_window_event(&app_handle, &label, "desktop:folder-drag-leave"); } + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::Focused(focused), + .. + } => { + if focused && identity::local_window_id(&label).is_ok() { + app_handle + .state::() + .mark_focused(&app_handle, &label); + } + update_workspace_menu_state(&app_handle); + update_fullscreen_shortcut(&app_handle); + } tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. } => { - if label == "main" { - if shutdown::main_window_close_allowed(&app_handle) { - return; - } - let final_window = app_handle.webview_windows().len() == 1; - if shutdown::exit_allowed(&app_handle) { - return; + if shutdown::exit_allowed(&app_handle) { + return; + } + let local_window = identity::local_window_id(&label).is_ok(); + if local_window { + match shutdown::consume_local_window_close(&app_handle, &label) { + Some(true) => return, + Some(false) => { + api.prevent_close(); + return; + } + None => {} } + } + let final_window = app_handle.webview_windows().len() == 1; + if final_window { api.prevent_close(); - if final_window { - shutdown::request(app_handle.clone()); - } else { - shutdown::request_main_window_close(app_handle.clone()); - } + shutdown::request(app_handle.clone()); return; } - // Let windows close normally. App shutdown is handled only after the - // last window is actually gone so remote windows can outlive `main`. + if local_window { + api.prevent_close(); + shutdown::request_local_window_close(app_handle.clone(), label); + } } tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => { - if label == "main" { - shutdown::main_window_destroyed(app_handle.clone()); + if let Ok(window_id) = identity::local_window_id(&label) { + app_handle + .state::() + .remove_runtime(&label); + if let Some(state) = app_handle.try_state::() { + state.unregister_window(&window_id); + } + } + if let Ok(mut wake) = app_handle.state::().wake_lock.lock() { + wake.labels.remove(&label); + if wake.labels.is_empty() { + wake.handle.take(); + } + } + if let Ok(mut zoom) = app_handle.state::().remote_zoom_levels.lock() { + zoom.remove(&label); } + update_workspace_menu_state(&app_handle); + update_fullscreen_shortcut(&app_handle); if !app_handle.webview_windows().is_empty() { return; } @@ -947,6 +1591,18 @@ fn main() { // truly exiting. shutdown::request(app_handle.clone()); } + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => { + let reopened = app_handle + .state::() + .mru_label() + .is_some_and(|label| local_windows::focus(app_handle, &label)); + if !reopened { + if let Err(error) = local_windows::create_new_window(app_handle) { + eprintln!("[tauri] failed to create local window on reopen: {error}"); + } + } + } _ => {} }); } @@ -984,7 +1640,13 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { submenus.push(app_menu); } - // File menu - create New Instance with accelerator + let new_window_item = MenuItem::with_id( + app, + "new-window", + "New Window", + true, + Some("CmdOrCtrl+Shift+N"), + )?; let new_instance_item = MenuItem::with_id( app, "new-instance", @@ -1025,8 +1687,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { 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) @@ -1035,8 +1695,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .build()? } else { SubmenuBuilder::new(app, "File") - .item(&new_instance_item) - .separator() .item(&open_folder_item) .item(&open_terminal_item) .item(&open_editor_menu) @@ -1129,6 +1787,9 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { // Window menu let window_menu = if is_linux { SubmenuBuilder::new(app, "Window") + .item(&new_window_item) + .item(&new_instance_item) + .separator() .text("minimize", "Minimize") .text("zoom", "Zoom") .separator() @@ -1136,11 +1797,17 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .build()? } else if is_mac { SubmenuBuilder::new(app, "Window") + .item(&new_window_item) + .item(&new_instance_item) + .separator() .minimize() .maximize() .build()? } else { SubmenuBuilder::new(app, "Window") + .item(&new_window_item) + .item(&new_instance_item) + .separator() .minimize() .maximize() .separator() @@ -1202,8 +1869,11 @@ 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, should_allow_registered_origin, - RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, + build_about_metadata, claim_unowned_remote_proxy_session, clear_remote_tls_handler, + is_allowed_local_origin, require_http_url, rollback_remote_window_metadata, + run_update_with_fallback, should_allow_registered_origin, should_open_external_url, + should_recreate_remote_window, RemoteProfileIdentity, RemoteWindowMetadata, + RemoteWindowOperationLocks, WakeLockState, RELEASES_URL, REMOTE_WINDOW_CONTEXT_SCRIPT, }; use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; @@ -1269,6 +1939,33 @@ mod menu_tests { .as_array() .unwrap() .contains(&json!("remote-window-notifications"))); + assert_eq!(config["app"]["windows"], json!([])); + let local: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/main-window.json")).unwrap(); + assert_eq!(local["windows"], json!(["local-*"])); + assert!(local["permissions"] + .as_array() + .unwrap() + .contains(&json!("allow-cli-restart"))); + assert!(!capability["permissions"] + .as_array() + .unwrap() + .iter() + .any(|permission| permission + .as_str() + .is_some_and(|value| value.starts_with("allow-cli")))); + } + + #[test] + fn wake_lock_request_labels_are_reference_counted() { + let mut state = WakeLockState::default(); + state.labels.insert("local-a".into()); + state.labels.insert("local-b".into()); + state.labels.remove("local-a"); + assert_eq!( + state.labels, + std::collections::HashSet::from(["local-b".to_string()]) + ); } #[test] @@ -1287,4 +1984,213 @@ mod menu_tests { &Url::parse("about:blank").unwrap() )); } + + #[test] + fn failed_remote_navigation_restores_exact_previous_authority() { + let previous = RemoteWindowMetadata { + origin: "https://old.example".into(), + title: "Old title".into(), + allow_linux_tls_certificate: false, + generation: 4, + window_generation: 2, + }; + let mut values = std::collections::HashMap::from([( + "remote-a".to_string(), + RemoteWindowMetadata { + origin: "https://new.example".into(), + title: "New title".into(), + allow_linux_tls_certificate: true, + generation: 5, + window_generation: 2, + }, + )]); + + assert!(rollback_remote_window_metadata( + &mut values, + "remote-a", + 5, + Some(previous.clone()), + )); + assert_eq!(values.get("remote-a"), Some(&previous)); + } + + #[test] + fn stale_remote_navigation_failure_cannot_rollback_newer_authority() { + let current = RemoteWindowMetadata { + origin: "https://newest.example".into(), + title: "Newest title".into(), + allow_linux_tls_certificate: true, + generation: 6, + window_generation: 3, + }; + let mut values = + std::collections::HashMap::from([("remote-a".to_string(), current.clone())]); + + assert!(!rollback_remote_window_metadata( + &mut values, + "remote-a", + 5, + None, + )); + assert_eq!(values.get("remote-a"), Some(¤t)); + } + + #[test] + fn stale_window_cleanup_cannot_remove_replacement_tls_handler() { + let mut handlers = std::collections::HashMap::from([("remote-a".to_string(), 2)]); + + assert!(!clear_remote_tls_handler(&mut handlers, "remote-a", 1)); + assert_eq!(handlers.get("remote-a"), Some(&2)); + assert!(clear_remote_tls_handler(&mut handlers, "remote-a", 2)); + assert!(!handlers.contains_key("remote-a")); + } + + #[test] + fn remote_window_urls_require_http_or_https() { + assert_eq!( + require_http_url("http://localhost:3000/app", "baseUrl") + .unwrap() + .scheme(), + "http" + ); + assert_eq!( + require_http_url("https://example.com/app", "entryUrl") + .unwrap() + .scheme(), + "https" + ); + for value in [ + "file:///tmp/app", + "data:text/html,hi", + "javascript:alert(1)", + ] { + assert!(require_http_url(value, "baseUrl") + .unwrap_err() + .contains("must use HTTP or HTTPS")); + } + } + + #[test] + fn external_navigation_allows_only_web_and_mail_urls() { + for value in [ + "https://example.com", + "http://example.com", + "mailto:hello@example.com", + ] { + assert!(should_open_external_url(&Url::parse(value).unwrap())); + } + for value in [ + "vscode://file/C:/workspace", + "ms-settings:privacy", + "tel:+15551234567", + "unknown:target", + ] { + assert!(!should_open_external_url(&Url::parse(value).unwrap())); + } + } + + #[test] + fn renderer_opener_permission_excludes_tel_and_unscoped_access() { + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/main-window.json")).unwrap(); + let opener = capability["permissions"] + .as_array() + .unwrap() + .iter() + .find(|permission| permission["identifier"] == "opener:allow-open-url") + .unwrap(); + + assert_eq!( + opener["allow"], + json!([ + { "url": "http://*" }, + { "url": "https://*" }, + { "url": "mailto:*" } + ]) + ); + assert!(!capability["permissions"] + .as_array() + .unwrap() + .iter() + .any(|permission| permission == "opener:allow-default-urls" + || permission == "opener:allow-open-url")); + } + + #[test] + fn remote_window_reuse_requires_exact_profile_identity() { + let direct = RemoteProfileIdentity::Direct; + let proxy_a = RemoteProfileIdentity::Proxy("a".into()); + let proxy_b = RemoteProfileIdentity::Proxy("b".into()); + assert!(!should_recreate_remote_window(Some(&direct), &direct)); + assert!(!should_recreate_remote_window(Some(&proxy_a), &proxy_a)); + assert!(should_recreate_remote_window(Some(&direct), &proxy_a)); + assert!(should_recreate_remote_window(Some(&proxy_a), &direct)); + assert!(should_recreate_remote_window(Some(&proxy_a), &proxy_b)); + assert!(should_recreate_remote_window(None, &direct)); + } + + #[test] + fn remote_window_operations_serialize_only_matching_labels() { + let operations = RemoteWindowOperationLocks::default(); + let first = operations.for_label("remote-a").unwrap(); + let same = operations.for_label("remote-a").unwrap(); + let other = operations.for_label("remote-b").unwrap(); + + tauri::async_runtime::block_on(async { + let _guard = first.lock().await; + assert!(same.try_lock().is_err()); + assert!(other.try_lock().is_ok()); + }); + } + + #[test] + fn proxy_cleanup_is_claimed_once_and_never_while_owned() { + let mut profiles = std::collections::HashMap::from([( + "remote-a".to_string(), + RemoteProfileIdentity::Proxy("previous".into()), + )]); + let mut claims = std::collections::HashSet::new(); + + assert!(!claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "previous", + )); + profiles.insert( + "remote-a".into(), + RemoteProfileIdentity::Proxy("newer".into()), + ); + assert!(claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "previous", + )); + assert!(!claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "previous", + )); + assert!(!claim_unowned_remote_proxy_session( + &profiles, + &mut claims, + "newer", + )); + } + + #[test] + fn local_navigation_rejects_remote_and_unrelated_loopback_origins() { + let managed = "http://127.0.0.1:43123"; + assert!(is_allowed_local_origin( + &Url::parse("http://127.0.0.1:43123/workspace").unwrap(), + Some(managed), + )); + assert!(!is_allowed_local_origin( + &Url::parse("http://127.0.0.1:43124/workspace").unwrap(), + Some(managed), + )); + assert!(!is_allowed_local_origin( + &Url::parse("https://remote.example/workspace").unwrap(), + Some(managed), + )); + } } diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 8fab8faef..936dd11e9 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -1,222 +1,509 @@ -use crate::{client_state, AppState}; -#[cfg(windows)] -use std::sync::atomic::{AtomicBool, Ordering}; +use crate::{client_state, local_windows::LocalWindows, AppState}; +use std::collections::HashMap; use std::sync::Mutex; -use std::time::Duration; +use std::time::{Duration, Instant}; #[cfg(windows)] use tauri::WebviewWindow; use tauri::{AppHandle, Emitter, Manager}; const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); const SHUTDOWN_STOP_ATTEMPTS: usize = 2; +const FLUSH_CANCELLED_EVENT: &str = "client-state:flush-cancelled"; #[cfg(windows)] const WINDOWS_SESSION_END_TIMEOUT: Duration = Duration::from_secs(5); -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) enum ShutdownPhase { - #[default] - Idle, - WaitingForShutdownRenderer, - CleanupInProgress, - CleanupBlocked, - ExitAllowed, - WaitingForMainWindowRenderer, - FlushingMainWindow, - FlushingMainWindowForShutdown, - MainWindowCloseAllowed, +#[derive(Clone, Debug, PartialEq, Eq)] +struct PendingClose { + window_id: String, + generation: u64, + persisted: bool, +} + +#[cfg(windows)] +struct WindowsSessionEndPreparation { + generation: u64, + deadline: Instant, + requests: Option>, } #[derive(Default)] -pub(crate) struct ShutdownCoordinator { - state: Mutex, +struct ShutdownState { + next_generation: u64, + local_closes: HashMap, + committed_local_closes: HashMap, + global_pending: HashMap, + global_requests: HashMap, + shutdown_started: bool, + cleanup_started: bool, + exit_allowed: bool, + #[cfg(windows)] + windows_session_end_generation: Option, #[cfg(windows)] - windows_session_end_started: AtomicBool, + windows_session_end_deadline: Option, + #[cfg(windows)] + windows_session_end_owns_shutdown: bool, + #[cfg(windows)] + windows_renderer_deadline: Option, + #[cfg(windows)] + windows_native_flush_complete: bool, } #[derive(Default)] -struct ShutdownState { - phase: ShutdownPhase, - flush_generation: u64, - active_flush_generation: Option, +pub(crate) struct ShutdownCoordinator { + state: Mutex, } impl ShutdownCoordinator { - fn apply(&self, event: ShutdownEvent) -> (ShutdownAction, Option) { - let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - if let Some(generation) = event.renderer_generation() { - if state.active_flush_generation != Some(generation) { - return (ShutdownAction::None, None); - } + fn begin_local_close(&self, label: String, window_id: String, persisted: bool) -> Option { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.shutdown_started + || state.local_closes.contains_key(&label) + || state.committed_local_closes.contains_key(&label) + { + return None; + } + state.next_generation += 1; + let generation = state.next_generation; + state.local_closes.insert( + label, + PendingClose { + window_id, + generation, + persisted, + }, + ); + Some(generation) + } + + fn acknowledge_local( + &self, + label: &str, + window_id: &str, + generation: u64, + ) -> Option { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let pending = state.local_closes.get(label)?; + if pending.window_id != window_id || pending.generation != generation { + return None; + } + state.local_closes.remove(label) + } + + fn begin_shutdown( + &self, + labels: impl IntoIterator, + ) -> Option> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.shutdown_started { + return None; + } + state.shutdown_started = true; + state.local_closes.clear(); + state.committed_local_closes.clear(); + state.global_requests.clear(); + let mut requests = Vec::new(); + for label in labels { + state.next_generation += 1; + let generation = state.next_generation; + state.global_pending.insert(label.clone(), generation); + state.global_requests.insert(label.clone(), generation); + requests.push((label, generation)); + } + Some(requests) + } + + fn acknowledge_global(&self, label: &str, generation: u64) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.global_pending.get(label).copied() != Some(generation) { + return false; + } + state.global_pending.remove(label); + state.global_pending.is_empty() + } + + fn begin_cleanup(&self, deadline_reached: bool) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if !state.shutdown_started + || state.cleanup_started + || (!deadline_reached && !state.global_pending.is_empty()) + { + return false; + } + #[cfg(windows)] + if state.windows_session_end_owns_shutdown { + return false; + } + state.global_pending.clear(); + state.cleanup_started = true; + true + } + + fn cleanup_failed(&self) -> Vec<(String, u64)> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let cancellations = state.global_requests.drain().collect(); + state.cleanup_started = false; + state.shutdown_started = false; + state.global_pending.clear(); + state.committed_local_closes.clear(); + #[cfg(windows)] + { + state.windows_session_end_generation = None; + state.windows_session_end_deadline = None; + state.windows_session_end_owns_shutdown = false; + state.windows_renderer_deadline = None; + state.windows_native_flush_complete = false; } - let (next, action) = transition(state.phase, event); - state.phase = next; - if action == ShutdownAction::RequestRendererFlush { - state.flush_generation += 1; - state.active_flush_generation = Some(state.flush_generation); - } else if !matches!( - next, - ShutdownPhase::WaitingForShutdownRenderer | ShutdownPhase::WaitingForMainWindowRenderer - ) { - state.active_flush_generation = None; + cancellations + } + + fn commit_local_close(&self, label: String, pending: PendingClose) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.shutdown_started { + return false; } - (action, state.active_flush_generation) + state.committed_local_closes.insert(label, pending); + true + } + + fn rollback_local_close(&self, label: &str) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .committed_local_closes + .remove(label); + } + + fn take_committed_local_close(&self, label: &str) -> Option { + self.state.lock().ok()?.committed_local_closes.remove(label) + } + + fn exit_allowed(&self) -> bool { + self.state + .lock() + .map(|state| state.exit_allowed) + .unwrap_or(false) } - fn phase(&self) -> ShutdownPhase { + fn allow_exit(&self) { self.state .lock() - .unwrap_or_else(|err| err.into_inner()) - .phase + .unwrap_or_else(|error| error.into_inner()) + .exit_allowed = true; } - fn with_navigation(&self, operation: impl FnOnce() -> T) -> Option { - let state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - (state.phase == ShutdownPhase::Idle).then(operation) + fn with_navigation_authority(&self, operation: impl FnOnce() -> T) -> Option { + let state = self.state.lock().ok()?; + if state.shutdown_started { + return None; + } + Some(operation()) + } + + fn shutdown_started(&self) -> bool { + self.state + .lock() + .map(|state| state.shutdown_started) + .unwrap_or(true) } #[cfg(windows)] - fn begin_windows_session_end(&self) -> bool { - !self - .windows_session_end_started - .swap(true, Ordering::SeqCst) + fn begin_windows_session_end( + &self, + labels: impl IntoIterator, + ) -> WindowsSessionEndPreparation { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(generation) = state.windows_session_end_generation { + return WindowsSessionEndPreparation { + generation, + deadline: state + .windows_session_end_deadline + .unwrap_or_else(Instant::now), + requests: None, + }; + } + state.next_generation += 1; + let session_generation = state.next_generation; + let session_deadline = Instant::now() + WINDOWS_SESSION_END_TIMEOUT; + state.windows_session_end_generation = Some(session_generation); + state.windows_session_end_deadline = Some(session_deadline); + if state.shutdown_started { + return WindowsSessionEndPreparation { + generation: session_generation, + deadline: session_deadline, + requests: Some(Vec::new()), + }; + } + state.windows_session_end_owns_shutdown = true; + state.windows_renderer_deadline = Some(Instant::now() + RENDERER_FLUSH_TIMEOUT); + state.windows_native_flush_complete = false; + state.shutdown_started = true; + state.local_closes.clear(); + state.committed_local_closes.clear(); + state.global_requests.clear(); + let mut requests = Vec::new(); + for label in labels { + state.next_generation += 1; + let generation = state.next_generation; + state.global_pending.insert(label.clone(), generation); + state.global_requests.insert(label.clone(), generation); + requests.push((label, generation)); + } + WindowsSessionEndPreparation { + generation: session_generation, + deadline: session_deadline, + requests: Some(requests), + } } #[cfg(windows)] - fn complete_windows_session_end(&self) { - let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - state.phase = ShutdownPhase::ExitAllowed; - state.active_flush_generation = None; + fn windows_session_end_owns_shutdown(&self, generation: u64) -> bool { + self.state + .lock() + .map(|state| { + state.windows_session_end_generation == Some(generation) + && state.windows_session_end_owns_shutdown + }) + .unwrap_or(false) } -} -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShutdownEvent { - BeginShutdown, - BeginMainWindowClose, - RendererFlushed(u64), - RendererUnavailable, - RendererTimeout(u64), - MainWindowFlushed, - MainWindowCloseFailed, - MainWindowDestroyed, - CleanupFinished, - CleanupFailed, -} + #[cfg(windows)] + fn windows_renderer_wait(&self, generation: u64) -> Option<(bool, Instant)> { + self.state.lock().ok().and_then(|state| { + (state.windows_session_end_generation == Some(generation) + && state.windows_session_end_owns_shutdown) + .then(|| { + state + .windows_renderer_deadline + .map(|deadline| (!state.global_pending.is_empty(), deadline)) + }) + .flatten() + }) + } -impl ShutdownEvent { - fn renderer_generation(self) -> Option { - match self { - Self::RendererFlushed(generation) | Self::RendererTimeout(generation) => { - Some(generation) - } - _ => None, + #[cfg(windows)] + fn begin_windows_cleanup(&self, generation: u64) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.windows_session_end_generation != Some(generation) + || !state.windows_session_end_owns_shutdown + || state.cleanup_started + { + return false; } + state.cleanup_started = true; + state.global_pending.clear(); + true } -} -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShutdownAction { - None, - RequestRendererFlush, - FlushMainWindow, - CloseMainWindow, - StartCleanup, -} + #[cfg(windows)] + fn complete_windows_native_flush(&self, generation: u64) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.windows_session_end_generation == Some(generation) + && state.windows_session_end_owns_shutdown + { + state.windows_native_flush_complete = true; + } + } + + #[cfg(windows)] + fn windows_native_flush_complete(&self, generation: u64) -> bool { + self.state + .lock() + .map(|state| { + state.windows_session_end_generation == Some(generation) + && state.windows_native_flush_complete + }) + .unwrap_or(false) + } + + #[cfg(windows)] + fn windows_session_end_remaining(&self, generation: u64, now: Instant) -> Option { + self.state.lock().ok().and_then(|state| { + (state.windows_session_end_generation == Some(generation)) + .then(|| { + state + .windows_session_end_deadline + .map(|deadline| deadline.saturating_duration_since(now)) + }) + .flatten() + }) + } -fn transition(phase: ShutdownPhase, event: ShutdownEvent) -> (ShutdownPhase, ShutdownAction) { - use ShutdownAction::*; - use ShutdownEvent::*; - use ShutdownPhase::*; - - match (phase, event) { - (Idle, BeginShutdown) => (WaitingForShutdownRenderer, RequestRendererFlush), - ( - WaitingForShutdownRenderer, - RendererFlushed(_) | RendererUnavailable | RendererTimeout(_) | MainWindowDestroyed, - ) => (CleanupInProgress, StartCleanup), - (Idle, BeginMainWindowClose) => (WaitingForMainWindowRenderer, RequestRendererFlush), - (WaitingForMainWindowRenderer, RendererFlushed(_) | RendererTimeout(_)) => { - (FlushingMainWindow, FlushMainWindow) + #[cfg(windows)] + fn cancel_windows_session_end(&self) -> Vec<(String, u64)> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.windows_session_end_generation.is_none() { + return Vec::new(); } - (WaitingForMainWindowRenderer, RendererUnavailable | MainWindowDestroyed) => (Idle, None), - (WaitingForMainWindowRenderer, BeginShutdown) => (WaitingForShutdownRenderer, None), - (FlushingMainWindow, BeginShutdown) => (FlushingMainWindowForShutdown, None), - (FlushingMainWindow, MainWindowFlushed) => (MainWindowCloseAllowed, CloseMainWindow), - (FlushingMainWindow, MainWindowDestroyed) => (Idle, None), - (FlushingMainWindowForShutdown, MainWindowFlushed | MainWindowDestroyed) => { - (CleanupInProgress, StartCleanup) + state.windows_session_end_generation = None; + state.windows_session_end_deadline = None; + state.windows_renderer_deadline = None; + state.windows_native_flush_complete = false; + if !state.windows_session_end_owns_shutdown || state.cleanup_started { + return Vec::new(); } - (MainWindowCloseAllowed, MainWindowDestroyed | MainWindowCloseFailed) => (Idle, None), - (MainWindowCloseAllowed, BeginShutdown) => (CleanupInProgress, StartCleanup), - (CleanupInProgress, CleanupFinished) => (ExitAllowed, None), - (CleanupInProgress, CleanupFailed) => (CleanupBlocked, None), - (CleanupBlocked, BeginShutdown) => (CleanupInProgress, StartCleanup), - _ => (phase, None), + state.windows_session_end_owns_shutdown = false; + state.shutdown_started = false; + state.global_pending.clear(); + state.global_requests.drain().collect() } } -fn apply_event(app: AppHandle, event: ShutdownEvent) { - let (action, generation) = app.state::().apply(event); +fn emit_flush(app: &AppHandle, label: &str, generation: u64) -> bool { + app.get_webview_window(label).is_some_and(|window| { + window + .emit( + "client-state:flush-requested", + client_state::RendererFlushRequest { generation }, + ) + .is_ok() + }) +} + +fn emit_flush_cancelled(app: &AppHandle, label: &str, generation: u64) { + if let Some(window) = app.get_webview_window(label) { + let _ = window.emit( + FLUSH_CANCELLED_EVENT, + client_state::RendererFlushRequest { generation }, + ); + } +} - match action { - ShutdownAction::RequestRendererFlush => request_renderer_flush(app, generation.unwrap()), - ShutdownAction::FlushMainWindow => flush_main_window(app), - ShutdownAction::CloseMainWindow => close_main_window(app), - ShutdownAction::StartCleanup => start_cleanup(app), - ShutdownAction::None => {} +fn emit_flush_cancellations(app: &AppHandle, cancellations: Vec<(String, u64)>) { + for (label, generation) in cancellations { + emit_flush_cancelled(app, &label, generation); } } -fn request_renderer_flush(app: AppHandle, generation: u64) { - let Some(window) = app.get_webview_window("main") else { - apply_event(app, ShutdownEvent::RendererUnavailable); +pub(crate) fn request_local_window_close(app: AppHandle, label: String) { + let Some(record) = app.state::().record(&label) else { return; }; - if let Err(err) = window.emit( - "client-state:flush-requested", - client_state::RendererFlushRequest { generation }, - ) { - eprintln!("[client-state] failed to request renderer shutdown flush: {err}"); + let Some(generation) = app.state::().begin_local_close( + label.clone(), + record.id.clone(), + record.persisted, + ) else { + return; + }; + if !emit_flush(&app, &label, generation) { + finish_local_close(app, label, record.id, generation); + return; } std::thread::spawn(move || { std::thread::sleep(RENDERER_FLUSH_TIMEOUT); - apply_event(app, ShutdownEvent::RendererTimeout(generation)); + finish_local_close(app, label, record.id, generation); }); } -fn flush_main_window(app: AppHandle) { +fn finish_local_close(app: AppHandle, label: String, window_id: String, generation: u64) { + let Some(pending) = app + .state::() + .acknowledge_local(&label, &window_id, generation) + else { + return; + }; std::thread::spawn(move || { - client_state::capture_and_flush_main_window(&app); - apply_event(app, ShutdownEvent::MainWindowFlushed); + client_state::capture_and_flush_window(&app, &label); + if !app + .state::() + .commit_local_close(label.clone(), pending) + { + return; + } + let close_app = app.clone(); + let close_label = label.clone(); + if app + .run_on_main_thread(move || { + let dispatched = close_app + .get_webview_window(&close_label) + .is_some_and(|window| window.close().is_ok()); + if !dispatched { + close_app + .state::() + .rollback_local_close(&close_label); + emit_flush_cancelled(&close_app, &close_label, generation); + } + }) + .is_err() + { + app.state::() + .rollback_local_close(&label); + emit_flush_cancelled(&app, &label, generation); + } }); } -fn close_main_window(app: AppHandle) { - let Some(window) = app.get_webview_window("main") else { - apply_event(app, ShutdownEvent::MainWindowDestroyed); +pub(crate) fn request(app: AppHandle) { + let labels = app + .state::() + .records() + .into_iter() + .map(|record| record.label) + .collect::>(); + let Some(requests) = app.state::().begin_shutdown(labels) else { + start_cleanup(app, false); return; }; - if let Err(err) = window.close() { - eprintln!("[client-state] failed to close main window after state flush: {err}"); - apply_event(app, ShutdownEvent::MainWindowCloseFailed); + for (label, generation) in &requests { + if !emit_flush(&app, label, *generation) { + app.state::() + .acknowledge_global(label, *generation); + } + } + if requests.is_empty() + || requests.iter().all(|(label, generation)| { + app.state::() + .state + .lock() + .ok() + .is_some_and(|state| state.global_pending.get(label) != Some(generation)) + }) + { + start_cleanup(app, false); + return; + } + std::thread::spawn(move || { + std::thread::sleep(RENDERER_FLUSH_TIMEOUT); + start_cleanup(app, true); + }); +} + +pub(crate) fn renderer_flushed(app: AppHandle, label: String, window_id: String, generation: u64) { + if app + .state::() + .acknowledge_global(&label, generation) + { + start_cleanup(app, false); + return; } + finish_local_close(app, label, window_id, generation); } -fn start_cleanup(app: AppHandle) { +fn start_cleanup(app: AppHandle, deadline_reached: bool) { + if !app + .state::() + .begin_cleanup(deadline_reached) + { + return; + } std::thread::spawn(move || { - let result = cleanup(&app, true); - match result { - Ok(()) => { - apply_event(app.clone(), ShutdownEvent::CleanupFinished); - app.exit(0); - } - Err(err) => { - eprintln!( - "[tauri] shutdown cleanup remains unconfirmed after {SHUTDOWN_STOP_ATTEMPTS} attempts: {err}; keeping the app alive for a later quit retry" - ); - apply_event(app, ShutdownEvent::CleanupFailed); - } + client_state::capture_and_flush_all_windows(&app); + let result = if let Some(state) = app.try_state::() { + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state.manager.stop().map_err(|error| error.to_string()) + }) + } else { + Ok(()) + }; + if let Err(error) = result { + eprintln!("[tauri] shutdown cleanup remains unconfirmed: {error}"); + let cancellations = app.state::().cleanup_failed(); + emit_flush_cancellations(&app, cancellations); + return; } + client_state::release(&app); + app.state::().allow_exit(); + app.exit(0); }); } @@ -228,40 +515,140 @@ fn retry_bounded( for attempt in 1..=attempts { match operation() { Ok(()) => return Ok(()), - Err(err) if attempt == attempts => return Err(err), + Err(error) if attempt == attempts => return Err(error), Err(_) => {} } } unreachable!() } -fn cleanup(app: &AppHandle, capture_window: bool) -> Result<(), String> { - if capture_window { - client_state::capture_and_flush_main_window(app); - } else { - client_state::flush_and_release_without_window_capture(app); +pub(crate) fn with_navigation_authority( + app: &AppHandle, + operation: impl FnOnce() -> T, +) -> Option { + app.state::() + .with_navigation_authority(operation) +} + +pub(crate) fn consume_local_window_close(app: &AppHandle, label: &str) -> Option { + let pending = app + .state::() + .take_committed_local_close(label)?; + if !pending.persisted { + return Some(true); } - if let Some(state) = app.try_state::() { - state.desktop_events.stop(); - retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - state.manager.stop().map_err(|err| err.to_string()) - })?; + let result = app + .try_state::() + .ok_or_else(|| "client state is unavailable".to_string()) + .and_then(|state| match state.remove_window(&pending.window_id) { + Ok(true) => Ok(()), + Ok(false) => Err("persistent window state was not removed".to_string()), + Err(error) => Err(error), + }); + if let Err(error) = result { + eprintln!("[client-state] failed to remove closed window: {error}"); + emit_flush_cancelled(app, label, pending.generation); + return Some(false); } - if capture_window { - client_state::release(app); + Some(true) +} + +pub(crate) fn exit_allowed(app: &AppHandle) -> bool { + app.state::().exit_allowed() +} + +#[cfg(windows)] +fn prepare_windows_session_end(app: &AppHandle) -> (u64, Instant) { + let labels = app + .state::() + .records() + .into_iter() + .map(|record| record.label) + .collect::>(); + let preparation = app + .state::() + .begin_windows_session_end(labels); + let generation = preparation.generation; + let deadline = preparation.deadline; + let Some(requests) = preparation.requests else { + return (generation, deadline); + }; + for (label, flush_generation) in requests { + if !emit_flush(app, &label, flush_generation) { + app.state::() + .acknowledge_global(&label, flush_generation); + } } - Ok(()) + if !app + .state::() + .windows_session_end_owns_shutdown(generation) + { + return (generation, deadline); + } + let flush_app = app.clone(); + std::thread::spawn(move || { + while flush_app + .state::() + .windows_renderer_wait(generation) + .is_some_and(|(pending, deadline)| pending && Instant::now() < deadline) + { + std::thread::sleep(Duration::from_millis(10)); + } + if !flush_app + .state::() + .windows_session_end_owns_shutdown(generation) + { + return; + } + client_state::flush_without_window_capture(&flush_app); + flush_app + .state::() + .complete_windows_native_flush(generation); + }); + (generation, deadline) } -pub(crate) fn request(app: AppHandle) { - apply_event(app, ShutdownEvent::BeginShutdown); +#[cfg(windows)] +fn cancel_windows_session_end(app: &AppHandle) { + let cancellations = app + .state::() + .cancel_windows_session_end(); + emit_flush_cancellations(app, cancellations); } #[cfg(windows)] pub(crate) fn request_windows_session_end(app: AppHandle) { + let (generation, session_deadline) = prepare_windows_session_end(&app); + if !app + .state::() + .windows_session_end_owns_shutdown(generation) + { + while !exit_allowed(&app) && Instant::now() < session_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + return; + } + + while !app + .state::() + .windows_native_flush_complete(generation) + && Instant::now() < session_deadline + { + std::thread::sleep(Duration::from_millis(10)); + } + if !app + .state::() + .windows_native_flush_complete(generation) + { + eprintln!( + "[tauri] Windows session-end state flush exceeded {:?}", + WINDOWS_SESSION_END_TIMEOUT + ); + return; + } if !app .state::() - .begin_windows_session_end() + .begin_windows_cleanup(generation) { return; } @@ -269,67 +656,35 @@ pub(crate) fn request_windows_session_end(app: AppHandle) { let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); let cleanup_app = app.clone(); std::thread::spawn(move || { - // WM_ENDSESSION runs on the window thread. Do not request renderer flushes or - // native window state here: either can marshal back to the blocked thread. The - // bounded fallback therefore persists only state already captured in memory. - let result = cleanup(&cleanup_app, false); - if let Err(err) = &result { - eprintln!("[tauri] Windows session-end cleanup failed: {err}"); - } - cleanup_app - .state::() - .complete_windows_session_end(); + let result = { + cleanup_app + .try_state::() + .map(|state| { + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state + .manager + .stop_until(session_deadline) + .map_err(|error| error.to_string()) + }) + }) + .unwrap_or(Ok(())) + }; + client_state::release(&cleanup_app); + cleanup_app.state::().allow_exit(); let _ = finished_tx.send(result); }); - match finished_rx.recv_timeout(WINDOWS_SESSION_END_TIMEOUT) { - Ok(Ok(())) => {} - Ok(Err(_)) => {} - Err(_) => eprintln!( - "[tauri] Windows session-end cleanup exceeded {:?}; returning control to Windows", + let remaining = app + .state::() + .windows_session_end_remaining(generation, Instant::now()) + .unwrap_or_default(); + if finished_rx.recv_timeout(remaining).is_err() { + eprintln!( + "[tauri] Windows session-end cleanup exceeded {:?}", WINDOWS_SESSION_END_TIMEOUT - ), + ); } } -pub(crate) fn request_main_window_close(app: AppHandle) { - apply_event(app, ShutdownEvent::BeginMainWindowClose); -} - -pub(crate) fn renderer_flushed(app: AppHandle, generation: u64) { - apply_event(app, ShutdownEvent::RendererFlushed(generation)); -} - -pub(crate) fn main_window_destroyed(app: AppHandle) { - apply_event(app, ShutdownEvent::MainWindowDestroyed); -} - -fn phase(app: &AppHandle) -> ShutdownPhase { - app.state::().phase() -} - -pub(crate) fn with_navigation_authority( - app: &AppHandle, - operation: impl FnOnce() -> T, -) -> Option { - app.state::() - .with_navigation(operation) -} - -pub(crate) fn main_window_close_allowed(app: &AppHandle) -> bool { - phase(app) == ShutdownPhase::MainWindowCloseAllowed -} - -pub(crate) fn exit_allowed(app: &AppHandle) -> bool { - phase(app) == ShutdownPhase::ExitAllowed -} - -#[cfg(windows)] -fn is_confirmed_windows_session_end(message: u32, wparam: usize) -> bool { - use windows_sys::Win32::UI::WindowsAndMessaging::WM_ENDSESSION; - - message == WM_ENDSESSION && wparam != 0 -} - #[cfg(windows)] struct WindowsSessionEndContext { app: AppHandle, @@ -345,8 +700,9 @@ unsafe extern "system" fn windows_session_end_proc( reference_data: usize, ) -> windows_sys::Win32::Foundation::LRESULT { use windows_sys::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass}; - use windows_sys::Win32::UI::WindowsAndMessaging::{WM_NCDESTROY, WM_QUERYENDSESSION}; - + use windows_sys::Win32::UI::WindowsAndMessaging::{ + WM_ENDSESSION, WM_NCDESTROY, WM_QUERYENDSESSION, + }; if message == WM_NCDESTROY { RemoveWindowSubclass(hwnd, Some(windows_session_end_proc), subclass_id); let result = DefSubclassProc(hwnd, message, wparam, lparam); @@ -355,26 +711,30 @@ unsafe extern "system" fn windows_session_end_proc( )); return result; } - if message == WM_QUERYENDSESSION { + // Windows requires this message to return promptly. Start the bounded state-flush + // worker now, but leave CLI cleanup and lock release for WM_ENDSESSION. + let context = &*(reference_data as *const WindowsSessionEndContext); + prepare_windows_session_end(&context.app); return 1; } - - if is_confirmed_windows_session_end(message, wparam) { + if message == WM_ENDSESSION { let context = &*(reference_data as *const WindowsSessionEndContext); - request_windows_session_end(context.app.clone()); + if wparam != 0 { + request_windows_session_end(context.app.clone()); + } else { + cancel_windows_session_end(&context.app); + } return 0; } - DefSubclassProc(hwnd, message, wparam, lparam) } #[cfg(windows)] pub(crate) fn install_windows_session_end_handler(window: &WebviewWindow) -> Result<(), String> { use windows_sys::Win32::UI::Shell::SetWindowSubclass; - const SUBCLASS_ID: usize = 0x434E_5345; - let hwnd = window.hwnd().map_err(|err| err.to_string())?; + let hwnd = window.hwnd().map_err(|error| error.to_string())?; let context = Box::into_raw(Box::new(WindowsSessionEndContext { app: window.app_handle().clone(), })); @@ -398,214 +758,13 @@ pub(crate) fn schedule_windows_session_end_handler(window: &WebviewWindow) -> Re let window = window.clone(); let app = window.app_handle().clone(); app.run_on_main_thread(move || { - if let Err(err) = install_windows_session_end_handler(&window) { - eprintln!("[client-state] failed to install Windows session-end handler: {err}"); + if let Err(error) = install_windows_session_end_handler(&window) { + eprintln!("[client-state] failed to install Windows session-end handler: {error}"); } }) - .map_err(|err| err.to_string()) + .map_err(|error| error.to_string()) } #[cfg(test)] -mod tests { - use super::*; - use std::sync::{mpsc, Arc}; - - #[test] - fn close_only_flushes_and_closes_without_cleanup() { - let waiting = transition(ShutdownPhase::Idle, ShutdownEvent::BeginMainWindowClose); - assert_eq!( - waiting, - ( - ShutdownPhase::WaitingForMainWindowRenderer, - ShutdownAction::RequestRendererFlush - ) - ); - let flushing = transition(waiting.0, ShutdownEvent::RendererFlushed(1)); - assert_eq!( - flushing, - ( - ShutdownPhase::FlushingMainWindow, - ShutdownAction::FlushMainWindow - ) - ); - assert_eq!( - transition(flushing.0, ShutdownEvent::MainWindowFlushed), - ( - ShutdownPhase::MainWindowCloseAllowed, - ShutdownAction::CloseMainWindow - ) - ); - } - - #[test] - fn shutdown_promotes_each_close_only_phase() { - for (phase, event, expected) in [ - ( - ShutdownPhase::WaitingForMainWindowRenderer, - ShutdownEvent::BeginShutdown, - ( - ShutdownPhase::WaitingForShutdownRenderer, - ShutdownAction::None, - ), - ), - ( - ShutdownPhase::FlushingMainWindow, - ShutdownEvent::BeginShutdown, - ( - ShutdownPhase::FlushingMainWindowForShutdown, - ShutdownAction::None, - ), - ), - ( - ShutdownPhase::FlushingMainWindowForShutdown, - ShutdownEvent::MainWindowFlushed, - ( - ShutdownPhase::CleanupInProgress, - ShutdownAction::StartCleanup, - ), - ), - ( - ShutdownPhase::MainWindowCloseAllowed, - ShutdownEvent::BeginShutdown, - ( - ShutdownPhase::CleanupInProgress, - ShutdownAction::StartCleanup, - ), - ), - ] { - assert_eq!(transition(phase, event), expected); - } - } - - #[test] - fn stale_renderer_completion_cannot_advance_a_later_close() { - let coordinator = ShutdownCoordinator::default(); - let (_, first) = coordinator.apply(ShutdownEvent::BeginMainWindowClose); - assert_eq!(first, Some(1)); - coordinator.apply(ShutdownEvent::RendererFlushed(1)); - coordinator.apply(ShutdownEvent::MainWindowFlushed); - coordinator.apply(ShutdownEvent::MainWindowDestroyed); - let (_, second) = coordinator.apply(ShutdownEvent::BeginMainWindowClose); - assert_eq!(second, Some(2)); - for stale in [ - ShutdownEvent::RendererFlushed(1), - ShutdownEvent::RendererTimeout(1), - ] { - assert_eq!(coordinator.apply(stale), (ShutdownAction::None, None)); - assert_eq!( - coordinator.phase(), - ShutdownPhase::WaitingForMainWindowRenderer - ); - } - assert_eq!( - coordinator.apply(ShutdownEvent::RendererTimeout(2)).0, - ShutdownAction::FlushMainWindow - ); - } - - #[test] - fn cleanup_completion_permanently_allows_exit() { - let completed = transition( - ShutdownPhase::CleanupInProgress, - ShutdownEvent::CleanupFinished, - ); - assert_eq!( - completed, - (ShutdownPhase::ExitAllowed, ShutdownAction::None) - ); - assert_eq!( - transition(completed.0, ShutdownEvent::BeginShutdown), - (ShutdownPhase::ExitAllowed, ShutdownAction::None) - ); - } - - #[test] - fn failed_cleanup_stays_alive_and_accepts_a_later_retry() { - assert_eq!( - transition( - ShutdownPhase::CleanupInProgress, - ShutdownEvent::CleanupFailed - ), - (ShutdownPhase::CleanupBlocked, ShutdownAction::None) - ); - assert_eq!( - transition(ShutdownPhase::CleanupBlocked, ShutdownEvent::BeginShutdown), - ( - ShutdownPhase::CleanupInProgress, - ShutdownAction::StartCleanup - ) - ); - } - - #[test] - fn shutdown_stop_retries_are_bounded() { - let mut attempts = 0; - retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - attempts += 1; - (attempts == SHUTDOWN_STOP_ATTEMPTS).then_some(()).ok_or(()) - }) - .unwrap(); - assert_eq!(attempts, SHUTDOWN_STOP_ATTEMPTS); - - let mut failures = 0; - assert!(retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - failures += 1; - Err::<(), _>("unconfirmed") - }) - .is_err()); - assert_eq!(failures, SHUTDOWN_STOP_ATTEMPTS); - } - - #[test] - fn shutdown_waits_for_the_final_navigation_invocation() { - let coordinator = Arc::new(ShutdownCoordinator::default()); - let navigating = Arc::clone(&coordinator); - let (started_tx, started_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let navigation = std::thread::spawn(move || { - navigating.with_navigation(|| { - started_tx.send(()).unwrap(); - release_rx.recv().unwrap(); - }) - }); - started_rx.recv().unwrap(); - let shutting_down = Arc::clone(&coordinator); - let (finished_tx, finished_rx) = mpsc::channel(); - let shutdown = std::thread::spawn(move || { - shutting_down.apply(ShutdownEvent::BeginShutdown); - finished_tx.send(()).unwrap(); - }); - assert!(finished_rx.recv_timeout(Duration::from_millis(20)).is_err()); - release_tx.send(()).unwrap(); - navigation.join().unwrap(); - finished_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - shutdown.join().unwrap(); - assert_eq!( - coordinator.phase(), - ShutdownPhase::WaitingForShutdownRenderer - ); - assert!(coordinator.with_navigation(|| ()).is_none()); - } - - #[cfg(windows)] - #[test] - fn windows_session_end_is_bounded_and_starts_once() { - let coordinator = ShutdownCoordinator::default(); - assert!(coordinator.begin_windows_session_end()); - assert!(!coordinator.begin_windows_session_end()); - assert!(WINDOWS_SESSION_END_TIMEOUT <= Duration::from_secs(5)); - } - - #[cfg(windows)] - #[test] - fn only_confirmed_end_session_starts_shutdown() { - use windows_sys::Win32::UI::WindowsAndMessaging::{ - WM_CLOSE, WM_ENDSESSION, WM_QUERYENDSESSION, - }; - - assert!(!is_confirmed_windows_session_end(WM_QUERYENDSESSION, 0)); - assert!(is_confirmed_windows_session_end(WM_ENDSESSION, 1)); - assert!(!is_confirmed_windows_session_end(WM_ENDSESSION, 0)); - assert!(!is_confirmed_windows_session_end(WM_CLOSE, 0)); - } -} +#[path = "shutdown_tests.rs"] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/shutdown_tests.rs b/packages/tauri-app/src-tauri/src/shutdown_tests.rs new file mode 100644 index 000000000..f3af1d0eb --- /dev/null +++ b/packages/tauri-app/src-tauri/src/shutdown_tests.rs @@ -0,0 +1,259 @@ +use super::*; +use std::sync::Arc; + +#[test] +fn close_generations_are_label_isolated_and_stale_ack_is_ignored() { + let coordinator = ShutdownCoordinator::default(); + let first = coordinator + .begin_local_close("local-a".into(), "a".into(), true) + .unwrap(); + let second = coordinator + .begin_local_close("local-b".into(), "b".into(), true) + .unwrap(); + assert!(coordinator + .acknowledge_local("local-a", "a", second) + .is_none()); + assert!(coordinator + .acknowledge_local("local-a", "a", first) + .is_some()); + assert!(coordinator + .acknowledge_local("local-b", "b", first) + .is_none()); + assert!(coordinator + .acknowledge_local("local-b", "b", second) + .is_some()); +} + +#[test] +fn final_shutdown_and_cleanup_start_once() { + let coordinator = ShutdownCoordinator::default(); + let requests = coordinator + .begin_shutdown(["local-a".to_string(), "local-b".to_string()]) + .unwrap(); + assert!(coordinator.begin_shutdown(std::iter::empty()).is_none()); + assert!(!coordinator.acknowledge_global(&requests[0].0, requests[0].1)); + assert!(coordinator.acknowledge_global(&requests[1].0, requests[1].1)); + assert!(coordinator.begin_cleanup(false)); + assert!(!coordinator.begin_cleanup(false)); + coordinator.cleanup_failed(); + assert!(!coordinator.shutdown_started()); + assert!(coordinator.with_navigation_authority(|| ()).is_some()); + assert!(coordinator.begin_shutdown(std::iter::empty()).is_some()); + assert!(coordinator.begin_cleanup(false)); +} + +#[test] +fn failed_shutdown_paths_emit_the_renderer_resume_event() { + let source = include_str!("shutdown.rs"); + assert_eq!(FLUSH_CANCELLED_EVENT, "client-state:flush-cancelled"); + assert!(source.contains("RendererFlushRequest { generation }")); + assert!(source.contains("emit_flush_cancellations(&app, cancellations)")); +} + +#[test] +fn global_shutdown_wins_an_uncommitted_local_close() { + let coordinator = ShutdownCoordinator::default(); + let generation = coordinator + .begin_local_close("local-a".into(), "a".into(), true) + .unwrap(); + let pending = coordinator + .acknowledge_local("local-a", "a", generation) + .unwrap(); + coordinator.begin_shutdown(["local-a".to_string()]).unwrap(); + assert!(!coordinator.commit_local_close("local-a".into(), pending)); +} + +#[test] +fn committed_local_close_retains_exact_authority_until_consumed() { + let coordinator = ShutdownCoordinator::default(); + let generation = coordinator + .begin_local_close("local-a".into(), "window-a".into(), true) + .unwrap(); + let pending = coordinator + .acknowledge_local("local-a", "window-a", generation) + .unwrap(); + + assert!(coordinator.commit_local_close("local-a".into(), pending.clone())); + assert_eq!( + coordinator.take_committed_local_close("local-a"), + Some(pending) + ); + assert!(coordinator.take_committed_local_close("local-a").is_none()); +} + +#[test] +fn local_close_dispatch_rollback_reopens_close_authority() { + let coordinator = ShutdownCoordinator::default(); + let generation = coordinator + .begin_local_close("local-a".into(), "window-a".into(), true) + .unwrap(); + let pending = coordinator + .acknowledge_local("local-a", "window-a", generation) + .unwrap(); + assert!(coordinator.commit_local_close("local-a".into(), pending)); + + coordinator.rollback_local_close("local-a"); + + assert!(coordinator.take_committed_local_close("local-a").is_none()); + assert!(coordinator + .begin_local_close("local-a".into(), "window-a".into(), true) + .is_some()); +} + +#[test] +fn navigation_authority_blocks_shutdown_start_until_dispatch_returns() { + let coordinator = Arc::new(ShutdownCoordinator::default()); + let guarded = Arc::clone(&coordinator); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let navigation = std::thread::spawn(move || { + guarded.with_navigation_authority(|| { + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }) + }); + entered_rx.recv().unwrap(); + + let shutting_down = Arc::clone(&coordinator); + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel(); + let shutdown = std::thread::spawn(move || { + let started = shutting_down.begin_shutdown(std::iter::empty()).is_some(); + shutdown_tx.send(started).unwrap(); + }); + assert!(shutdown_rx.recv_timeout(Duration::from_millis(20)).is_err()); + + release_tx.send(()).unwrap(); + assert!(navigation.join().unwrap().is_some()); + assert!(shutdown_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + shutdown.join().unwrap(); +} + +#[test] +fn shutdown_start_rejects_navigation_dispatch() { + let coordinator = ShutdownCoordinator::default(); + coordinator.begin_shutdown(std::iter::empty()).unwrap(); + let dispatched = std::sync::atomic::AtomicBool::new(false); + + assert!(coordinator + .with_navigation_authority(|| dispatched.store(true, std::sync::atomic::Ordering::SeqCst)) + .is_none()); + assert!(!dispatched.load(std::sync::atomic::Ordering::SeqCst)); +} + +#[test] +fn bounded_retry_stops_after_success() { + let mut calls = 0; + retry_bounded(2, || { + calls += 1; + if calls == 1 { + Err("retry") + } else { + Ok(()) + } + }) + .unwrap(); + assert_eq!(calls, 2); +} + +#[test] +fn failed_global_shutdown_cancels_the_exact_renderer_generations() { + let coordinator = ShutdownCoordinator::default(); + let mut requests = coordinator + .begin_shutdown(["local-a".to_string(), "local-b".to_string()]) + .unwrap(); + for (label, generation) in &requests { + coordinator.acknowledge_global(label, *generation); + } + assert!(coordinator.begin_cleanup(false)); + + let mut cancellations = coordinator.cleanup_failed(); + requests.sort(); + cancellations.sort(); + assert_eq!(cancellations, requests); +} + +#[cfg(windows)] +#[test] +fn windows_session_end_defers_native_cleanup_until_renderer_flush_finishes() { + let coordinator = ShutdownCoordinator::default(); + let preparation = coordinator.begin_windows_session_end(["local-a".to_string()]); + let generation = preparation.generation; + let requests = preparation.requests.unwrap(); + + assert!(!coordinator.begin_cleanup(false)); + assert!(coordinator.windows_renderer_wait(generation).unwrap().0); + assert!(coordinator.acknowledge_global(&requests[0].0, requests[0].1)); + assert!(!coordinator.windows_renderer_wait(generation).unwrap().0); + assert!(!coordinator.windows_native_flush_complete(generation)); + coordinator.complete_windows_native_flush(generation); + assert!(coordinator.windows_native_flush_complete(generation)); + assert!(coordinator.begin_windows_cleanup(generation)); + assert!(!coordinator.begin_windows_cleanup(generation)); +} + +#[cfg(windows)] +#[test] +fn cancelled_windows_session_end_reopens_renderer_persistence() { + let coordinator = ShutdownCoordinator::default(); + coordinator.begin_windows_session_end(["local-a".to_string()]); + + assert_eq!(coordinator.cancel_windows_session_end().len(), 1); + assert!(!coordinator.shutdown_started()); + assert!(coordinator.with_navigation_authority(|| ()).is_some()); + assert!(coordinator.begin_shutdown(std::iter::empty()).is_some()); +} + +#[cfg(windows)] +#[test] +fn multi_window_session_end_prepare_reuses_generation_deadline_and_requests() { + let coordinator = ShutdownCoordinator::default(); + let first = + coordinator.begin_windows_session_end(["local-a".to_string(), "local-b".to_string()]); + let second = coordinator.begin_windows_session_end(["local-c".to_string()]); + + assert_eq!(first.requests.as_ref().unwrap().len(), 2); + assert_eq!(second.generation, first.generation); + assert_eq!(second.deadline, first.deadline); + assert!(second.requests.is_none()); +} + +#[cfg(windows)] +#[test] +fn repeated_session_end_calls_share_timeout_and_cleanup_claim() { + let coordinator = ShutdownCoordinator::default(); + let preparation = coordinator.begin_windows_session_end(std::iter::empty()); + let generation = preparation.generation; + + assert_eq!( + coordinator.windows_session_end_remaining(generation, preparation.deadline), + Some(Duration::ZERO) + ); + coordinator.complete_windows_native_flush(generation); + assert!(coordinator.begin_windows_cleanup(generation)); + assert!(!coordinator.begin_windows_cleanup(generation)); + assert_eq!( + coordinator.windows_session_end_remaining( + generation, + preparation.deadline + Duration::from_secs(1) + ), + Some(Duration::ZERO) + ); +} + +#[cfg(windows)] +#[test] +fn cancelled_session_worker_cannot_complete_a_new_session_flush() { + let coordinator = ShutdownCoordinator::default(); + let first = coordinator + .begin_windows_session_end(["local-a".to_string()]) + .generation; + coordinator.cancel_windows_session_end(); + let second = coordinator + .begin_windows_session_end(["local-a".to_string()]) + .generation; + + coordinator.complete_windows_native_flush(first); + assert!(!coordinator.windows_native_flush_complete(second)); + coordinator.complete_windows_native_flush(second); + assert!(coordinator.windows_native_flush_complete(second)); +} diff --git a/packages/tauri-app/src-tauri/src/windows_update.rs b/packages/tauri-app/src-tauri/src/windows_update.rs index 7d4ce2cb8..6d96c88b8 100644 --- a/packages/tauri-app/src-tauri/src/windows_update.rs +++ b/packages/tauri-app/src-tauri/src/windows_update.rs @@ -4,7 +4,15 @@ use std::process::Command; use std::os::windows::process::CommandExt; #[tauri::command] -pub async fn install_stable_update() -> Result<(), String> { +pub async fn install_stable_update( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::AppState>, +) -> Result<(), String> { + crate::require_local_app_window(&window, &state)?; + install_stable_update_impl().await +} + +pub(crate) async fn install_stable_update_impl() -> Result<(), String> { tauri::async_runtime::spawn_blocking(|| { let mut command = Command::new("winget"); command.args([ diff --git a/packages/tauri-app/src-tauri/src/workspace_open.rs b/packages/tauri-app/src-tauri/src/workspace_open.rs index 76cbded33..d2e720f68 100644 --- a/packages/tauri-app/src-tauri/src/workspace_open.rs +++ b/packages/tauri-app/src-tauri/src/workspace_open.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::Duration; use tauri::State; #[derive(Clone)] @@ -39,17 +40,18 @@ fn get_local_json( path: &str, ) -> Result { let config = manager - .desktop_event_stream_config() + .local_cli_access() .ok_or("Local CodeNomad server is unavailable")?; let url = format!("{}{}", config.base_url.trim_end_matches('/'), path); - let client = reqwest::blocking::Client::new(); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|error| error.to_string())?; let mut request = client.get(url); - if let Some(cookie) = config.session_cookie { - request = request.header( - reqwest::header::COOKIE, - format!("{}={cookie}", config.cookie_name), - ); - } + request = request.header( + reqwest::header::COOKIE, + format!("{}={}", config.cookie_name, config.session_cookie), + ); request .send() .and_then(reqwest::blocking::Response::error_for_status) @@ -572,12 +574,10 @@ pub async fn open_workspace_target( path: Option, editor: Option, ) -> Result<(), String> { - if window.label() != "main" { - return Err("Workspace open requests are limited to the local main window".into()); - } + crate::identity::local_window_id(window.label())?; let config = state .manager - .desktop_event_stream_config() + .local_cli_access() .ok_or("Local CodeNomad server is unavailable")?; let expected = url::Url::parse(&config.base_url).map_err(|error| error.to_string())?; let current = window.url().map_err(|error| error.to_string())?; diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index bed5e90d6..8f7aea20f 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -11,31 +11,8 @@ "app": { "enableGTKAppId": true, "withGlobalTauri": true, - "windows": [ - { - "label": "main", - "title": "CodeNomad", - "url": "loading.html", - "width": 1400, - "height": 900, - "minWidth": 800, - "minHeight": 600, - "center": true, - "resizable": true, - "fullscreen": false, - "decorations": true, - "theme": "Dark", - "backgroundColor": "#1a1a1a", - "zoomHotkeysEnabled": true, - "visible": false - } - ], + "windows": [], "security": { - "assetProtocol": { - "scope": [ - "**" - ] - }, "capabilities": [ "main-window-native-dialogs", "remote-window-notifications" diff --git a/packages/ui/package.json b/packages/ui/package.json index 9a7e6b323..539d761ea 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "^1.17.8", + "@opencode-ai/client": "0.0.0-beta-17595", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", @@ -39,6 +39,7 @@ "yaml": "^2.4.2" }, "devDependencies": { + "@types/debug": "^4.1.13", "@vite-pwa/assets-generator": "^1.0.2", "autoprefixer": "10.4.21", "postcss": "8.5.6", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index eadd9701b..56cba9094 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -5,6 +5,7 @@ import useMediaQuery from "@suid/material/useMediaQuery" import { Minimize2 } from "lucide-solid" import AlertDialog from "./components/alert-dialog" import FolderSelectionView from "./components/folder-selection-view" +import { useDesktopFolderLaunch } from "./lib/hooks/use-electron-folder-launch" import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" @@ -20,7 +21,7 @@ import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppSessionRestore } from "./lib/hooks/use-app-session-restore" import { loadedRestorableSession } from "./stores/client-state" -import { shouldShowAppHomeOverlay, shouldShowEmptyAppHome } from "./stores/app-session-restore-gate" +import { shouldShowAppHomeOverlay, shouldShowAppRestoreLoading } from "./stores/app-session-restore-gate" import { getLogger } from "./lib/logger" import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors" import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors" @@ -43,6 +44,8 @@ import { stopInstance, disconnectedInstance, acknowledgeDisconnectedInstance, + reconcilePendingSessionIndicators, + refreshVolatileInstanceState, syncPendingRequests, } from "./stores/instances" import { @@ -71,6 +74,7 @@ import { activeAppTab, activeAppTabId, appTabs, + closeInstanceTab, ensureActiveAppTab, getAdjacentAppTabId, getAppTabById, @@ -110,8 +114,6 @@ const App: Component = () => { const { preferences, recentFolders, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, @@ -310,6 +312,7 @@ const App: Component = () => { registerInvalidation: (invalidate) => { invalidateSessions = invalidate }, }), syncPendingRequests(id, (invalidate) => { invalidatePendingRequests = invalidate }), + refreshVolatileInstanceState(id), ]), `Foreground refresh for ${id}`, () => { @@ -321,6 +324,7 @@ const App: Component = () => { ) const failedInstanceIds: string[] = [] sessionListResults.forEach((result, i) => { + reconcilePendingSessionIndicators(instanceIds[i]) if (result.status === "rejected") { failedInstanceIds.push(instanceIds[i]) log.error("Foreground refresh: fetchSessions failed", { instanceId: instanceIds[i], error: result.reason }) @@ -386,8 +390,8 @@ const App: Component = () => { const launchErrorPath = () => { const value = launchError()?.binaryPath - if (!value) return "opencode" - return value.trim() || "opencode" + if (!value) return "opencode2" + return value.trim() || "opencode2" } const launchErrorMessage = () => launchError()?.message ?? "" @@ -402,24 +406,24 @@ const App: Component = () => { return recent?.projectName?.trim() || getPathBasename(folderPath) } - async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) { + async function handleSelectFolder(folderPath: string): Promise { if (!folderPath) { - return + return false } - const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" + const selectedBinary = serverSettings().opencodeBinary || "opencode2" const projectName = getProjectNameForFolder(folderPath) clearLaunchError() setIsSelectingFolder(true) try { - const result = await createInstance(folderPath, selectedBinary, projectName, { forceNew: options?.forceNew }) - recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath) + const result = await createInstance(folderPath, projectName) + recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, folderPath) if (result.reused) { selectInstanceTab(result.instanceId) setShowFolderSelection(false) log.info("Selected reused instance", { instanceId: result.instanceId, folderPath }) - return + return true } selectInstanceTab(result.instanceId) @@ -429,6 +433,7 @@ const App: Component = () => { instanceId: result.instanceId, port: instances().get(result.instanceId)?.port, }) + return true } catch (error) { const message = formatLaunchErrorMessage( error, @@ -438,15 +443,18 @@ const App: Component = () => { const missingBinary = isMissingBinaryMessage(message) showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) log.error("Failed to create instance", error) + return false } finally { setIsSelectingFolder(false) } } - function handleSelectExistingInstance(instanceId: string, recentPath: string, binaryPath: string) { + useDesktopFolderLaunch(handleSelectFolder) + + function handleSelectExistingInstance(instanceId: string, recentPath: string) { const instance = instances().get(instanceId) if (!instance) return - recordWorkspaceLaunch(instance.folder, binaryPath, recentPath) + recordWorkspaceLaunch(instance.folder, recentPath) selectInstanceTab(instanceId) setShowFolderSelection(false) log.info("Selected existing instance", { instanceId, folderPath: instance.folder }) @@ -487,14 +495,16 @@ const App: Component = () => { } async function handleDisconnectedInstanceClose() { + const instanceId = disconnectedInstance()?.id try { await acknowledgeDisconnectedInstance() + if (instanceId) closeInstanceTab(instanceId) } catch (error) { log.error("Failed to finalize disconnected instance", error) } } - async function handleCloseInstance(instanceId: string) { + async function handleStopInstance(instanceId: string) { const confirmed = await showConfirmDialog( t("app.stopInstance.confirmMessage"), { @@ -507,7 +517,7 @@ const App: Component = () => { if (!confirmed) return - stopInstance(instanceId) + await stopInstance(instanceId) } async function handleNewSession(instanceId: string) { @@ -547,7 +557,7 @@ const App: Component = () => { const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId() if (tab.kind === "instance") { - await handleCloseInstance(tab.instance.id) + closeInstanceTab(tab.instance.id) } else { closeSidecarTab(tab.sidecarTab.token) } @@ -573,8 +583,6 @@ const App: Component = () => { const { commands: paletteCommands, executeCommand } = useCommands({ preferences, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, @@ -590,7 +598,7 @@ const App: Component = () => { setToolInputsVisibility, handleNewInstanceRequest, handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), - handleCloseInstance, + handleStopInstance, handleNewSession, handleCloseSession, getActiveInstance: activeInstance, @@ -601,7 +609,6 @@ const App: Component = () => { setEscapeInDebounce, handleNewInstanceRequest, handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), - handleCloseInstance, handleNewSession, handleCloseSession, showFolderSelection, @@ -777,14 +784,12 @@ const App: Component = () => { } > - - - + diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index be3616d35..58f5a5fc0 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -2,7 +2,7 @@ import { Select } from "@kobalte/core/select" import { Show, createEffect, createMemo } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" -import { getSelectableAgentsForSession, type Agent } from "../types/session" +import { findAgentById, getSelectableAgentsForSession, type Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -31,14 +31,7 @@ export default function AgentSelector(props: AgentSelectorProps) { const availableAgents = createMemo(() => { return getSelectableAgentsForSession(instanceAgents(), props.currentAgent, isChildSession()) }) - - createEffect(() => { - const list = availableAgents() - if (list.length === 0) return - if (!list.some((agent) => agent.name === props.currentAgent)) { - void props.onAgentChange(list[0].name) - } - }) + const selectedAgent = createMemo(() => findAgentById(availableAgents(), props.currentAgent)) createEffect(() => { if (instanceAgents().length === 0) { @@ -47,18 +40,18 @@ export default function AgentSelector(props: AgentSelectorProps) { }) const handleChange = async (value: Agent | null) => { - if (value && value.name !== props.currentAgent) { - await props.onAgentChange(value.name) + if (value && value.id !== props.currentAgent) { + await props.onAgentChange(value.id) } } return ( + ) + }} + + {(message) => } +
+ + +
+ + ) +} + +export default FormRequest diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index 15e3d17b1..d9f448c8c 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -7,7 +7,7 @@ import { getLogger } from "../lib/logger" const log = getLogger("session") -type ServiceSection = "lsp" | "mcp" | "plugins" +type ServiceSection = "mcp" | "plugins" interface InstanceServiceStatusProps { sections?: ServiceSection[] @@ -23,23 +23,19 @@ type ParsedMcpStatus = { } function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] { - if (!status || typeof status !== "object") return [] - const result: ParsedMcpStatus[] = [] - for (const [name, value] of Object.entries(status)) { - if (!value || typeof value !== "object") continue - const rawStatus = (value as { status?: string }).status - if (!rawStatus) continue + if (!status) return [] + return status.data.map((server) => { + const rawStatus = server.status.status let mapped: ParsedMcpStatus["status"] if (rawStatus === "connected") mapped = "running" else if (rawStatus === "failed") mapped = "error" else mapped = "stopped" - result.push({ - name, + return { + name: server.name, status: mapped, - error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined, - }) - } - return result + error: server.status.status === "failed" ? server.status.error : undefined, + } + }) } const InstanceServiceStatus: Component = (props) => { @@ -53,23 +49,19 @@ const InstanceServiceStatus: Component = (props) => }) const isLoading = metadataContext?.isLoading ?? (() => false) const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve()) - const sections = createMemo(() => props.sections ?? ["lsp", "mcp", "plugins"]) - const includeLsp = createMemo(() => sections().includes("lsp")) + const sections = createMemo(() => props.sections ?? ["mcp", "plugins"]) const includeMcp = createMemo(() => sections().includes("mcp")) const includePlugins = createMemo(() => sections().includes("plugins")) const showHeadings = () => props.showSectionHeadings !== false const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata) const metadata = createMemo(() => metadataAccessor()) - const hasLspMetadata = () => metadata()?.lspStatus !== undefined const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined const hasPluginsMetadata = () => metadata()?.plugins !== undefined - const lspServers = createMemo(() => metadata()?.lspStatus ?? []) const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined)) const plugins = createMemo(() => metadata()?.plugins ?? []) - const isLspLoading = () => isLoading() || !hasLspMetadata() const isMcpLoading = () => isLoading() || !hasMcpMetadata() const isPluginsLoading = () => isLoading() || !hasPluginsMetadata() @@ -91,10 +83,14 @@ const InstanceServiceStatus: Component = (props) => const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect" setPendingMcpAction(serverName, action) try { + const resolved = metadata()?.mcpStatus?.location + const location = resolved + ? { directory: resolved.directory, ...(resolved.workspaceID ? { workspace: resolved.workspaceID } : {}) } + : { directory: instance().folder } if (shouldEnable) { - await client.mcp.connect({ name: serverName }) + await client.mcp.connect({ server: serverName, location }) } else { - await client.mcp.disconnect({ name: serverName }) + await client.mcp.disconnect({ server: serverName, location }) } await refreshMetadata() } catch (error) { @@ -110,45 +106,6 @@ const InstanceServiceStatus: Component = (props) =>

) - const renderLspSection = () => ( -
- -
- {t("instanceServiceStatus.sections.lsp")} -
-
- 0} - fallback={renderEmptyState(isLspLoading() ? t("instanceServiceStatus.lsp.loading") : t("instanceServiceStatus.lsp.empty"))} - > -
- - {(server) => ( -
-
-
- {server.name ?? server.id} - - {server.root} - -
-
-
- - {server.status === "connected" - ? t("instanceServiceStatus.lsp.status.connected") - : t("instanceServiceStatus.lsp.status.error")} - -
-
-
- )} - -
- -
- ) - const renderMcpSection = () => (
@@ -250,7 +207,6 @@ const InstanceServiceStatus: Component = (props) => return (
- {renderLspSection()} {renderMcpSection()} {renderPluginsSection()}
diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 004d85d1e..d1ad5d7fc 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -17,7 +17,6 @@ import Toolbar from "@suid/material/Toolbar" import useMediaQuery from "@suid/material/useMediaQuery" import type { Instance } from "../../types/instance" import type { Command } from "../../lib/commands" -import type { BackgroundProcess } from "../../../../server/src/api-types" import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette" @@ -27,6 +26,9 @@ import InfoView from "../info-view" import CommandPalette from "../command-palette" import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" +import { getFormRequestAutoOpenId } from "../form-request-auto-open" +import { shouldRenderFormInFallback } from "../form-request-tool-target" +import { messageStoreBus } from "../../stores/message-v2/bus" import SessionView from "../session/session-view" import MessageSection from "../message-section" import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" @@ -35,12 +37,10 @@ import { formatTokenTotal } from "../../lib/formatters" import ContextMeter from "../context-meter" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" -import { serverApi } from "../../lib/api-client" -import { loadBackgroundProcesses } from "../../stores/background-processes" -import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import PromptInput from "../prompt-input" import { useI18n } from "../../lib/i18n" -import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" +import { activeInterruption, getPermissionQueueLength } from "../../stores/instances" +import { getFormQueue } from "../../stores/forms" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" @@ -119,9 +119,8 @@ const InstanceShell2: Component = (props) => { const [sessionCenterEl, setSessionCenterEl] = createSignal(null) const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal("wide") - const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) - const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) + let lastAutoOpenedFormId: string | null = null const [now, setNow] = createSignal(Date.now()) const [sessionPromptApis, setSessionPromptApis] = createSignal>({}) const [draftAgent, setDraftAgent] = createSignal("") @@ -141,12 +140,27 @@ const InstanceShell2: Component = (props) => { activeSessionForInstance, latestTodoState, tokenStats, - backgroundProcessList, handleSessionSelect, } = useInstanceSessionContext({ instanceId: () => props.instance.id, }) + createEffect(() => { + const active = activeInterruption().get(props.instance.id) + const form = active?.kind === "form" + ? getFormQueue(props.instance.id).find((entry) => entry.id === active.id) + : undefined + if (form && !shouldRenderFormInFallback(form, activeSessionIdForInstance(), messageStoreBus.getOrCreate(props.instance.id))) { + lastAutoOpenedFormId = form.id + return + } + + const formId = getFormRequestAutoOpenId(active, lastAutoOpenedFormId) + if (!formId) return + lastAutoOpenedFormId = formId + setPermissionModalOpen(true) + }) + const desktopQuery = useMediaQuery("(min-width: 1280px)") const tabletQuery = useMediaQuery("(min-width: 768px)") @@ -247,13 +261,6 @@ const InstanceShell2: Component = (props) => { onCleanup(() => document.removeEventListener("pointerdown", handleFloatingDrawerPointerDown, true)) }) - createEffect(() => { - const instanceId = props.instance.id - loadBackgroundProcesses(instanceId).catch((error) => { - log.warn("Failed to load background processes", error) - }) - }) - onMount(() => { if (typeof window === "undefined") return @@ -345,8 +352,7 @@ const InstanceShell2: Component = (props) => { const hasPendingRequests = createMemo(() => { const permissions = getPermissionQueueLength(props.instance.id) - const questions = getQuestionQueueLength(props.instance.id) - return permissions + questions > 0 + return permissions + getFormQueue(props.instance.id).length > 0 }) const activePromptInputApi = createMemo(() => { @@ -383,12 +389,6 @@ const InstanceShell2: Component = (props) => { await openSessionPreview(sessionId, normalized) } - function handleShowPreview() { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") return - showSessionPreview(sessionId) - } - function handlePreviewButtonClick() { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return @@ -425,7 +425,7 @@ const InstanceShell2: Component = (props) => { const activeSession = activeSessionForInstance() const needsPermission = Boolean(activeSession?.pendingPermission) - const needsQuestion = Boolean(activeSession?.pendingQuestion) + const needsQuestion = Boolean(activeSession?.pendingForm) const needsInput = needsPermission || needsQuestion if (needsInput) { @@ -561,32 +561,6 @@ const InstanceShell2: Component = (props) => { ] }) - const openBackgroundOutput = (process: BackgroundProcess) => { - setSelectedBackgroundProcess(process) - setShowBackgroundOutput(true) - } - - const closeBackgroundOutput = () => { - setShowBackgroundOutput(false) - setSelectedBackgroundProcess(null) - } - - const stopBackgroundProcess = async (processId: string) => { - try { - await serverApi.stopBackgroundProcess(props.instance.id, processId) - } catch (error) { - log.warn("Failed to stop background process", error) - } - } - - const terminateBackgroundProcess = async (processId: string) => { - try { - await serverApi.terminateBackgroundProcess(props.instance.id, processId) - } catch (error) { - log.warn("Failed to terminate background process", error) - } - } - const instancePaletteCommands = createMemo(() => props.paletteCommands()) const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) @@ -781,10 +755,6 @@ const InstanceShell2: Component = (props) => { activeSessionId={activeSessionIdForInstance} activeSession={activeSessionForInstance} latestTodoState={latestTodoState} - backgroundProcessList={backgroundProcessList} - onOpenBackgroundOutput={openBackgroundOutput} - onStopBackgroundProcess={stopBackgroundProcess} - onTerminateBackgroundProcess={terminateBackgroundProcess} isPhoneLayout={isPhoneLayout} rightDrawerWidth={rightDrawerWidth} rightDrawerWidthInitialized={rightDrawerWidthInitialized} @@ -849,10 +819,6 @@ const InstanceShell2: Component = (props) => { activeSessionId={activeSessionIdForInstance} activeSession={activeSessionForInstance} latestTodoState={latestTodoState} - backgroundProcessList={backgroundProcessList} - onOpenBackgroundOutput={openBackgroundOutput} - onStopBackgroundProcess={stopBackgroundProcess} - onTerminateBackgroundProcess={terminateBackgroundProcess} isPhoneLayout={isPhoneLayout} rightDrawerWidth={rightDrawerWidth} rightDrawerWidthInitialized={rightDrawerWidthInitialized} @@ -1360,13 +1326,6 @@ const InstanceShell2: Component = (props) => { onExecute={props.onExecuteCommand} /> - - latestTodoState: Accessor - backgroundProcessList: Accessor - onOpenBackgroundOutput: (process: BackgroundProcess) => void - onStopBackgroundProcess: (processId: string) => Promise | void - onTerminateBackgroundProcess: (processId: string) => Promise | void isPhoneLayout: Accessor rightDrawerWidth: Accessor @@ -185,10 +180,6 @@ const RightPanel: Component = (props) => { activeSessionId: props.activeSessionId, activeSession: props.activeSession, latestTodoState: props.latestTodoState, - backgroundProcessList: props.backgroundProcessList, - onOpenBackgroundOutput: props.onOpenBackgroundOutput, - onStopBackgroundProcess: props.onStopBackgroundProcess, - onTerminateBackgroundProcess: props.onTerminateBackgroundProcess, isPhoneLayout: props.isPhoneLayout, rightDrawerWidth: props.rightDrawerWidth, rightDrawerWidthInitialized: props.rightDrawerWidthInitialized, diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index 20fd18c77..5be5b7b10 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -16,7 +16,6 @@ interface CoreStatusSectionRenderers { renderPlanSectionContent: () => JSX.Element renderBackgroundProcesses: () => JSX.Element renderMcpStatus: () => JSX.Element - renderLspStatus: () => JSX.Element renderPluginStatus: () => JSX.Element } @@ -63,7 +62,6 @@ export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRend plan: renderers.renderPlanSectionContent, "background-processes": renderers.renderBackgroundProcesses, mcp: renderers.renderMcpStatus, - lsp: renderers.renderLspStatus, plugins: renderers.renderPluginStatus, } diff --git a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx index c7be8ecce..2408db218 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx @@ -1,8 +1,7 @@ import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk/v2" +import type { ToolState } from "../../../../types/tool-state" import type { Instance } from "../../../../types/instance" -import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" import type { PromptInputApi } from "../../../prompt-input/types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" @@ -44,10 +43,6 @@ interface CoreRightPanelRuntimeOptions { activeSessionId: Accessor activeSession: Accessor latestTodoState: Accessor - backgroundProcessList: Accessor - onOpenBackgroundOutput: (process: BackgroundProcess) => void - onStopBackgroundProcess: (processId: string) => Promise | void - onTerminateBackgroundProcess: (processId: string) => Promise | void isPhoneLayout: Accessor rightDrawerWidth: Accessor rightDrawerWidthInitialized: Accessor @@ -230,10 +225,6 @@ export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOption activeSessionId={options.activeSessionId} activeSession={options.activeSession} latestTodoState={options.latestTodoState} - backgroundProcessList={options.backgroundProcessList} - onOpenBackgroundOutput={options.onOpenBackgroundOutput} - onStopBackgroundProcess={options.onStopBackgroundProcess} - onTerminateBackgroundProcess={options.onTerminateBackgroundProcess} expandedItems={options.expandedItems} onExpandedItemsChange={options.onExpandedItemsChange} customization={options.customization} diff --git a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts new file mode 100644 index 000000000..596d5dc51 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { adaptSdkGitStatusEntries } from "./git-changes-model.ts" + +describe("adaptSdkGitStatusEntries", () => { + it("adapts native V2 status fields and preserves CodeNomad stage details", () => { + assert.deepEqual( + adaptSdkGitStatusEntries( + [{ file: "src\\app.ts", additions: 4, deletions: 2, status: "modified" }], + [{ + path: "src/app.ts", + originalPath: null, + stagedStatus: "modified", + stagedAdditions: 1, + stagedDeletions: 0, + unstagedStatus: "modified", + unstagedAdditions: 3, + unstagedDeletions: 2, + }], + ), + [{ + path: "src/app.ts", + originalPath: null, + additions: 4, + deletions: 2, + status: "modified", + stagedStatus: "modified", + stagedAdditions: 1, + stagedDeletions: 0, + unstagedStatus: "modified", + unstagedAdditions: 3, + unstagedDeletions: 2, + }], + ) + }) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts index a7e248b7f..31db17af9 100644 --- a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts @@ -1,4 +1,4 @@ -import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client" +import type { VcsFileStatus } from "@opencode-ai/client" import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types" import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types" @@ -13,18 +13,18 @@ export function normalizeGitChangeStatus(status: unknown): GitChangeStatus { return typeof status === "string" && status.trim().length > 0 ? status : "modified" } -export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry { +export function adaptSdkGitStatusEntry(entry: VcsFileStatus): GitChangeEntry { return { - path: normalizeGitChangePath(entry?.path), + path: normalizeGitChangePath(entry.file), originalPath: null, - additions: typeof entry?.added === "number" ? entry.added : 0, - deletions: typeof entry?.removed === "number" ? entry.removed : 0, - status: normalizeGitChangeStatus(entry?.status), + additions: entry.additions, + deletions: entry.deletions, + status: normalizeGitChangeStatus(entry.status), } } export function adaptSdkGitStatusEntries( - entries: SdkGitFileStatus[] | null | undefined, + entries: VcsFileStatus[] | null | undefined, details?: WorktreeGitStatusEntry[] | null, ): GitChangeEntry[] { const detailsByPath = new Map( @@ -42,12 +42,12 @@ export function adaptSdkGitStatusEntries( const adapted = adaptSdkGitStatusEntry(entry) if (!adapted.path) continue const detail = detailsByPath.get(adapted.path) - adaptedByPath.set(adapted.path, { - ...adapted, - originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null, - stagedStatus: detail?.stagedStatus ?? null, - unstagedStatus: detail?.unstagedStatus ?? null, - stagedAdditions: detail?.stagedAdditions ?? 0, + adaptedByPath.set(adapted.path, { + ...adapted, + originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null, + stagedStatus: detail?.stagedStatus ?? null, + unstagedStatus: detail?.unstagedStatus ?? null, + stagedAdditions: detail?.stagedAdditions ?? 0, stagedDeletions: detail?.stagedDeletions ?? 0, unstagedAdditions: detail?.unstagedAdditions ?? 0, unstagedDeletions: detail?.unstagedDeletions ?? 0, @@ -57,12 +57,12 @@ export function adaptSdkGitStatusEntries( for (const detail of details ?? []) { const normalizedPath = normalizeGitChangePath(detail.path) if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue - adaptedByPath.set(normalizedPath, { - path: normalizedPath, - originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null, - additions: 0, - deletions: 0, - status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified", + adaptedByPath.set(normalizedPath, { + path: normalizedPath, + originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null, + additions: 0, + deletions: 0, + status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified", stagedStatus: detail.stagedStatus, unstagedStatus: detail.unstagedStatus, stagedAdditions: detail.stagedAdditions, diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts index 7aa0f7f58..2216cc7ac 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -72,7 +72,6 @@ describe("right panel plugin manifests", () => { renderPlanSectionContent: render, renderBackgroundProcesses: render, renderMcpStatus: render, - renderLspStatus: render, renderPluginStatus: render, }) @@ -86,7 +85,6 @@ describe("right panel plugin manifests", () => { "plan", "background-processes", "mcp", - "lsp", "plugins", ]) }) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index b1c96f0e5..ede617772 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -1,5 +1,4 @@ import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js" -import type { FileNode } from "@opencode-ai/sdk/v2/client" import { Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid" @@ -42,11 +41,17 @@ function isMarkdownPath(path: string | null | undefined): boolean { return /\.(md|markdown|mdown|mkdn)$/i.test(path) } +export interface FileBrowserEntry { + name: string + path: string + type: "file" | "directory" +} + interface FilesTabProps { t: (key: string, vars?: Record) => string browserPath: Accessor - browserEntries: Accessor + browserEntries: Accessor browserLoading: Accessor browserError: Accessor @@ -161,7 +166,7 @@ const FilesTab: Component = (props) => { } } - const rowActions = (item: FileNode): ActionOverflowMenuItem[] => { + const rowActions = (item: FileBrowserEntry): ActionOverflowMenuItem[] => { const items: ActionOverflowMenuItem[] = [] if (canOpenWorkspacePaths()) { if (item.type === "directory") { diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 33d1b55d0..74f31e4a3 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -1,5 +1,6 @@ -import { For, Show, createMemo, type Accessor, type Component } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk/v2" +import { For, Show, createEffect, createMemo, createSignal, on, type Accessor, type Component } from "solid-js" +import type { ShellInfo } from "@opencode-ai/client" +import type { ToolState } from "../../../../../types/tool-state" import { DragDropProvider, DragDropSensors, @@ -12,10 +13,9 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { BellRing, ChevronDown, GripVertical, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" +import { ChevronDown, GripVertical, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" -import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { Session } from "../../../../../types/session" import ContextUsagePanel from "../../../../session/context-usage-panel" @@ -26,6 +26,10 @@ import { togglePermissionAutoAcceptForSession } from "../../../../../stores/inst import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" import { createCoreStatusSectionManifest } from "../core-plugin" +import { shellStore } from "../../../../../stores/shells" +import { showConfirmDialog } from "../../../../../stores/alerts" +import { showToastNotification } from "../../../../../lib/notifications" +import { ShellOutputDialog } from "../../../../shell-output-dialog" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -38,11 +42,6 @@ interface StatusTabProps { latestTodoState: Accessor - backgroundProcessList: Accessor - onOpenBackgroundOutput: (process: BackgroundProcess) => void - onStopBackgroundProcess: (processId: string) => Promise | void - onTerminateBackgroundProcess: (processId: string) => Promise | void - expandedItems: Accessor onExpandedItemsChange: (values: string[]) => void customization: Accessor @@ -86,6 +85,14 @@ const SortableStatusSection: Component = (props) => const StatusTab: Component = (props) => { const isSectionExpanded = (id: string) => props.expandedItems().includes(id) + const shellDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) + const shellState = createMemo(() => shellStore.getState(props.instanceId, shellDirectory())) + const [outputShell, setOutputShell] = createSignal(null) + + createEffect(on( + () => [props.instanceId, shellDirectory()] as const, + ([instanceId, directory]) => void shellStore.load(instanceId, directory), + )) const renderYoloModeSection = () => { const session = props.activeSession() @@ -133,89 +140,89 @@ const StatusTab: Component = (props) => { return } - const renderBackgroundProcesses = () => { - const processes = props.backgroundProcessList() - if (processes.length === 0) { - return ( -
- {props.t("instanceShell.backgroundProcesses.empty")} -
- ) - } - - return ( -
- - {(process) => ( -
-
- {process.title} -
- - - - {props.t("instanceShell.backgroundProcesses.status", { status: process.status })} - - - {props.t("instanceShell.backgroundProcesses.output", { - sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024), - })} - - -
-
-
- - - -
-
- )} -
-
+ const removeShell = async (shellId: string, command: string, running: boolean) => { + const confirmed = await showConfirmDialog( + props.t("instanceShell.backgroundProcesses.remove.message", { title: command }), + { + title: props.t("instanceShell.backgroundProcesses.remove.title"), + confirmLabel: props.t(running + ? "instanceShell.backgroundProcesses.actions.stopRemove" + : "instanceShell.backgroundProcesses.actions.remove"), + }, ) + if (confirmed && !await shellStore.remove(props.instanceId, shellDirectory(), shellId)) { + showToastNotification({ message: props.t("instanceShell.backgroundProcesses.error"), variant: "error" }) + } } + const renderBackgroundProcesses = () => ( + {props.t("instanceShell.backgroundProcesses.error")}} + > + 0} + fallback={
{props.t("instanceShell.backgroundProcesses.loading")}
} + > + 0} + fallback={
{props.t("instanceShell.backgroundProcesses.empty")}
} + > +
+ + {(shell) => { + const running = () => shell.status === "running" + return ( +
+
+
+

{shell.command}

+ {shell.shell} +
+
+
+ {props.t(`instanceShell.backgroundProcesses.status.${shell.status}`)} + + {props.t("instanceShell.backgroundProcesses.pid", { pid: shell.pid })} + + + {props.t("instanceShell.backgroundProcesses.exitCode", { code: shell.exit })} + +
+
{shell.cwd}
+
+ + +
+
+ ) + }} +
+
+
+
+
+ ) + const renderProviderUsage = () => { const session = props.activeSession() if (!session) { @@ -235,7 +242,6 @@ const StatusTab: Component = (props) => { renderPlanSectionContent, renderBackgroundProcesses, renderMcpStatus: () => , - renderLspStatus: () => , renderPluginStatus: () => ( ), @@ -293,6 +299,13 @@ const StatusTab: Component = (props) => { + setOutputShell(null)} + /> ) } diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/file-v2-adapters.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/file-v2-adapters.ts new file mode 100644 index 000000000..36ce606e9 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/file-v2-adapters.ts @@ -0,0 +1,15 @@ +import type { FileSystemEntry } from "@opencode-ai/client" +import type { FileBrowserEntry } from "./FilesTab" + +export function adaptFileSystemEntries(entries: FileSystemEntry[]): FileBrowserEntry[] { + return entries.map((entry) => { + const path = entry.path.replace(/\\+/g, "/").replace(/\/+$/, "") + return { ...entry, path, name: path.split("/").pop() || path } + }) +} + +export function decodeFileContent(content: Uint8Array): string { + const text = new TextDecoder("utf-8", { fatal: true }).decode(content) + if (text.includes("\0")) throw new Error("Binary file cannot be displayed") + return text +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.test.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.test.ts new file mode 100644 index 000000000..316478e49 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { adaptFileSystemEntries, decodeFileContent } from "./file-v2-adapters.ts" + +describe("files runtime V2 adapters", () => { + it("adds names to native file system entries", () => { + assert.deepEqual(adaptFileSystemEntries([ + { path: "src/components", type: "directory" }, + { path: "src\\index.ts", type: "file" }, + ]), [ + { path: "src/components", name: "components", type: "directory" }, + { path: "src/index.ts", name: "index.ts", type: "file" }, + ]) + }) + + it("decodes native byte output and rejects binary content", () => { + assert.equal(decodeFileContent(new TextEncoder().encode("hello\n")), "hello\n") + assert.throws(() => decodeFileContent(Uint8Array.of(0xff))) + assert.throws(() => decodeFileContent(Uint8Array.of(0))) + }) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx index 105849987..45fb55070 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -1,14 +1,16 @@ -import { createEffect, createMemo, createSignal, lazy, type Accessor, type JSX } from "solid-js" -import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, createSignal, lazy, onCleanup, type Accessor, type JSX } from "solid-js" import type { DiffWordWrapMode, RightPanelTab } from "../types" +import type { FileBrowserEntry } from "./FilesTab" +import { adaptFileSystemEntries, decodeFileContent } from "./file-v2-adapters" import { getRootClient } from "../../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../../stores/opencode-workspaces" -import { requestData } from "../../../../../lib/opencode-api" +import { instances } from "../../../../../stores/instances" +import { getWorktrees } from "../../../../../stores/worktrees" import { serverApi } from "../../../../../lib/api-client" import { showConfirmDialog } from "../../../../../stores/alerts" import { showToastNotification } from "../../../../../lib/notifications" +import { createDebouncedRefresh, filesystemInvalidationVersion } from "../../../../../lib/filesystem-events" import { writeClientLayoutValue } from "../../../../../stores/client-state" import { RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, @@ -34,7 +36,7 @@ interface FilesTabRuntimeOptions { export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JSX.Element { const [browserPath, setBrowserPath] = createSignal(".") - const [browserEntries, setBrowserEntries] = createSignal(null) + const [browserEntries, setBrowserEntries] = createSignal(null) const [browserLoading, setBrowserLoading] = createSignal(false) const [browserError, setBrowserError] = createSignal(null) const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null) @@ -61,9 +63,12 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS options.isPhoneLayout() ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, ) - const fileWorkspacePayload = async () => { - const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, options.worktreeSlug()) - return workspace ? { workspace } : {} + const fileLocation = () => { + const slug = options.worktreeSlug() + const directory = getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug)?.directory + ?? (slug === "root" ? instances().get(options.instanceId)?.folder : undefined) + if (!directory) throw new Error(`Missing directory for worktree ${slug}`) + return { directory } } createEffect(() => { @@ -117,9 +122,9 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS setBrowserLoading(true) setBrowserError(null) try { - const nodes = await requestData(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list") + const result = await browserClient().file.list({ path: normalized, location: fileLocation() }) setBrowserPath(normalized) - setBrowserEntries(Array.isArray(nodes) ? nodes : []) + setBrowserEntries(adaptFileSystemEntries(result.data)) } catch (error) { setBrowserError(error instanceof Error ? error.message : "Failed to load files") setBrowserEntries([]) @@ -138,13 +143,7 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS if (options.isPhoneLayout()) setFilesListOpen(false) try { - const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") throw new Error("Binary file cannot be displayed") - if (encoding === "base64") throw new Error("Binary file cannot be displayed") - const text = (content as any)?.content - if (typeof text !== "string") throw new Error("Unsupported file type") + const text = decodeFileContent(await browserClient().file.read({ path, location: fileLocation() })) setBrowserSelectedContent(text) setBrowserSelectedOriginalContent(text) } catch (error) { @@ -161,11 +160,7 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS const originalContent = browserSelectedOriginalContent() if (originalContent !== null) { try { - const currentDiskContent = await requestData( - browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), - "file.read", - ) - const diskContent = (currentDiskContent as any)?.content + const diskContent = decodeFileContent(await browserClient().file.read({ path, location: fileLocation() })) if (diskContent !== originalContent && diskContent !== content) { const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.conflict.message", { path }), { variant: "warning", @@ -260,13 +255,7 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS setBrowserSelectedLoading(true) setBrowserSelectedError(null) try { - const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") throw new Error("Binary file cannot be displayed") - if (encoding === "base64") throw new Error("Binary file cannot be displayed") - const text = (content as any)?.content - if (typeof text !== "string") throw new Error("Unsupported file type") + const text = decodeFileContent(await browserClient().file.read({ path: selected, location: fileLocation() })) setBrowserSelectedContent(text) setBrowserSelectedOriginalContent(text) setBrowserSelectedDirty(false) @@ -279,6 +268,21 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS const browserParentPath = createMemo(() => getParentPath(browserPath())) const browserScopeKey = createMemo(() => `${options.instanceId}:${options.worktreeSlug()}`) + let seenFilesystemInvalidation = filesystemInvalidationVersion(options.instanceId) + const filesystemRefresh = createDebouncedRefresh(() => { + void loadBrowserEntries(browserPath()) + const selected = browserSelectedPath() + if (selected && !browserSelectedDirty()) void openBrowserFile(selected) + }) + + createEffect(() => { + const version = filesystemInvalidationVersion(options.instanceId) + if (version === seenFilesystemInvalidation) return + seenFilesystemInvalidation = version + if (options.rightPanelTab() === "files") filesystemRefresh.trigger() + else setBrowserEntries(null) + }) + onCleanup(() => filesystemRefresh.cancel()) return () => ( ) => string @@ -39,9 +38,17 @@ export function useGitChanges(options: UseGitChangesOptions) { let passiveGitRefreshInFlight = false let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null let previousGitChangesActivationKey: string | null = null + let seenFilesystemInvalidation = filesystemInvalidationVersion(options.instanceId) const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries())) + const gitLocation = (slug: string) => { + const directory = getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug)?.directory + ?? (slug === "root" ? instances().get(options.instanceId)?.folder : undefined) + if (!directory) throw new Error(`Missing directory for worktree ${slug}`) + return { directory } + } + const clearGitBulkSelection = () => { setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set())) setGitBulkSelectionAnchorId(null) @@ -168,12 +175,11 @@ export function useGitChanges(options: UseGitChangesOptions) { if (!force && gitStatusEntries() !== null) return const slug = options.worktreeSlug() const client = getRootClient(options.instanceId) - const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, slug) const requestVersion = ++gitStatusRequestVersion setGitStatusLoading(true) setGitStatusError(null) try { - const sdkStatusPromise = requestData(client.file.status({ ...(workspace ? { workspace } : {}) }), "file.status") + const sdkStatusPromise = client.vcs.status({ location: gitLocation(slug) }).then((result) => result.data) const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug) if (requestVersion !== gitStatusRequestVersion) return if (slug !== options.worktreeSlug()) return @@ -424,21 +430,15 @@ export function useGitChanges(options: UseGitChangesOptions) { void passiveRefreshGitStatus() }) + const filesystemRefresh = createDebouncedRefresh(() => void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })) createEffect(() => { - if (options.rightPanelTab() !== "git-changes") return - - const unsubscribe = serverEvents.on("instance.event", (event) => { - if (event.type !== "instance.event") return - if (event.instanceId !== options.instanceId) return - const eventType = (event.event as { type?: unknown } | undefined)?.type - if (eventType !== "session.updated") return - void passiveRefreshGitStatus({ forceReloadSelectedDiff: true }) - }) - - onCleanup(() => { - unsubscribe() - }) + const version = filesystemInvalidationVersion(options.instanceId) + if (version === seenFilesystemInvalidation) return + seenFilesystemInvalidation = version + if (options.rightPanelTab() === "git-changes") filesystemRefresh.trigger() + else setGitStatusEntries(null) }) + onCleanup(() => filesystemRefresh.cancel()) createEffect(() => { if (options.rightPanelTab() === "git-changes") return diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index 3544ac482..8db5f4b32 100644 --- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -1,5 +1,5 @@ import { createMemo, type Accessor } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk/v2" +import type { ToolState } from "../../../types/tool-state" import type { Session } from "../../../types/session" import { activeParentSessionId, @@ -12,7 +12,6 @@ import { setActiveSessionFromList, } from "../../../stores/sessions" import { messageStoreBus } from "../../../stores/message-v2/bus" -import { getBackgroundProcesses } from "../../../stores/background-processes" import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types" type InstanceSessionContextOptions = { @@ -37,9 +36,6 @@ type InstanceSessionContextState = { latestTodoSnapshot: Accessor latestTodoState: Accessor - // Background processes - backgroundProcessList: Accessor> - // Controller handleSessionSelect: (sessionId: string) => void } @@ -103,7 +99,7 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions if (!sessionId || sessionId === "info") return null const store = messageStore() if (!store) return null - const snapshot = store.state.latestTodos[sessionId] + const snapshot = store.getLatestTodoSnapshot(sessionId) return snapshot ?? null }) @@ -122,8 +118,6 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions return state }) - const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId())) - const handleSessionSelect = (sessionId: string) => { const instanceId = options.instanceId() if (sessionId === "info") { @@ -147,7 +141,6 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions tokenStats, latestTodoSnapshot, latestTodoState, - backgroundProcessList, handleSessionSelect, } } diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index cc3691ac5..b0ed5bd3c 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1,5 +1,5 @@ import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js" -import { CheckSquare2, Copy, ExternalLink, FoldVertical, ListStart, Square, Trash, Volume2 } from "lucide-solid" +import { Copy, ExternalLink, FoldVertical, Volume2 } from "lucide-solid" import MessageItem from "./message-item" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { ClientPart, MessageInfo } from "../types/message" @@ -10,10 +10,7 @@ import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions" import { selectInstanceTab } from "../stores/app-tabs" -import { showAlertDialog } from "../stores/alerts" -import { deleteMessage } from "../stores/session-actions" import { useI18n } from "../lib/i18n" -import type { DeleteHoverState } from "../types/delete-hover" import { useSpeech } from "../lib/hooks/use-speech" import { createFollowScroll } from "../lib/follow-scroll" import { inferReasoningDurationMs } from "../lib/message-timing" @@ -22,14 +19,8 @@ import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overfl import { copyToClipboard } from "../lib/clipboard" import SpeechActionButton from "./speech-action-button" import type { VisibilityPreference } from "../stores/preferences" - -function DeleteUpToIcon() { - return ( - - ) -} +import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } from "../types/tool-state" +import { parseReasoningSummary } from "../lib/reasoning-summary" const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" @@ -44,11 +35,6 @@ function ToolCallFallback() { type ToolCallPart = Extract -type ToolState = import("@opencode-ai/sdk/v2").ToolState -type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning -type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted -type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError - function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { return Boolean(state && state.status === "running") } @@ -289,13 +275,8 @@ interface MessageContentItemProps { messageIndex: number lastAssistantIndex: () => number onRevert?: (messageId: string) => void - onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void onContentRendered?: () => void - showDeleteMessage?: boolean - onDeleteHoverChange?: (state: DeleteHoverState) => void - selectedMessageIds?: () => Set - onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function isSupportedPartType(part: unknown): boolean { @@ -391,12 +372,7 @@ function MessageContentItem(props: MessageContentItemProps) { contentStartPartId={props.startPartId} isQueued={isQueued()} showAgentMeta={showAgentMeta()} - showDeleteMessage={props.showDeleteMessage} - onDeleteHoverChange={props.onDeleteHoverChange} - selectedMessageIds={props.selectedMessageIds} - onToggleSelectedMessage={props.onToggleSelectedMessage} onRevert={props.onRevert} - onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onFork={props.onFork} onContentRendered={props.onContentRendered} /> @@ -412,41 +388,10 @@ interface ToolCallItemProps { messageId: string partId: string onContentRendered?: () => void - showDeleteMessage?: boolean - deleteHover?: () => DeleteHoverState - onDeleteHoverChange?: (state: DeleteHoverState) => void - onDeleteMessagesUpTo?: (messageId: string) => void | Promise - selectedMessageIds?: () => Set - selectedToolPartKeys?: () => Set - onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function ToolCallItem(props: ToolCallItemProps) { const { t } = useI18n() - const [deletingMessage, setDeletingMessage] = createSignal(false) - const [deletingUpTo, setDeletingUpTo] = createSignal(false) - - const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) - - const isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`)) - - const isDeleteOverlayActive = () => { - if (isSelectedForDeletion()) return true - if (isSelectedToolPartForDeletion()) return true - const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) - if (hover.kind === "message") { - return hover.messageId === props.messageId - } - if (hover.kind === "deleteUpTo") { - const ids = props.store().getSessionMessageIds(props.sessionId) - const targetIndex = ids.indexOf(hover.messageId) - if (targetIndex === -1) return false - const currentIndex = ids.indexOf(props.messageId) - if (currentIndex === -1) return false - return currentIndex >= targetIndex - } - return false - } const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) @@ -483,36 +428,9 @@ function ToolCallItem(props: ToolCallItemProps) { navigateToTaskSession(location) } - const deleteUpTo = async () => { - if (!props.showDeleteMessage) return - if (!props.onDeleteMessagesUpTo) return - if (deletingUpTo()) return - - setDeletingUpTo(true) - try { - await props.onDeleteMessagesUpTo(props.messageId) - } finally { - setDeletingUpTo(false) - } - } - const actionMenuItems = (): ActionOverflowMenuItem[] => { const items: ActionOverflowMenuItem[] = [] - if (props.showDeleteMessage) { - items.push({ - key: "select", - label: isSelectedForDeletion() - ? t("messageItem.selection.deselectForDeletion") - : t("messageItem.selection.selectForDeletion"), - icon: isSelectedForDeletion() - ?