From 07f29d08d5a7b502249dab616a7ab8ac13b7e48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 12 Aug 2026 20:09:44 +0200 Subject: [PATCH 001/131] feat(opencode): migrate CodeNomad to native V2 Replace the V1 SDK, custom plugin, and per-workspace runtimes with the pinned OpenCode V2 client and one shared service. Model workspaces through native locations and route sessions, messages, events, files, VCS, permissions, questions, providers, commands, and MCP directly through V2 APIs. Remove the legacy plugin and background-process layers, use native Shell and PTY support, preserve server-side Git mutations where V2 lacks parity, and update the UI, packaging, CI, architecture documentation, and translations for the new model. Add focused coverage for shared-service ownership, Windows and WSL startup, proxy boundaries, worktree event routing, native event normalization, provider authentication, voice instructions, and location-scoped requests. Server and UI typechecks pass; remaining security review items and the real opencode2 smoke test are documented in MIGRATION_V2.md and will be completed before the draft PR is marked ready. --- .github/workflows/pr-build.yml | 4 +- .../codenomad-architecture-guide/SKILL.md | 218 ++--- .../references/architecture-overview.md | 97 +- .../references/feature-traces.md | 199 +--- .../references/sdk-api-reference.md | 125 +-- .../references/sdk-critical-behaviors.md | 124 +-- .../references/sdk-integration-patterns.md | 217 +---- .../references/server-conventions.md | 131 +-- CONTRIBUTING.md | 13 +- MIGRATION_V2.md | 87 ++ dev-docs/INDEX.md | 6 +- dev-docs/MVP-PRINCIPLES.md | 2 +- dev-docs/SUMMARY.md | 80 +- dev-docs/architecture.md | 337 ++----- dev-docs/build-roadmap.md | 18 +- dev-docs/technical-implementation.md | 664 ++------------ package-lock.json | 321 +++++-- package.json | 3 +- packages/electron-app/package.json | 4 - packages/opencode-plugin/README.md | 32 - packages/opencode-plugin/package.json | 22 - packages/opencode-plugin/plugin/codenomad.ts | 73 -- .../plugin/lib/background-process.ts | 265 ------ packages/opencode-plugin/plugin/lib/client.ts | 133 --- .../opencode-plugin/plugin/lib/request.ts | 214 ----- packages/opencode-plugin/tsconfig.json | 17 - packages/server/package.json | 5 +- .../scripts/package-opencode-plugin.mjs | 59 -- packages/server/src/api-types.ts | 48 - .../src/background-processes/manager.test.ts | 153 ---- .../src/background-processes/manager.ts | 684 -------------- packages/server/src/index.ts | 15 +- packages/server/src/opencode-plugin.test.ts | 38 - packages/server/src/opencode-plugin.ts | 175 ---- .../server/src/opencode-update/service.ts | 14 +- .../src/permissions/auto-accept-store.ts | 2 +- .../src/permissions/opencode-replier.test.ts | 31 + .../src/permissions/opencode-replier.ts | 35 +- .../opencode-yolo-metadata.test.ts | 105 +-- .../src/permissions/opencode-yolo-metadata.ts | 110 +-- packages/server/src/plugins/channel.ts | 55 -- packages/server/src/plugins/handlers.ts | 36 - packages/server/src/plugins/voice-mode.ts | 100 -- .../server/__tests__/instance-proxy.test.ts | 162 ++++ packages/server/src/server/http-server.ts | 324 ++++--- .../src/server/routes/background-processes.ts | 103 --- packages/server/src/server/routes/plugin.ts | 106 --- .../src/server/routes/workspaces.test.ts | 12 +- .../server/src/server/routes/workspaces.ts | 2 - .../server/src/server/routes/worktrees.ts | 100 +- packages/server/src/settings/binaries.test.ts | 16 +- packages/server/src/settings/binaries.ts | 7 +- .../src/workspaces/__tests__/spawn.test.ts | 131 +-- .../__tests__/workspace-identity.test.ts | 34 +- .../src/workspaces/instance-client.test.ts | 175 ---- .../server/src/workspaces/instance-client.ts | 63 +- .../src/workspaces/instance-events.test.ts | 108 +++ .../server/src/workspaces/instance-events.ts | 257 ++---- .../src/workspaces/launch-cleanup.test.ts | 34 - packages/server/src/workspaces/loopback.ts | 8 - .../server/src/workspaces/manager.test.ts | 452 +++------ packages/server/src/workspaces/manager.ts | 433 +++------ .../src/workspaces/opencode-auth.test.ts | 41 - .../server/src/workspaces/opencode-auth.ts | 49 - .../src/workspaces/opencode-service.test.ts | 298 ++++++ .../server/src/workspaces/opencode-service.ts | 271 ++++++ .../process-identity.darwin.test.ts | 97 -- .../src/workspaces/process-identity.test.ts | 162 ---- .../server/src/workspaces/process-identity.ts | 561 ------------ .../server/src/workspaces/runtime.test.ts | 265 ------ packages/server/src/workspaces/runtime.ts | 854 ------------------ packages/server/src/workspaces/spawn.ts | 123 +-- .../server/src/workspaces/worktree-map.ts | 99 +- packages/tauri-app/scripts/prebuild.js | 22 - packages/ui/package.json | 3 +- packages/ui/src/App.tsx | 16 +- .../background-process-output-dialog.tsx | 169 ---- .../src/components/folder-selection-view.tsx | 18 +- .../components/instance-service-status.tsx | 58 +- .../components/instance/instance-shell2.tsx | 55 -- .../instance/shell/right-panel/RightPanel.tsx | 11 +- .../shell/right-panel/core-plugin.tsx | 2 - .../shell/right-panel/core-runtime.tsx | 11 +- .../right-panel/git-changes-model.test.ts | 37 + .../shell/right-panel/git-changes-model.ts | 38 +- .../shell/right-panel/plugin-manifest.test.ts | 2 - .../shell/right-panel/tabs/FilesTab.tsx | 9 +- .../shell/right-panel/tabs/StatusTab.tsx | 94 +- .../right-panel/tabs/file-v2-adapters.ts | 15 + .../right-panel/tabs/files-runtime.test.ts | 22 + .../shell/right-panel/tabs/files-runtime.tsx | 44 +- .../shell/right-panel/tabs/status-sections.ts | 6 - .../shell/right-panel/useGitChanges.ts | 15 +- .../shell/useInstanceSessionContext.ts | 9 +- packages/ui/src/components/message-block.tsx | 473 +--------- packages/ui/src/components/message-item.tsx | 92 +- packages/ui/src/components/message-part.tsx | 2 +- .../ui/src/components/message-preview.tsx | 11 - .../ui/src/components/message-section.tsx | 623 +------------ .../ui/src/components/message-timeline.tsx | 41 +- .../components/opencode-binary-selector.tsx | 16 +- packages/ui/src/components/prompt-input.tsx | 1 - .../prompt-input/usePromptPicker.ts | 6 +- .../provider-auth/provider-auth-form.tsx | 151 ++++ .../provider-auth/provider-manager-modal.tsx | 333 ++++--- .../src/components/session/session-view.tsx | 43 +- .../settings/opencode-settings-section.tsx | 4 +- .../settings/opencode-update-card.tsx | 8 +- packages/ui/src/components/tool-call.tsx | 2 +- .../src/components/tool-call/diagnostics.ts | 2 +- .../src/components/tool-call/diff-render.tsx | 2 +- .../components/tool-call/markdown-render.tsx | 2 +- .../components/tool-call/question-block.tsx | 2 +- .../components/tool-call/renderers/bash.tsx | 2 +- .../components/tool-call/renderers/task.tsx | 2 +- .../components/tool-call/renderers/todo.tsx | 2 +- packages/ui/src/components/tool-call/types.ts | 2 +- packages/ui/src/components/tool-call/utils.ts | 6 +- .../tool-deletion-companions.test.ts | 135 --- .../components/tool-deletion-companions.ts | 74 -- packages/ui/src/components/unified-picker.tsx | 8 +- .../ui/src/components/worktree-selector.tsx | 6 +- packages/ui/src/lib/api-client.ts | 87 -- packages/ui/src/lib/command-utils.ts | 6 +- .../src/lib/hooks/use-app-session-restore.ts | 2 +- packages/ui/src/lib/hooks/use-commands.ts | 18 +- .../ui/src/lib/hooks/use-instance-metadata.ts | 33 +- .../ui/src/lib/i18n/messages/de/instance.ts | 13 +- .../ui/src/lib/i18n/messages/de/settings.ts | 7 +- .../ui/src/lib/i18n/messages/en/instance.ts | 13 +- .../ui/src/lib/i18n/messages/en/settings.ts | 7 +- .../ui/src/lib/i18n/messages/es/instance.ts | 12 +- .../ui/src/lib/i18n/messages/es/settings.ts | 7 +- .../ui/src/lib/i18n/messages/fr/instance.ts | 12 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 7 +- .../ui/src/lib/i18n/messages/he/instance.ts | 13 +- .../ui/src/lib/i18n/messages/he/settings.ts | 7 +- .../ui/src/lib/i18n/messages/ja/instance.ts | 12 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 7 +- .../ui/src/lib/i18n/messages/ne/instance.ts | 13 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 7 +- .../ui/src/lib/i18n/messages/ru/instance.ts | 12 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 7 +- .../src/lib/i18n/messages/zh-Hans/instance.ts | 12 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 7 +- packages/ui/src/lib/opencode-api.ts | 4 +- packages/ui/src/lib/provider-auth.test.ts | 32 + packages/ui/src/lib/provider-auth.ts | 68 +- packages/ui/src/lib/sdk-manager.test.ts | 27 + packages/ui/src/lib/sdk-manager.ts | 37 +- packages/ui/src/lib/sse-manager.ts | 196 ++-- .../ui/src/stores/background-processes.ts | 66 -- packages/ui/src/stores/commands.ts | 12 +- packages/ui/src/stores/conversation-speech.ts | 30 - .../instances-restore-ownership.test.ts | 4 +- packages/ui/src/stores/instances.ts | 275 ++---- packages/ui/src/stores/launch-errors.ts | 2 +- .../stores/message-v2/instance-store.test.ts | 4 +- .../src/stores/message-v2/normalizers.test.ts | 81 ++ .../ui/src/stores/message-v2/normalizers.ts | 121 ++- .../ui/src/stores/opencode-client.test.ts | 10 + packages/ui/src/stores/opencode-client.ts | 6 +- .../src/stores/opencode-workspace-matching.ts | 51 -- .../ui/src/stores/opencode-workspaces.test.ts | 60 -- packages/ui/src/stores/opencode-workspaces.ts | 157 ---- .../src/stores/permission-lifecycle.test.ts | 149 ++- packages/ui/src/stores/preferences.tsx | 15 +- .../ui/src/stores/request-locations.test.ts | 37 +- packages/ui/src/stores/request-locations.ts | 33 +- .../ui/src/stores/session-actions.test.ts | 114 +++ packages/ui/src/stores/session-actions.ts | 220 ++--- packages/ui/src/stores/session-api.ts | 489 ++-------- packages/ui/src/stores/session-events.ts | 274 +++--- .../ui/src/stores/session-list-options.ts | 3 +- .../stores/session-metadata-completeness.ts | 7 - .../ui/src/stores/session-metadata.test.ts | 18 - packages/ui/src/stores/session-metadata.ts | 69 -- .../src/stores/session-native-events.test.ts | 82 ++ .../stores/session-request-authority.test.ts | 56 +- .../src/stores/session-send-lifecycle.test.ts | 96 +- packages/ui/src/stores/session-state.ts | 16 +- packages/ui/src/stores/sessions.ts | 2 + packages/ui/src/stores/worktree-ready.test.ts | 22 +- packages/ui/src/stores/worktrees.ts | 257 +----- .../src/styles/components/provider-auth.css | 18 + packages/ui/src/styles/panels/right-panel.css | 30 - packages/ui/src/types/delete-hover.ts | 4 - packages/ui/src/types/instance.ts | 16 +- packages/ui/src/types/message.ts | 110 ++- packages/ui/src/types/permission.test.ts | 8 +- packages/ui/src/types/permission.ts | 91 +- packages/ui/src/types/question.ts | 35 +- packages/ui/src/types/session.test.ts | 16 +- packages/ui/src/types/session.ts | 31 +- packages/ui/src/types/tool-state.ts | 29 + 195 files changed, 4473 insertions(+), 12563 deletions(-) create mode 100644 MIGRATION_V2.md delete mode 100644 packages/opencode-plugin/README.md delete mode 100644 packages/opencode-plugin/package.json delete mode 100644 packages/opencode-plugin/plugin/codenomad.ts delete mode 100644 packages/opencode-plugin/plugin/lib/background-process.ts delete mode 100644 packages/opencode-plugin/plugin/lib/client.ts delete mode 100644 packages/opencode-plugin/plugin/lib/request.ts delete mode 100644 packages/opencode-plugin/tsconfig.json delete mode 100644 packages/server/scripts/package-opencode-plugin.mjs delete mode 100644 packages/server/src/background-processes/manager.test.ts delete mode 100644 packages/server/src/background-processes/manager.ts delete mode 100644 packages/server/src/opencode-plugin.test.ts delete mode 100644 packages/server/src/opencode-plugin.ts create mode 100644 packages/server/src/permissions/opencode-replier.test.ts delete mode 100644 packages/server/src/plugins/channel.ts delete mode 100644 packages/server/src/plugins/handlers.ts delete mode 100644 packages/server/src/plugins/voice-mode.ts create mode 100644 packages/server/src/server/__tests__/instance-proxy.test.ts delete mode 100644 packages/server/src/server/routes/background-processes.ts delete mode 100644 packages/server/src/server/routes/plugin.ts delete mode 100644 packages/server/src/workspaces/instance-client.test.ts create mode 100644 packages/server/src/workspaces/instance-events.test.ts delete mode 100644 packages/server/src/workspaces/launch-cleanup.test.ts delete mode 100644 packages/server/src/workspaces/loopback.ts delete mode 100644 packages/server/src/workspaces/opencode-auth.test.ts delete mode 100644 packages/server/src/workspaces/opencode-auth.ts create mode 100644 packages/server/src/workspaces/opencode-service.test.ts create mode 100644 packages/server/src/workspaces/opencode-service.ts delete mode 100644 packages/server/src/workspaces/process-identity.darwin.test.ts delete mode 100644 packages/server/src/workspaces/process-identity.test.ts delete mode 100644 packages/server/src/workspaces/process-identity.ts delete mode 100644 packages/server/src/workspaces/runtime.test.ts delete mode 100644 packages/server/src/workspaces/runtime.ts delete mode 100644 packages/ui/src/components/background-process-output-dialog.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/file-v2-adapters.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.test.ts create mode 100644 packages/ui/src/components/provider-auth/provider-auth-form.tsx delete mode 100644 packages/ui/src/components/tool-deletion-companions.test.ts delete mode 100644 packages/ui/src/components/tool-deletion-companions.ts create mode 100644 packages/ui/src/lib/provider-auth.test.ts create mode 100644 packages/ui/src/lib/sdk-manager.test.ts delete mode 100644 packages/ui/src/stores/background-processes.ts create mode 100644 packages/ui/src/stores/message-v2/normalizers.test.ts create mode 100644 packages/ui/src/stores/opencode-client.test.ts delete mode 100644 packages/ui/src/stores/opencode-workspace-matching.ts delete mode 100644 packages/ui/src/stores/opencode-workspaces.test.ts delete mode 100644 packages/ui/src/stores/opencode-workspaces.ts create mode 100644 packages/ui/src/stores/session-actions.test.ts delete mode 100644 packages/ui/src/stores/session-metadata-completeness.ts delete mode 100644 packages/ui/src/stores/session-metadata.test.ts delete mode 100644 packages/ui/src/stores/session-metadata.ts create mode 100644 packages/ui/src/stores/session-native-events.test.ts delete mode 100644 packages/ui/src/types/delete-hover.ts create mode 100644 packages/ui/src/types/tool-state.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 52b56b408..a87e16744 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -124,9 +124,10 @@ jobs: 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/session-generation-recovery.test.ts - packages/ui/src/stores/session-metadata.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts - name: Test restore ownership integration @@ -135,6 +136,7 @@ jobs: packages/ui/src/lib/hooks/use-active-session-message-load.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/permission-lifecycle.test.ts + packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 0066a61fa..8fdc3cb33 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -1,153 +1,75 @@ --- 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 exact version `@opencode-ai/client@0.0.0-next-17288` in server and UI. +- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`. +- There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. +- The server owns one shared OpenCode service through `OpenCodeSharedService` and upstream `Service.ensure`; workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. +- The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. +- OpenCode owns session APIs, native Shell (`client.session.shell`) and session instructions (`client.session.instructions.entry`). +- CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. + +## 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` + +## 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. +- 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. + +## Anti-Patterns + +| Avoid | Use | +|---|---| +| `@opencode-ai/sdk` | `@opencode-ai/client@0.0.0-next-17288` | +| One `opencode serve` per workspace | One `Service.ensure` shared service | +| Per-worktree clients/processes | Root proxy client plus native location/directory inputs | +| Reintroducing `packages/opencode-plugin` | Native OpenCode Shell/instructions | +| 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..d41ed709b 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -1,76 +1,47 @@ # 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` +## Runtime Shape + +```text +Electron/Tauri -> CodeNomad Fastify server -> one shared OpenCode service + | | + | /api/* | Location-scoped /api/* + v v + SolidJS UI <- /api/events <- event bridge +``` -### 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/` +The server calls `Service.ensure` once through `packages/server/src/workspaces/opencode-service.ts`. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; a workspace is a logical location owner, not an OpenCode child process. -### 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` +## Boundaries -### 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/` +| Owner | Responsibilities | Main paths | +|---|---|---| +| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | `@opencode-ai/client@0.0.0-next-17288` | +| 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/` | -### BuildAndPackaging (28 entities) -- **Build Scripts:** Version sync, icon generation, resource copying -- **Packaging:** Server resource bundling, node runtime preparation +`packages/opencode-plugin/` and the server plugin/background-process integration were deleted. Do not use those paths as extension points. -### CloudflareDeployment (3 entities) -- **Edge Functions:** Asset serving with cache headers in `packages/cloudflare/src/index.ts` +## HTTP And Events -## Key Entry Points +- CodeNomad control endpoints live under `/api/*`, including `/api/workspaces`, Git routes and `/api/events`. +- OpenCode requests use `/workspaces/:id/instance/api/*`. The proxy injects service auth, validates supplied `location`/`directory` values, checks session ownership, and defaults safe requests to the workspace directory. +- Yolo state endpoints currently use `/workspaces/:id/yolo/sessions/:sessionId`; state changes and auto-accept confirmations travel over `/api/events`. +- `InstanceEventBridge` subscribes once to the shared OpenCode event stream and publishes typed `instance.event` records on CodeNomad's event bus. -| 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 | +## Persistence -## Inter-Area Dependencies +`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. -``` -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) -``` +OpenCode location/workspace identity is upstream state. CodeNomad persists only its own preferences and policy metadata, including Yolo state. -## Finding Code by Area +## 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/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 2aa5a42dc..93743051c 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -1,182 +1,43 @@ # 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. `Service.ensure` discovers or starts one shared `opencode serve --service` endpoint. +4. `client.location.get` validates the directory and returns native location/workspace identity. +5. CodeNomad publishes workspace events on `/api/events` and exposes `/workspaces/:id/instance` as the authorized native API proxy. +6. On final owner deletion, CodeNomad calls `client.debug.location.evict`; server shutdown stops only its owned shared endpoint. -1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` - - Events are pushed through the instance event stream +## Prompt, Shell And Instructions -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`. +4. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. +5. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. -3. **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. -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`. 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..6075adb9e 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,44 @@ -# SDK API Reference +# Native OpenCode V2 Client Reference -## Overview +## Package -CodeNomad uses the OpenCode SDK V2 (`@opencode-ai/sdk/v2/client`) via `createOpencodeClient()`. +CodeNomad pins `@opencode-ai/client@0.0.0-next-17288` exactly in both `packages/server/package.json` and `packages/ui/package.json`. -**Note:** The SDK implementation lives outside this repository. +- Promise client: `import { OpenCode } from "@opencode-ai/client"` +- Service lifecycle: `import { Service } from "@opencode-ai/client/service"` +- Client construction: `OpenCode.make({ baseUrl, headers?, fetch? })` +- Declarations: `node_modules/@opencode-ai/client/dist/promise/` -- 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 V1/V2 wrapper shapes, `{ data, error }` conventions, and `createOpencodeClient()` do not apply. -## SDK Methods Used by CodeNomad +## Used Native APIs -### Session +| Area | Calls | CodeNomad caller | +|---|---|---| +| Service | `Service.discover/ensure/headers/stop` | `packages/server/src/workspaces/opencode-service.ts` | +| Location | `client.location.get`, `client.debug.location.evict` | shared service wrapper | +| Events | `client.event.subscribe()` | `packages/server/src/workspaces/instance-events.ts` | +| Sessions | `list/get/create/fork/remove/rename/prompt/command/shell/interrupt` | UI session stores | +| Instructions | `client.session.instructions.entry.put/remove` | conversation-mode prompt setup | +| Permissions | `permission.request.list`, `permission.reply` | UI and server Yolo replier | +| Questions | `question.request.list` and reply/reject APIs | UI interruption flow | -**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 +## Routing -**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", -) -``` +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. -### Part +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. -**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", -) -``` +## CodeNomad-Owned APIs -**⚠️ Constraint:** Message must retain ≥1 part. Delete entire message if removing last part. +Do not look for these in the OpenCode client: -**Note on Part Updates:** CodeNomad does not currently use `client.part.update()`. Part modifications are handled through other mechanisms. +- 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` -### Permission - -**SDK:** `client.permission.reply({ requestID, reply: "allow" | "deny" | "once" })` -**Wrapper:** `packages/ui/src/stores/instances.ts:sendPermissionResponse()` - -**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) +These use `packages/ui/src/lib/api-client.ts` and server routes. 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..4f8c8f044 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,33 @@ -# 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. +- Version is pinned to `@opencode-ai/client@0.0.0-next-17288`; update server and UI together. +- The package root is the generated zero-Effect Promise client. Use installed declarations, not old SDK examples. +- Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy. -## 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`. +- Evict an upstream location only after its final logical owner is deleted. -## Schema Violation Details +## Shared Lifecycle -### Assistant Part Metadata (Fatal) +- There is one shared `Service.ensure`, client and upstream event subscription. +- A workspace stop removes location ownership; it does not stop a dedicated OpenCode process. +- Shutdown stops the service only when CodeNomad started and still owns the discovered endpoint. -**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 | +| Service discovery/start/stop | OpenCode `Service`, wrapped by CodeNomad | +| 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. +Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, 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..f903aa1ad 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,42 @@ -# SDK Integration Patterns +# Native OpenCode V2 Integration Patterns -## Client Lifecycle +## Shared Service -### SDK Manager +`WorkspaceManager` owns one `OpenCodeSharedService`. Its first workspace calls upstream `Service.ensure`; later workspaces reuse/discover the same endpoint. The wrapper creates one server-side Promise client, performs health checks, owns shutdown only when CodeNomad started the endpoint, and invalidates failed connections. -CodeNomad creates and manages `OpencodeClient` instances through `SDKManager`: +`Service.ensure` has no environment option. The wrapper temporarily overlays the configured environment only around the single launch call; do not create per-workspace services to avoid that limitation. -```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. Deletion calls `client.debug.location.evict` only after the final CodeNomad owner is gone. -**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 rejects unowned `directory`, `location.directory`, and `location[directory]` values. It also resolves session IDs and verifies the session location before forwarding. Keep this check at the server trust boundary. -### 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 +## Native Shell And Instructions -```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`. +- Do not recreate plugin-backed shell, voice instructions, or background-process routes. -// 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) +Optimistic UI updates must still reconcile with native events or a refetch after reconnect. -// 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..c887bdcf0 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -1,114 +1,43 @@ # 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 `Service.ensure` lifecycle and one event subscription for all workspaces. +- 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. -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. +- 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 +- 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` -- **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` +## Current Paths -### Auth Flow +- Workspace/location manager: `packages/server/src/workspaces/manager.ts` +- Shared service: `packages/server/src/workspaces/opencode-service.ts` +- Launch adapter: `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` -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) +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/CONTRIBUTING.md b/CONTRIBUTING.md index 968b54669..1853bc33c 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,16 @@ 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 pin `@opencode-ai/client@0.0.0-next-17288`; do not add `@opencode-ai/sdk`. +- `packages/server/src/workspaces/opencode-service.ts` owns the single shared `Service.ensure` lifecycle. Workspaces are native OpenCode locations/directories, not separate server processes. +- OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. +- Native `client.session.shell` and `client.session.instructions.entry` cover Shell and prompt instructions. There is no `packages/opencode-plugin` integration. +- Git mutations and Yolo policy remain CodeNomad-owned server boundaries. + ### Key UI Files | Path | Purpose | @@ -123,7 +130,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/MIGRATION_V2.md b/MIGRATION_V2.md new file mode 100644 index 000000000..cb5f132f5 --- /dev/null +++ b/MIGRATION_V2.md @@ -0,0 +1,87 @@ +# OpenCode V2 Migration + +## Summary + +This branch migrates CodeNomad from the OpenCode V1 SDK and custom plugin architecture to the native OpenCode V2 client and shared service model. + +The migration removes the V1 compatibility layer rather than maintaining both integrations. It substantially reduces custom runtime code and aligns CodeNomad with OpenCode's supported V2 APIs. + +## Main Changes + +- Replace `@opencode-ai/sdk` with the pinned native V2 client, `@opencode-ai/client@0.0.0-next-17288`. +- Use one shared OpenCode V2 service instead of one runtime per workspace. +- Represent CodeNomad workspaces as logical instances associated with absolute directories. +- Use native `Location` and `SessionInfo.location` data to associate sessions, files, events, and Git worktrees. +- Migrate sessions, messages, streaming events, permissions, questions, files, VCS, commands, MCP, providers, models, and agents to native V2 APIs. +- Handle native text, reasoning, tool, status, and terminal session events. +- Reconcile session state through `session.active()` after reconnecting so missed events do not leave stale working states. +- Route events from owned Git worktrees to their corresponding logical CodeNomad workspace. + +## Removed Legacy Components + +- Remove the custom `packages/opencode-plugin` package. +- Remove V1 plugin communication channels and per-workspace runtime management. +- Replace the custom background-process implementation with native V2 Shell and PTY APIs. +- Replace per-workspace OpenCode binary selection with one global `opencode2` binary. +- Remove message and part deletion controls because V2 currently has no equivalent API. +- Keep Git mutation operations on the CodeNomad server where V2 does not yet provide sufficient parity. + +## Provider Authentication and Voice Mode + +- Support native V2 API-key, OAuth, interactive form, and command-based provider authentication. +- Display required provider fields and submit answers in the native V2 format. +- Store voice-mode instructions through `session.instructions.entry`. +- Synchronize voice instructions before prompts, slash commands, and shell requests. + +## Security and Service Lifecycle + +- Restrict Shell and PTY working directories to workspace-owned roots and Git worktrees. +- Remove CodeNomad authentication cookies before forwarding requests to OpenCode. +- Prevent OpenCode `Set-Cookie` headers from being relayed to the browser. +- Avoid logging unredacted secret-bearing proxy request bodies. +- Share a consistent service registration location between Windows and WSL. +- Stop a shared service only when CodeNomad can prove that its own process started it. + +## Expected Benefits + +- Less custom integration code and fewer long-running processes. +- Closer alignment with the supported OpenCode V2 architecture. +- Native access to future OpenCode functionality without maintaining V1 compatibility code. +- Consistent behavior between root workspaces and Git worktrees. +- Simpler service startup, event handling, and client-side API access. + +## Current Status + +- Server and UI typechecks pass. +- Focused tests for service ownership, proxy security, worktree event routing, provider authentication, and voice instructions pass. +- UI tests and builds passed earlier in the migration. +- A real service smoke test is blocked because `opencode2` is not installed in the current `PATH`. +- The migration is not merge-ready yet. The final security review found unresolved proxy isolation issues that must be fixed first. + +## Remaining Work + +- Fix the encoded-path proxy issue that can redirect an authenticated upstream request to another host. +- Restrict or filter global V2 endpoints that are not scoped by `Location`. +- Enforce session ownership for experimental session log routes. +- Validate embedded locations when importing sessions. +- Harden shared service registration and multi-process shutdown behavior. +- Complete the remaining high-priority event and provider-auth security fixes. +- Run the complete test and build matrix after the fixes. +- Run an end-to-end smoke test with the actual `opencode2` binary. + +## Validation + +The final validation should include: + +- Server and UI typechecks. +- Server and UI test suites. +- Server and UI production builds. +- Electron native tests and typecheck when its local dependencies are available. +- `git diff --check`. +- A real OpenCode V2 startup, session, event, Shell, and shutdown smoke test. + +## Review Notes + +- The OpenCode V2 client is still a beta contract and may change. +- This branch intentionally provides no OpenCode V1 fallback. +- The branch should remain a Draft Pull Request until the security findings and real-service smoke test are complete. diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 290f7f798..ed48cae27 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -50,12 +50,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 +**Read this to understand:** Current implementation boundaries ### [build-roadmap.md](build-roadmap.md) 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..0c0979f48 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -10,25 +10,13 @@ A comprehensive specification and task breakdown for building the CodeNomad desk ## 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 +tasks/ Task tracking ``` ## Documentation Overview @@ -81,9 +69,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,8 +80,8 @@ packages/opencode-client/ - Complete project structure - TypeScript interfaces -- Process spawning logic -- SDK client management +- `Service.ensure` and location lifecycle +- Native Promise client management - Message rendering implementation - Build and packaging config @@ -137,17 +125,17 @@ packages/opencode-client/ - Add keyboard shortcuts - Style and test responsiveness -**003 - Process Manager** (4-5 hours) +**003 - Shared Service Manager** (4-5 hours) -- Spawn OpenCode server processes -- Parse stdout for port extraction -- Kill processes on command +- Discover or start one OpenCode service with `Service.ensure` +- Validate workspace locations/directories +- Stop only the shared endpoint CodeNomad owns - Handle errors and timeouts - Auto-cleanup on app quit -**004 - SDK Integration** (3-4 hours) +**004 - Native Client Integration** (3-4 hours) -- Create SDK client per instance +- Create native clients through the CodeNomad proxy - Fetch sessions, agents, models - Implement session CRUD operations - Add error handling and retries @@ -169,18 +157,18 @@ 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 server discovers or starts one service with `Service.ensure` +- Workspace folders become validated native locations +- UI traffic stays behind the CodeNomad proxy +- Shutdown stops only the endpoint CodeNomad owns -### 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 `Service.ensure` 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 @@ -307,15 +295,13 @@ packages/opencode-client/ - SolidJS docs: https://solidjs.com - Kobalte UI: https://kobalte.dev -## Questions to Resolve - -Before starting implementation, clarify: +## Current OpenCode Baseline -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 +- Native client: `@opencode-ai/client@0.0.0-next-17288` +- Service: one shared `Service.ensure` +- Workspaces: native locations/directories +- Shell and instructions: native session APIs +- Git mutations and Yolo: CodeNomad-owned ## Estimated Timeline diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 21f94654f..6619f6d57 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -2,311 +2,78 @@ ## 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 directly with native OpenCode V2 through exact dependency `@opencode-ai/client@0.0.0-next-17288`. -## 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 - -### UI - -- **TailwindCSS** - Styling -- **Kobalte** - Accessible UI primitives -- **Shiki** - Code syntax highlighting -- **Marked** - Markdown parsing - -### Communication - -- **OpenCode SDK** - API client -- **EventSource** - SSE streaming -- **Node Child Process** - Process spawning - -## Error Handling - -### Process Errors +There is no `@opencode-ai/sdk` integration and no `packages/opencode-plugin` package. -- Server fails to start → Show error in instance tab -- Server crashes → Attempt auto-restart once -- Port already in use → Find next available port +## Shared Service And Locations -### Network Errors +`packages/server/src/workspaces/opencode-service.ts` wraps native `Service.discover`, `Service.ensure`, `Service.headers` and `Service.stop`. The first workspace ensures one `opencode serve --service`; all workspaces share that endpoint, client and event stream. -- API call fails → Show inline error, allow retry -- SSE disconnects → Auto-reconnect with backoff -- Timeout → Show timeout error, allow manual retry +`packages/server/src/workspaces/manager.ts` treats selected folders as native OpenCode locations: -### User Errors +1. Validate the directory with `client.location.get`. +2. Store the returned `LocationRef` and publish the logical workspace. +3. Reuse the shared service for every additional directory. +4. Evict a location only after its final CodeNomad owner is deleted. +5. Stop the shared service at CodeNomad shutdown only if CodeNomad started it. -- Invalid folder selection → Show error dialog -- Permission denied → Show actionable error message -- Out of memory → Graceful degradation message +Workspaces are not OpenCode processes and do not own ports or PIDs. -## Performance Considerations +## API Boundaries -**Note: Performance optimization is NOT a focus for MVP. These are future considerations.** +CodeNomad control APIs live under `/api/*`. Important routes include: -### Message Rendering (Post-MVP) +- `/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` -- 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 +Native OpenCode requests use `/workspaces/:id/instance/api/*`. The Fastify proxy adds shared-service authorization and rejects locations/directories outside the selected workspace or its worktrees. Session routes also verify `session.location.directory`. -### State Updates +Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId`; Yolo notifications use `/api/events`. -- SolidJS fine-grained reactivity handles most cases -- No special optimizations needed for MVP -- Batching/debouncing can be added later if needed +## Client And Events -### Memory Management (Post-MVP) +`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. -- No memory management in MVP -- Let browser/OS handle it -- Add limits only if problems arise in testing +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. -## 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` | +| 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 +Native Shell and session instructions replace the deleted plugin-backed integrations. Do not restore plugin background-process, voice-mode, channel or packaging paths. -### 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 index 1fd48cb34..31aa48cf0 100644 --- a/dev-docs/build-roadmap.md +++ b/dev-docs/build-roadmap.md @@ -23,28 +23,28 @@ The minimum viable product includes: ## Phase 1: Foundation (Week 1) -**Goal:** Running Electron app that can spawn OpenCode servers +**Goal:** Running desktop app connected to one shared OpenCode service ### Tasks 1. ✅ **001-project-setup** - Electron + SolidJS + Vite boilerplate 2. ✅ **002-empty-state-ui** - Empty state UI with folder selection -3. ✅ **003-process-manager** - Spawn and manage OpenCode server processes -4. ✅ **004-sdk-integration** - Connect to server via SDK +3. ✅ **003-process-manager** - Discover/start and manage the shared OpenCode service +4. ✅ **004-sdk-integration** - Connect through the native OpenCode client 5. ✅ **005-session-picker-modal** - Select/create session modal ### Deliverables - App launches successfully - Can select folder -- Server spawns automatically +- Shared service starts or reconnects automatically - Session picker appears - Can create/select session ### Success Criteria - User can launch app → select folder → see session picker -- Server process runs in background +- Workspace location is ready on the shared service - Sessions fetch from API successfully --- @@ -116,7 +116,7 @@ The minimum viable product includes: 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 +20. **020-multiple-sdk-clients** - Location-scoped clients over one shared service ### Deliverables @@ -228,7 +228,7 @@ The minimum viable product includes: 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 +40. **040-native-capabilities** - Integrate additional native OpenCode capabilities ### Deliverables @@ -236,7 +236,7 @@ The minimum viable product includes: - Cross-session search - Workspace persistence - Theme editor -- Plugin loader +- Native capability integration ### Success Criteria @@ -352,7 +352,7 @@ Some tasks can be worked on independently: ### External - OpenCode CLI availability -- OpenCode SDK stability +- `@opencode-ai/client` contract stability - Electron framework updates ### Internal diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index bbfc6443e..856c11784 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -1,642 +1,90 @@ -# Technical Implementation Details +# Technical Implementation -## Technology Stack +## OpenCode Dependency -### Core Technologies +Server and UI pin `@opencode-ai/client@0.0.0-next-17288`. Import the generated Promise client from `@opencode-ai/client` and service lifecycle APIs from `@opencode-ai/client/service`. -- **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: -### Communication - -- **OpenCode SDK** (@opencode-ai/sdk) - API client -- **EventSource API** - Server-sent events -- **Node Child Process** - Process management - -### 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) - } -} -``` - -## 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"], +```ts +const endpoint = await Service.ensure(options) +const client = OpenCode.make({ + baseUrl: endpoint.url, + headers: Service.headers(endpoint), }) - -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:** - -- No message limits -- No pruning -- No lazy loading -- Let users create as many messages as they want -- Optimize later if problems occur - -**When to add optimizations (post-MVP):** - -- Users report slowness with large sessions -- Measurable performance degradation -- Memory usage becomes problematic -- See Phase 8 tasks for virtual scrolling and optimization - -## IPC Communication - -### Main Process → Renderer - -```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 - -```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 -} -``` +It caches one connection, checks discovery before reuse, invalidates failures, subscribes to one native event stream, and stops only an endpoint it started. `Service.ensure` has no environment option, so the adapter temporarily overlays configured variables only during the shared launch. -### Preload Script (Bridge) +Workspace creation passes a native location: -```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), -}) +```ts +await client.location.get({ location: { directory } }) ``` -## Error Handling Strategy +`WorkspaceManager` records the returned directory/workspace ID and uses `client.debug.location.evict` after the final owner is removed. -### Network Errors +## UI Integration -```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) - } -} -``` +`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. -### SSE Errors +Session actions use native APIs directly: -```typescript -eventSource.onerror = (error) => { - // Connection lost - if (eventSource.readyState === EventSource.CLOSED) { - // Attempt reconnect - reconnectSSE() - } -} +```ts +await client.session.prompt({ sessionID, text, files }) +await client.session.shell({ sessionID, command }) +await client.session.instructions.entry.put({ sessionID, key, value }) ``` -### 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 -} -``` - -## Security Measures - -### IPC Security - -- Use `contextIsolation: true` -- Whitelist allowed IPC channels -- Validate all data from renderer -- No `nodeIntegration` in renderer +Shell mode and conversation instructions are upstream features. They do not require a CodeNomad plugin. -### Process Security +## Routing And Security -- Spawn OpenCode with user permissions only -- No shell execution of user input -- Sanitize file paths +- CodeNomad operations: `packages/ui/src/lib/api-client.ts` -> `/api/*`. +- OpenCode operations: generated client -> `/workspaces/:id/instance/api/*`. +- Browser events: `GET /api/events`; heartbeat response: `POST /api/client-connections/pong`. +- The proxy checks client-provided directories, defaults safe requests to the workspace location, and verifies session ownership before forwarding. -### Content Security +Never trust a browser-supplied worktree path. Resolve workspace/worktree ownership server-side. -- Sanitize markdown before rendering -- Use DOMPurify for HTML sanitization -- No `dangerouslySetInnerHTML` without sanitization -- CSP headers in renderer +## CodeNomad-Owned Mutations -## Testing Strategy (Future) +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-*`. -### Unit Tests +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`. -- State management logic -- Utility functions -- Message parsing +## Events -### Integration 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`. The UI's `sse-manager.ts` handles the multiplexed stream and reconnects; stores reconcile optimistic state with events or refetches. -- Process spawning -- SDK client operations -- SSE event handling +## Current Structure -### E2E Tests +```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 -- 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 ``` -### Production - -```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 +Deleted plugin, background-process, and per-workspace runtime files are not architectural extension points. -## Configuration Files +## Validation -### 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", - }, - }, - }, -}) -``` - -### 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 473c90740..4753d2a6a 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" ] } }, @@ -1576,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 @@ -3189,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 @@ -3229,50 +3302,42 @@ "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==", + "node_modules/@opencode-ai/client": { + "version": "0.0.0-next-17288", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17288.tgz", + "integrity": "sha512-9qD73yHk4zpIafusE5NQ/fMAQhm9TdEmiocy3niuL6VuTaUzy7/18gjqYbUAjmKriCPf3G/ue+l44JC/P8n8rw==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.3.7", - "zod": "4.1.8" + "@opencode-ai/protocol": "0.0.0-next-17288", + "@opencode-ai/schema": "0.0.0-next-17288" }, "peerDependencies": { - "@opentui/core": ">=0.1.92", - "@opentui/solid": ">=0.1.92" + "effect": "4.0.0-beta.101" }, "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { + "effect": { "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==", + "node_modules/@opencode-ai/protocol": { + "version": "0.0.0-next-17288", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17288.tgz", + "integrity": "sha512-UlKX6+3ShWAJF6Ctq5yvJQbBvpb48GFeFaGSyZWTkqNdzG9kCbiXpxo6/bMUps+JyS7yQ7tdNcn9vfSNZ0QFEA==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "dependencies": { + "@opencode-ai/schema": "0.0.0-next-17288", + "effect": "4.0.0-beta.101" } }, - "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==", + "node_modules/@opencode-ai/schema": { + "version": "0.0.0-next-17288", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17288.tgz", + "integrity": "sha512-c/raCR/Es3UXada+7ZpL/nabjBtSV5QPS11sL3mmfmryMtsrZE4DSnnG5I7ihwwr9ZpR8taAmXbyh0fzq6h3lQ==", "license": "MIT", "dependencies": { - "cross-spawn": "7.0.6" + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.101" } }, "node_modules/@pinojs/redact": { @@ -3873,6 +3938,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", @@ -4342,7 +4413,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": { @@ -6109,7 +6182,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" @@ -6294,6 +6367,24 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/effect": { + "version": "4.0.0-beta.101", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.101.tgz", + "integrity": "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "multipasta": "^0.2.8", + "toml": "^4.1.2", + "uuid": "^14.0.1", + "yaml": "^2.9.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "dev": true, @@ -6805,6 +6896,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" @@ -7035,6 +7148,12 @@ "node": ">=14" } }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "license": "MIT", @@ -7844,6 +7963,15 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8538,6 +8666,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, @@ -9012,6 +9146,43 @@ "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/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "dev": true, @@ -9087,6 +9258,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, @@ -9693,6 +9879,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", @@ -11465,6 +11667,15 @@ "node": ">=0.6" } }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -11930,6 +12141,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, @@ -13291,9 +13515,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" @@ -13422,18 +13646,6 @@ "dev": true, "license": "MIT" }, - "packages/opencode-plugin": { - "name": "@codenomad/codenomad-opencode-plugin", - "version": "0.18.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.18.0", @@ -13442,7 +13654,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-next-17288", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -13492,7 +13704,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-next-17288", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", @@ -13518,6 +13730,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/package.json b/package.json index dd823123e..724f81bcf 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/package.json b/packages/electron-app/package.json index 339f56e89..d3ce819a2 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -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 b2193719a..000000000 --- a/packages/opencode-plugin/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@codenomad/codenomad-opencode-plugin", - "version": "0.18.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/package.json b/packages/server/package.json index 3ca359259..45d53b50a 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-next-17288", "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..ca9b1a0a4 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -40,7 +40,6 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string - binaryPath?: string requestId?: string forceNew?: boolean } @@ -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 { @@ -407,18 +398,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 @@ -545,37 +528,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/index.ts b/packages/server/src/index.ts index 3993ed7a6..b7db7c557 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" @@ -388,7 +386,7 @@ 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, @@ -450,18 +448,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,8 +485,6 @@ async function main() { previewManager, authManager, clientConnectionManager, - pluginChannel, - voiceModeManager, remoteProxySessionManager, yoloManager, sessionMetadataPersistence, @@ -523,8 +512,6 @@ async function main() { previewManager, authManager, clientConnectionManager, - pluginChannel, - voiceModeManager, remoteProxySessionManager, yoloManager, sessionMetadataPersistence, 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.ts b/packages/server/src/opencode-update/service.ts index c84167d2d..a9b031bea 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -3,13 +3,11 @@ 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 inFlightUpgrades = new Map>() type UpgradeResult = { success: true; version: string } | { success: false; error: string } @@ -180,15 +178,9 @@ export function createOpenCodeUpdateService( return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) } }, probeBinary: probeBinaryVersion, - findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath), + // The native V2 client has no self-upgrade operation. + findReadyInstanceId: () => undefined, 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 - }, + upgradeInstance: async () => ({ success: false, error: "OpenCode V2 does not expose self-upgrade" }), }) } diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts index b53e62585..fe186c01f 100644 --- a/packages/server/src/permissions/auto-accept-store.ts +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -9,7 +9,7 @@ * - 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 { 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..4371d0bac --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient } from "@opencode-ai/client" + +import type { Logger } from "../logger" +import type { WorkspaceManager } from "../workspaces/manager" +import { createOpencodePermissionReplier } from "./opencode-replier" + +describe("createOpencodePermissionReplier", () => { + it("uses the native permission reply input", async () => { + const calls: Array> = [] + const client = { + permission: { reply: async (input: Record) => { calls.push(input) } }, + } as unknown as OpenCodeClient + const workspaceManager = { + get: () => ({ path: "/repo" }), + getSharedServiceClient: async () => client, + } as unknown as WorkspaceManager + const replier = createOpencodePermissionReplier({ workspaceManager, logger: {} as Logger }) + + await replier({ + instanceId: "instance", + sessionId: "session", + permissionId: "permission", + source: "legacy", + reply: "once", + }) + + assert.deepEqual(calls, [{ sessionID: "session", requestID: "permission", reply: "once" }]) + }) +}) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 2ed9bfc9e..0beb03e92 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -10,38 +10,19 @@ interface OpencodeReplierDeps { /** * 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, - ) - } + await client.permission.reply({ + sessionID: reply.sessionId, + requestID: reply.permissionId, + reply: reply.reply, + }) } } diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index d31d56523..fdd1f1f0a 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -1,61 +1,64 @@ 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), - { - thirdParty: { keep: true }, - codenomad: { version: 1, worktreeSlug: "feature", yolo: { enabled: true, rootSessionId: "root" } }, - }, - ) - }) - - 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) - }) +import type { SettingsService } from "../settings/service" +import type { WorkspaceManager } from "../workspaces/manager" +import { createOpencodeYoloPersistence } from "./opencode-yolo-metadata" - 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: {} } }, +function createHarness() { + 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 workspaceManager = { get: () => ({ path: "/repo" }) } as unknown as WorkspaceManager + const client = { + session: { + async list(input: Record) { + assert.deepEqual(input, { directory: "/repo", limit: 10_000 }) + return { + data: [ + { + id: "root", + parentID: undefined, + revert: undefined, + location: { directory: "/repo", workspaceID: "workspace" }, + }, + ], + cursor: {}, + } }, - } - 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") - }) + }, + } as unknown as OpenCodeClient + const persistence = createOpencodeYoloPersistence( + workspaceManager, + settings, + async () => client, + ) + return { persistence } +} + +describe("OpenCode Yolo persistence", () => { + it("loads native sessions and Yolo state from the CodeNomad store", async () => { + const { persistence } = createHarness() + await persistence.persist("instance", "root", 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 } } - }, + assert.deepEqual(await persistence.loadSessions("instance"), [ + { + id: "root", + parentId: null, + revert: undefined, + workspaceId: "workspace", + yoloEnabled: true, }, - } - 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" }, - }) }) + }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index bca929015..a3bce3911 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -1,111 +1,75 @@ -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { OpenCodeClient } 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 -} - -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 sessionState(settings: SettingsService, sessionId: string): PersistedSessionState { + const sessions = record(settings.getOwner("state", STATE_OWNER).sessions) + return record(sessions[sessionId]) as PersistedSessionState } 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`) + return (await (await clientFor(instanceId)).session.list({ + directory: workspace.path, + limit: SESSION_LIST_LIMIT, + })).data + } + 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) => ({ + return (await listSessions(instanceId)).map((session) => ({ id: session.id, parentId: session.parentID ?? null, revert: session.revert, - workspaceId: session.workspaceID, - yoloEnabled: hasPersistedYolo(session.id, session.metadata), + workspaceId: session.location.workspaceID, + yoloEnabled: sessionState(settings, session.id).yoloEnabled === true, })) }, - 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) - }, - 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..f2f44a665 --- /dev/null +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict" +import { afterEach, describe, it } from "node:test" +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" + +const apps: FastifyInstance[] = [] +afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))) + +function logger(): Logger { + const value = { debug() {}, trace() {}, error() {}, isLevelEnabled() { return false } } + return value as unknown as Logger +} + +async function harness(sessionDirectory = "/repo/worktree") { + const upstream = Fastify() + apps.push(upstream) + let requests = 0 + upstream.all("/*", async (request, reply) => { + requests++ + reply.header("set-cookie", "upstream_session=secret; Path=/") + 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(["/repo", "/repo/worktree"]) + const sessionGets: string[] = [] + const client = { + session: { + get: async ({ sessionID }: { sessionID: string }) => { + sessionGets.push(sessionID) + return { id: sessionID, location: { directory: sessionDirectory } } as SessionInfo + }, + }, + } as OpenCodeClient + const manager: InstanceProxyWorkspaceManager = { + get: () => ({ id: "workspace", path: "/repo" }) as never, + getSharedServiceEndpoint: async () => ({ url: `http://127.0.0.1:${address.port}` }), + getInstanceAuthorizationHeader: () => "Basic internal-secret", + getSharedServiceClient: async () => client, + ownsDirectory: async (_id, directory) => owned.has(directory), + } + const app = Fastify() + apps.push(app) + await app.register(replyFrom) + registerInstanceProxyRoutes(app, { workspaceManager: manager, logger: logger() }) + await app.ready() + return { app, sessionGets, requestCount: () => requests } +} + +describe("instance proxy location enforcement", () => { + it("preserves an owned worktree for session list and create", async () => { + const { app } = await harness() + const listed = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?directory=%2Frepo%2Fworktree&limit=5", + }) + assert.equal(listed.statusCode, 200) + assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Frepo%2Fworktree&limit=5") + + const created = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { title: "test", location: { directory: "/repo/worktree", workspaceID: "worktree" } }, + }) + assert.equal(created.statusCode, 200) + assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo/worktree", workspaceID: "worktree" }) + }) + + it("defaults session list and create to the workspace root", async () => { + const { app } = await harness() + const listed = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session" }) + assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Frepo") + + const created = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session", payload: { title: "test" } }) + assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo" }) + }) + + it("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("accepts owned and rejects unowned native shell and pty cwd values", async () => { + const { app, requestCount } = await harness() + for (const route of ["shell", "pty"]) { + const accepted = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}`, payload: { cwd: "/repo/worktree" } }) + const rejected = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}`, payload: { cwd: "/other" } }) + assert.equal(accepted.statusCode, 200) + assert.equal(rejected.statusCode, 403) + } + assert.equal(requestCount(), 2) + }) + + it("strips browser session and hop-by-hop headers in both directions", async () => { + const { app } = 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-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-remove-me"], undefined) + assert.equal(response.headers["set-cookie"], undefined) + }) + + it("authorizes location-less session routes through the shared client", async () => { + const { app, sessionGets, requestCount } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/session-1/message" }) + assert.equal(response.statusCode, 200) + assert.deepEqual(sessionGets, ["session-1"]) + assert.equal(requestCount(), 1) + }) + + it("rejects sessions owned by another workspace", async () => { + const { app, requestCount } = await harness("/other") + const response = await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/session/session-2" }) + assert.equal(response.statusCode, 403) + assert.equal(requestCount(), 0) + assert.doesNotMatch(response.body, /internal-secret/) + }) +}) + +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..a983c549c 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 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,7 +32,6 @@ 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" @@ -41,8 +39,6 @@ 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" @@ -66,8 +62,6 @@ interface HttpServerDeps { previewManager: PreviewManager authManager: AuthManager clientConnectionManager: ClientConnectionManager - pluginChannel: PluginChannelManager - voiceModeManager: VoiceModeManager remoteProxySessionManager: RemoteProxySessionManager yoloManager: AutoAcceptManager sessionMetadataPersistence: OpencodeYoloPersistence @@ -131,7 +125,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 +199,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 +225,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 +274,7 @@ export function createHttpServer(deps: HttpServerDeps) { logger: sseLogger, connectionManager: deps.clientConnectionManager, }) - registerWorktreeRoutes(app, { - workspaceManager: deps.workspaceManager, - sessionMetadataPersistence: deps.sessionMetadataPersistence, - }) + registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus, @@ -323,14 +298,6 @@ 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 }) @@ -394,8 +361,16 @@ export function createHttpServer(deps: HttpServerDeps) { } } +export interface InstanceProxyWorkspaceManager { + get(id: string): ReturnType + getSharedServiceEndpoint(id: string): ReturnType + getInstanceAuthorizationHeader(id: string): string | undefined + getSharedServiceClient(): Promise + ownsDirectory(id: string, directory: string): Promise +} + interface InstanceProxyDeps { - workspaceManager: WorkspaceManager + workspaceManager: InstanceProxyWorkspaceManager logger: Logger } @@ -523,9 +498,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 ( @@ -559,12 +541,10 @@ 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 logger: Logger pathSuffix?: string }) { @@ -572,127 +552,69 @@ async function proxyWorkspaceRequest(args: { const workspaceId = (request.params as { id: string }).id const workspace = workspaceManager.get(workspaceId) - const bodyToJson = (body: unknown): unknown => { - if (body == null) return null - - 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. - 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 } - } - } - } - } catch { - // fall through - } - - return { __stream: true } - } - - const maybeParse = (input: string): unknown => { - try { - return JSON.parse(input) - } catch { - return { __raw: input } - } - } - - if (Buffer.isBuffer(body)) { - return maybeParse(body.toString("utf-8")) - } - - if (typeof body === "string") { - return maybeParse(body) - } - - if (typeof body === "object") { - return body - } - - return body - } - if (!workspace) { reply.code(404).send({ error: "Workspace not found" }) return } - const port = workspaceManager.getInstancePort(workspaceId) - if (!port) { - reply.code(502).send({ error: "Workspace instance is not ready" }) + const endpoint = await workspaceManager.getSharedServiceEndpoint(workspaceId) + if (!endpoint) { + reply.code(502).send({ error: "OpenCode service is not ready" }) return } 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) + const targetUrl = appendIncomingQuery(new URL(normalizedSuffix, endpoint.url), request.raw.url ?? "") + const requestLocations = readRequestDirectories(targetUrl, request.body) + readNativeCwd(targetUrl, request.body, requestLocations) + if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))) { + reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" }) + return + } - 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") + const sessionId = getSessionRouteId(targetUrl.pathname) + if (sessionId) { + let session + try { + session = await (await workspaceManager.getSharedServiceClient()).session.get({ sessionID: sessionId }) + } catch { + reply.code(404).send({ error: "Session not found" }) + return + } + if (!(await workspaceManager.ownsDirectory(workspaceId, session.location.directory))) { + reply.code(403).send({ error: "Session does not belong to workspace" }) + return + } } - return reply.from(targetUrl, { - rewriteRequestHeaders: (_originalRequest, headers) => { - if (instanceAuthHeader) { - headers.authorization = instanceAuthHeader - } + const body = applyDefaultWorkspaceLocation(targetUrl, request.body, request.method, workspace.path, requestLocations.directories.length > 0, Boolean(sessionId)) + const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) - if (logger.isLevelEnabled("trace")) { - const outgoing: Record = {} - for (const [key, value] of Object.entries(headers as Record)) { - outgoing[key] = value - } + logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance") - // Redact sensitive headers. - for (const key of Object.keys(outgoing)) { - const lower = key.toLowerCase() - if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") { - outgoing[key] = "" - } - } + return reply.from(targetUrl.toString(), { + ...(body !== request.body ? { body } : {}), + rewriteRequestHeaders: (_originalRequest, headers) => { + const outgoingHeaders = sanitizeInstanceProxyRequestHeaders(headers, instanceAuthHeader) + if (logger.isLevelEnabled("trace")) { logger.trace( { workspaceId, method: request.method, - targetUrl, + targetUrl: targetUrl.toString(), contentType: request.headers["content-type"], - body: bodyToJson(request.body), - headers: outgoing, + headers: redactSecrets(outgoingHeaders), }, "Proxy -> OpenCode request", ) } - return headers + return outgoingHeaders }, + rewriteHeaders: stripInstanceProxyResponseCookies, onError: (proxyReply, { error }) => { - logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") + 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" }) } @@ -700,6 +622,128 @@ async function proxyWorkspaceRequest(args: { }) } +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 +} + +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 + } + } + + 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 } +} + +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 +} + +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)) { + if (!blocked.has(key.toLowerCase())) result[key] = value + } + if (authorization) result.authorization = authorization + return result +} + +function stripInstanceProxyResponseCookies(headers: Record) { + return Object.fromEntries(Object.entries(headers).filter(([key]) => !["set-cookie", "set-cookie2"].includes(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) +} + +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\/session\/([^/]+)(?:\/|$)/) + if (!match || match[1] === "active" || match[1] === "import") return null + try { + return decodeURIComponent(match[1]) + } catch { + return null + } +} + 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/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..afb6694a7 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,7 +39,7 @@ 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, }, @@ -47,7 +47,6 @@ describe("workspace routes", () => { assert.equal(response.statusCode, 201) assert.deepEqual(calls, [["C:/work", "Work", { - binaryPath: "C:/tools/opencode.exe", requestId: "restore-request", forceNew: true, }]]) @@ -74,13 +73,6 @@ describe("workspace routes", () => { assert.equal(cancelled.statusCode, 204) assert.deepEqual(calls.at(-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..834ae27d9 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -14,7 +14,6 @@ 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(), }) @@ -76,7 +75,6 @@ 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, }) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index d48d9ddb1..bf237d4b9 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -8,54 +8,19 @@ 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 type { WorktreeListResponse } from "../../api-types" +import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" interface RouteDeps { workspaceManager: WorkspaceManager - sessionMetadataPersistence: OpencodeYoloPersistence } -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) { @@ -149,73 +114,12 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { 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, - ) - } - reply.code(204) } catch (error) { return handleError(error, reply) } }, ) - - 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..9ae8234fd 100644 --- a/packages/server/src/settings/binaries.test.ts +++ b/packages/server/src/settings/binaries.test.ts @@ -5,16 +5,24 @@ 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") }) }) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index d637ac95d..5c85b3000 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 ?? "opencode2" const entry = binaries.find((b) => b.path === path) return { diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index d11d8a66d..9ac7ffa2a 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -4,7 +4,7 @@ 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, resolveWslWorkingDirectory } from "../spawn" describe("parseWslUncPath", () => { it("parses WSL UNC paths into distro and linux path", () => { @@ -114,19 +114,12 @@ describe("buildWindowsSpawnSpec", () => { 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 +136,33 @@ 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", () => { + 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: { - OPENCODE_CONFIG_CONTENT: JSON.stringify({ - plugin: [ - "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz", - ], - }), + NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`, }, - 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/) + assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p") }) - it("propagates inherited known path variables even when they are not explicitly requested", () => { + it("propagates requested configured variables into WSL", () => { 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`, - }, + env: { CUSTOM_SERVICE_VALUE: "configured" }, + propagateEnvKeys: ["CUSTOM_SERVICE_VALUE"], }, ) - assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p") + assert.equal(spec.env?.WSLENV, "CUSTOM_SERVICE_VALUE") }) it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => { @@ -250,32 +215,68 @@ 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__:", - }, +}) + +describe("buildServiceLaunchSpec", () => { + it("returns direct commands for executables, PowerShell, and WSL", () => { + assert.deepEqual( + buildServiceLaunchSpec("opencode.exe", ["serve"], { platform: "win32" }).command, + ["opencode.exe", "serve"], ) + assert.deepEqual( + buildServiceLaunchSpec("opencode.ps1", ["serve"], { platform: "win32" }).command, + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "opencode.ps1", "serve"], + ) + assert.equal( + buildServiceLaunchSpec(String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, ["serve"], { platform: "win32" }).command[0], + "wsl.exe", + ) + }) - 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", - "/home/dev/workspace", - "/home/dev/.opencode/bin/opencode", - "serve", + it("uses a Node trampoline for the verbatim cmd.exe batch command", () => { + const launch = buildServiceLaunchSpec(String.raw`C:\Program Files\OpenCode\opencode.cmd`, ["serve", "--service"], { + platform: "win32", + env: { ComSpec: "test-cmd.exe" }, + }) + + assert.equal(launch.command[0], process.execPath) + assert.equal(launch.command[1], "-e") + assert.equal(launch.command[3], "test-cmd.exe") + assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), [ + "/d", "/s", "/c", String.raw`""C:\Program Files\OpenCode\opencode.cmd" serve --service"`, ]) - assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:") + assert.equal(launch.command[5], "") + }) + + it("records a direct service contender PID", () => { + const launch = buildServiceLaunchSpec("opencode.exe", ["serve", "--service"], { + platform: "win32", + contenderFile: String.raw`C:\Temp\codenomad-contenders.txt`, + }) + + assert.equal(launch.command[0], process.execPath) + assert.equal(launch.command[3], "opencode.exe") + assert.equal(launch.command[5], String.raw`C:\Temp\codenomad-contenders.txt`) + assert.equal(launch.command[6], "false") }) + it("translates shared Windows state and contender files for WSL", () => { + const contenderFile = String.raw`C:\Temp\codenomad\contenders.txt` + const launch = buildServiceLaunchSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\opencode`, + ["serve", "--service"], + { + platform: "win32", + contenderFile, + env: { XDG_STATE_HOME: String.raw`C:\Temp\codenomad` }, + }, + ) + + assert.equal(launch.command[0], "wsl.exe") + assert.match(launch.command[6] ?? "", /wslpath -au/) + assert.equal(launch.command[8], contenderFile) + assert.equal(launch.env?.WSLENV, "XDG_STATE_HOME/p") + }) }) 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..6d8e4b127 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -10,12 +10,6 @@ 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 @@ -39,17 +33,26 @@ 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 }, + }), + subscribe: async () => ({ async *[Symbol.asyncIterator]() {} }), + evict: 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 } @@ -64,10 +67,10 @@ async function createSharedLaunch() { const manager = createManager(root) const launchGate = deferred() let launches = 0 - ;(manager as any).runtime.launch = async () => { + ;(manager as any).sharedService.validateLocation = async ({ directory }: { directory: string }) => { launches += 1 await launchGate.promise - return runtimeResult() + return { directory, project: { id: directory, directory, canonical: directory } } } const leader = manager.create(target, undefined, { requestId: "leader" }) const follower = manager.create(link, undefined, { requestId: "follower" }) @@ -153,7 +156,7 @@ describe("workspace identity", () => { const manager = createManager(root) const launchGate = deferred() let launches = 0 - ;(manager as any).runtime.launch = async () => { + ;(manager as any).sharedService.validateLocation = async () => { launches += 1 await launchGate.promise throw new Error("launch failed") @@ -167,7 +170,10 @@ describe("workspace identity", () => { assert.deepEqual((await Promise.allSettled(failures)).map((result) => result.status), ["rejected", "rejected"]) assert.equal(launches, 1) - ;(manager as any).runtime.launch = async () => runtimeResult(456) + ;(manager as any).sharedService.validateLocation = async ({ directory }: { directory: string }) => ({ + directory, + project: { id: directory, directory, canonical: directory }, + }) assert.equal((await manager.create(target)).created, true) }) 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..882ba9d65 --- /dev/null +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -0,0 +1,108 @@ +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 waitFor(check: () => boolean): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for event")), 1000) + const poll = () => { + if (check()) { + clearTimeout(timeout) + resolve() + } else { + setTimeout(poll, 0) + } + } + poll() + }) +} + +describe("InstanceEventBridge", () => { + it("routes root and owned worktree events to the logical workspace and caches ownership", async () => { + const events = [ + { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo-a" }, data: { id: "p1" } }, + { + 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: "4", created: 4, type: "server.connected", data: {} }, + { + 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 === 4) + assert.equal(received[0].instanceId, "a") + assert.deepEqual(received[0].event.location, { directory: "/repo-a" }) + assert.deepEqual(received[0].event.data, { id: "p1" }) + assert.deepEqual(received[0].event.properties, { id: "p1" }) + assert.equal(received[1].event.data.sessionID, "session-1") + assert.equal(received[1].event.properties.info.id, "session-1") + assert.equal(received[2].instanceId, "a") + assert.deepEqual(received[2].event.properties, { + sessionID: "session-2", + assistantMessageID: "message-1", + ordinal: 0, + delta: "hello", + }) + assert.equal(received[3].instanceId, "a") + assert.equal(received[3].event.properties.delta, " again") + assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2) + } finally { + bridge.shutdown() + } + }) +}) diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 5be037007..f9f680469 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -1,13 +1,11 @@ -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" -const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 }) const RECONNECT_DELAY_MS = 1000 +const DIRECTORY_OWNER_CACHE_MS = 2000 interface InstanceEventBridgeOptions { workspaceManager: WorkspaceManager @@ -15,212 +13,125 @@ 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 task?: Promise + private readonly directoryOwners = new Map }>() + private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { + this.directoryOwners.clear() + if (!this.task) this.task = this.run() + else this.publishStatus(event.workspace.id, this.status) + } + private readonly onWorkspaceStopped = (event: { workspaceId: string }) => { + this.directoryOwners.clear() + this.publishStatus(event.workspaceId, "disconnected", "workspace stopped") + } + private readonly onWorkspaceError = (event: { workspace: { id: string } }) => { + this.directoryOwners.clear() + 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.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) - } - }) - - this.streams.set(workspaceId, { controller, task }) - } - - private stopStream(workspaceId: string, reason?: string) { - const active = this.streams.get(workspaceId) - if (!active) { - return + 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") } - 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 - } - - this.publishStatus(workspaceId, "connecting") - + private async run() { + while (!this.controller.signal.aborted) { + this.updateStatus("connecting") try { - await this.consumeStream(workspaceId, port, signal) - } catch (error) { - if (signal.aborted) { - break + const events = await this.options.workspaceManager.subscribeToSharedService(this.controller.signal) + this.updateStatus("connected") + for await (const event of events) { + if (this.controller.signal.aborted) return + await this.publishEvent(event) } - 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 (!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) } } } - private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) { - const url = `http://${LOOPBACK_HOST}:${port}/global/event` + private async publishEvent(event: OpenCodeEvent) { + const directory = event.location?.directory + if (!directory) return - const headers: Record = { Accept: "text/event-stream" } - const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId) - if (authHeader) { - headers["Authorization"] = authHeader - } + const instanceId = await this.resolveDirectoryOwner(directory) + if (!instanceId) return - const response = await fetch(url, { - headers, - signal, - dispatcher: STREAM_AGENT, - }) - - if (!response.ok || !response.body) { - throw new Error(`Instance event stream unavailable (${response.status})`) - } - - 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) + // The server's auto-accept boundary still reads the legacy property name. + const compatibleEvent: InstanceStreamEvent = { + ...event, + properties: this.compatibilityProperties(event), } + this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent }) } - private flushEvents(buffer: string, workspaceId: string) { - let separatorIndex = buffer.indexOf("\n\n") + private resolveDirectoryOwner(directory: string): Promise { + const now = Date.now() + const cached = this.directoryOwners.get(directory) + if (cached && cached.expiresAt > now) return cached.owner - while (separatorIndex >= 0) { - const chunk = buffer.slice(0, separatorIndex) - buffer = buffer.slice(separatorIndex + 2) - this.processChunk(chunk, workspaceId) - separatorIndex = buffer.indexOf("\n\n") - } - - return buffer + const workspaces = this.options.workspaceManager.list() + const owner = Promise.all(workspaces.map((workspace) => ( + this.options.workspaceManager.ownsDirectory(workspace.id, directory) + ))) + .then((ownership) => workspaces.find((_, index) => ownership[index])?.id) + .catch((error) => { + this.options.logger.warn({ err: error, directory }, "Failed to resolve instance event directory owner") + return undefined + }) + this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owner }) + return owner } - private processChunk(chunk: string, workspaceId: string) { - 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()) - } + private compatibilityProperties(event: OpenCodeEvent): Record { + if (event.type === "session.created") { + return { info: { ...event.data, id: event.data.sessionID } } } - - if (dataLines.length === 0) { - return + if (event.type === "session.deleted") { + return { id: event.data.sessionID } } + return event.data as Record + } - const payload = dataLines.join("\n").trim() - if (!payload) { - return - } - - 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 - } - - // 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 event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null - - // 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 - } - - if (!event || typeof (event as any).type !== "string") { - this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event") - return - } - - 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.logger.debug({ instanceId, status, reason }, "Instance event status updated") this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, 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..720e90d46 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, } from "./manager" +import type { OpenCodeEnsureOptions } from "./opencode-service" +import path from "node:path" +import os from "node:os" function deferred() { let resolve!: (value: T) => void @@ -26,49 +23,75 @@ 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 +class ControlledSharedService { + readonly validationStarted = deferred() + validationGate?: ReturnType> + validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = [] + evictions: LocationRef[] = [] + failEvictions = 0 - 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 + async endpoint(options?: OpenCodeEnsureOptions) { + this.assertCommand(options) + 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(options?: OpenCodeEnsureOptions) { + this.assertCommand(options) + return { authorization: "Basic token" } + } + + async validateLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeEnsureOptions) { + this.assertCommand(options) + this.validationCalls.push({ location, options }) + this.validationStarted.resolve() + if (this.validationGate) { + 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() + }), + ]) + } + return { + directory: location.directory, + workspaceID: location.workspaceID ?? "location-1", + project: { id: "project-1", directory: location.directory, canonical: location.directory }, + } + } + + async subscribe(): Promise> { + return { async *[Symbol.asyncIterator]() {} } + } + + async evict(location: LocationRef) { + if (this.failEvictions-- > 0) throw new Error("controlled eviction failure") + this.evictions.push(location) + } + + async shutdown() {} + + private assertCommand(options?: OpenCodeEnsureOptions) { + assert.equal(options?.file, path.join(os.tmpdir(), "codenomad-opencode-v2", "opencode", "service.json")) + assert.equal(options?.environment?.XDG_STATE_HOME, path.join(os.tmpdir(), "codenomad-opencode-v2")) + assert.equal( + options?.command?.[5], + options?.environment?.CODENOMAD_SERVICE_CONTENDERS, + ) + assert.match(options?.environment?.CODENOMAD_SERVICE_CONTENDERS ?? "", new RegExp(`contenders-${process.pid}-.*\\.txt$`)) + assert.equal(options?.command?.[0], process.execPath) + assert.equal(options?.command?.[1], "-e") + assert.equal(options?.command?.[4], JSON.stringify(["serve", "--service"])) } } -function createHarness(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()) { 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)) @@ -76,313 +99,98 @@ function createHarness(options: { const manager = new WorkspaceManager({ rootDir: process.cwd(), settings: { getOwner: () => ({}) } as never, - binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } 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, }) - 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, started, stopped } } -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 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"' }], - }, - }) - 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 - - 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 - }) - 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(), []) - } finally { - globalThis.fetch = originalFetch - } +describe("workspace manager shared service lifecycle", () => { + it("creates a ready logical location without a workspace process", async () => { + const { manager, service, started } = createHarness() + const { workspace, created } = await manager.create(process.cwd()) + + assert.equal(created, true) + assert.equal(workspace.status, "ready") + assert.equal(workspace.pid, undefined) + assert.equal(workspace.port, undefined) + assert.equal(manager.getInstanceAuthorizationHeader(workspace.id), "Basic token") + assert.deepEqual(service.validationCalls.map(({ location }) => location), [{ directory: process.cwd() }]) + assert.deepEqual(started, [workspace.id]) }) - 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() - } - - 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("shares failed cleanup and allows a later delete retry", async () => { + it("shares one in-flight logical location creation", 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) + harness.service.validationGate = deferred() + const first = harness.manager.create(process.cwd()) + await harness.service.validationStarted.promise + const second = harness.manager.create(process.cwd()) + harness.service.validationGate.resolve() + + const [leader, follower] = await Promise.all([first, second]) + assert.equal(leader.workspace.id, follower.workspace.id) + assert.equal(Number(leader.created) + Number(follower.created), 1) + assert.equal(harness.service.validationCalls.length, 1) }) - it("retries cancellation deletion for an already-cancelled request", async () => { + it("evicts only after the last logical owner of a location is deleted", 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 + const first = await harness.manager.create(process.cwd()) + const forced = await harness.manager.create(process.cwd(), undefined, { forceNew: true }) - 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.delete(forced.workspace.id) + assert.deepEqual(harness.service.evictions, []) + assert.equal(harness.manager.get(first.workspace.id)?.status, "ready") - 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]) + await harness.manager.delete(first.workspace.id) + assert.deepEqual(harness.service.evictions, [{ directory: process.cwd(), workspaceID: "location-1" }]) + assert.deepEqual(harness.stopped, [forced.workspace.id, first.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) - } - - 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) + it("evicts a location once when duplicate owners are deleted concurrently", async () => { + const harness = createHarness() + const first = await harness.manager.create(process.cwd()) + const forced = await harness.manager.create(process.cwd(), undefined, { forceNew: true }) - 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 + await Promise.all([ + harness.manager.delete(first.workspace.id), + harness.manager.delete(forced.workspace.id), + ]) - 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) + assert.equal(harness.service.evictions.length, 1) + assert.deepEqual(harness.manager.list(), []) }) - it("retains unresolved pre-creation cancellation until its delayed create", async () => { + it("cancels validation and cleans its logical location", 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))) - - 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) + 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] + const deletion = harness.manager.delete(record.id) + + await assert.rejects(creation, WorkspaceLaunchCancelledError) + await deletion + assert.deepEqual(harness.manager.list(), []) + assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }]) }) - it("returns scoped correlation while an ordinary shared launch remains retained", async () => { + it("keeps a failed eviction retryable and reports shutdown failures", 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") + const { workspace } = await harness.manager.create(process.cwd()) + harness.service.failEvictions = 1 - 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) - }) - - 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)) - } - - 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 assert.rejects(harness.manager.shutdown(), (error: unknown) => { + assert.ok(error instanceof WorkspaceShutdownError) + assert.match(String(error.errors[0]), /controlled eviction failure/) + return true }) - } - - 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) - }) + assert.equal(harness.manager.get(workspace.id)?.status, "ready") - 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]) + await harness.manager.delete(workspace.id) + assert.equal(harness.manager.get(workspace.id), undefined) }) - - 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) - }) - } }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 6d39c9192..f0cd75a5f 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,8 +1,10 @@ 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 { mkdirSync } from "node:fs" +import os from "node:os" +import type { Endpoint } from "@opencode-ai/client/service" +import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" import type { BinaryResolver } from "../settings/binaries" @@ -10,33 +12,28 @@ 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" -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" +import { buildServiceLaunchSpec, parseWslUncPath } from "./spawn" +import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" +import { resolveWorktreeSlugForDirectory } from "./worktree-directory" -const STARTUP_STABILITY_DELAY_MS = 1500 const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 const ORDINARY_CREATION_OWNER = "" const WORKSPACE_STATE = Symbol("workspaceState") +const SERVICE_STATE_ROOT = path.join(os.tmpdir(), "codenomad-opencode-v2") +const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json") +const SERVICE_CONTENDER_FILE = path.join(SERVICE_STATE_ROOT, `contenders-${process.pid}-${randomUUID()}.txt`) type ManagerTimeout = ReturnType -interface WorkspaceRuntimeController { - launch: WorkspaceRuntime["launch"] - stop: WorkspaceRuntime["stop"] +interface SharedService { + endpoint: (options?: OpenCodeEnsureOptions) => Promise + client: (options?: OpenCodeEnsureOptions) => Promise + headers: (options?: OpenCodeEnsureOptions) => Promise<{ authorization: string } | undefined> + validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise + subscribe: (requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise> + evict: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise + shutdown: () => Promise } export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean { @@ -62,7 +59,7 @@ interface WorkspaceManagerOptions { getServerBaseUrl: () => string /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ nodeExtraCaCertsPath?: string - runtime?: Pick + sharedService?: SharedService shutdownTimeoutMs?: number launchSettlementTimeoutMs?: number launchTimeoutMs?: number @@ -72,6 +69,7 @@ interface WorkspaceManagerOptions { interface WorkspaceRecord extends WorkspaceDescriptor { identityKey: string + location?: LocationRef ownership: WorkspaceCreationOwnership [WORKSPACE_STATE]: WorkspaceState } @@ -119,31 +117,22 @@ 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 shuttingDown = false - private readonly runtime: Pick - private readonly codeNomadPluginUrl: string - private readonly opencodeAuth = new Map() + private readonly sharedService: SharedService + private serviceEndpoint?: Endpoint + private serviceAuthorization?: string 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 +144,40 @@ export class WorkspaceManager { return record?.[WORKSPACE_STATE].published ? record : undefined } - getInstancePort(id: string): number | undefined { - const record = this.workspaces.get(id) - return record?.[WORKSPACE_STATE].published ? record.port : undefined + getInstanceAuthorizationHeader(id: string): string | undefined { + return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.serviceAuthorization : 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.serviceEndpoint = endpoint + 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() + } + + async ownsDirectory(id: string, directory: string): Promise { + const workspace = this.get(id) + if (!workspace) return false + return (await resolveWorktreeSlugForDirectory({ + workspaceId: id, + workspacePath: workspace.path, + directory, + logger: this.options.logger, + })) !== null + } + + subscribeToSharedService(signal?: AbortSignal): Promise> { + return this.sharedService.subscribe({ signal }) } findReadyInstanceIdByBinary(binaryPath: string): string | undefined { @@ -309,7 +325,7 @@ export class WorkspaceManager { launchDeadlineAt: number, ): WorkspaceRecord { const id = randomUUID() - const binary = this.options.binaryResolver.resolve(options.binaryPath) + const binary = this.options.binaryResolver.resolveDefault() const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - Date.now())) clearWorkspaceSearchCache(workspacePath) @@ -361,7 +377,7 @@ export class WorkspaceManager { } }, timeoutMs) try { - return await this.createResolvedWorkspace(record, options) + return await this.createResolvedWorkspace(record) } finally { if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) } @@ -385,99 +401,60 @@ export class WorkspaceManager { } private async createResolvedWorkspace( record: WorkspaceRecord, - options: WorkspaceCreateOptions, ): Promise { const state = record[WORKSPACE_STATE] - const { id, path: workspacePath, binaryId: resolvedBinaryPath, proxyPath } = record + const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record + const serverConfig = this.options.settings.getOwner("config", "server") + const configuredEnvironment = this.readConfiguredEnvironment(serverConfig) + if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath + configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT + configuredEnvironment.CODENOMAD_SERVICE_CONTENDERS = SERVICE_CONTENDER_FILE + mkdirSync(SERVICE_STATE_ROOT, { recursive: true }) + const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], { + env: { ...process.env, ...configuredEnvironment }, + propagateEnvKeys: Object.keys(configuredEnvironment), + contenderFile: SERVICE_CONTENDER_FILE, + }) + const ensureOptions: OpenCodeEnsureOptions = { + file: SERVICE_REGISTRATION_FILE, + command: launch.command, + environment: { + ...configuredEnvironment, + ...(launch.env?.WSLENV ? { WSLENV: launch.env.WSLENV } : {}), + }, + } try { 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 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 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 - + record.location = { directory: workspacePath } + const [endpoint, headers, location] = await Promise.all([ + this.sharedService.endpoint(ensureOptions), + this.sharedService.headers(ensureOptions), + this.sharedService.validateLocation( + { directory: workspacePath }, + { signal: state.abortController.signal }, + ensureOptions, + ), + ]) + this.serviceEndpoint = endpoint + this.serviceAuthorization = headers?.authorization + record.location = { directory: location.directory, workspaceID: location.workspaceID } 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, - }) - 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") + this.options.logger.info({ workspaceId: id, location: record.location }, "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) { + if (!state.deletePromise) { + await this.evictLocationIfUnused(record).catch((cleanupError) => { + this.options.logger.warn({ workspaceId: id, err: cleanupError }, "Failed to evict rejected workspace location") + }) this.removeRecord(id, record, state.published) - throw launchFailure - } - if (!state.published) { - throw stopFailure - } - 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 }) } this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start") throw launchFailure @@ -588,6 +565,7 @@ export class WorkspaceManager { if (this.workspaces.size === 0) { this.pendingWorkspaceCreations.clear() this.cancelledCreationRequests.clear() + await this.sharedService.shutdown().catch((error) => stopFailures.push(error)) } else if (!stopFailures.length) stopFailures.push( new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`), ) @@ -621,23 +599,25 @@ 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") - }) await this.withTimeout(record[WORKSPACE_STATE].settlement!, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`) - await immediateStop - await this.runtime.stop(id) - + await this.evictLocationIfUnused(record) this.removeRecord(id, record, true) return record } + private async evictLocationIfUnused(record: WorkspaceRecord): Promise { + if (!record.location) return + const peers = Array.from(this.workspaces.values()).filter((candidate) => { + return candidate !== record && candidate.identityKey === record.identityKey + }) + if (peers.some((candidate) => !candidate[WORKSPACE_STATE].deletePromise)) return + if (peers.some((candidate) => candidate.id < record.id)) return + await this.sharedService.evict(record.location) + } + private removeRecord(id: string, record: WorkspaceRecord, publishStopped: boolean): 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") } @@ -651,6 +631,15 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.id, reason }) } + private readConfiguredEnvironment(serverConfig: unknown): NodeJS.ProcessEnv { + if (!serverConfig || typeof serverConfig !== "object" || Array.isArray(serverConfig)) return {} + const environment = (serverConfig as { environmentVariables?: unknown }).environmentVariables + if (!environment || typeof environment !== "object" || Array.isArray(environment)) return {} + return Object.fromEntries( + Object.entries(environment).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) + } + resolveBinaryPath(identifier: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { if (!identifier) { return identifier @@ -703,192 +692,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-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts new file mode 100644 index 000000000..ecff89906 --- /dev/null +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -0,0 +1,298 @@ +import assert from "node:assert/strict" +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import type { OpenCodeClient } from "@opencode-ai/client" + +import { OpenCodeSharedService } from "./opencode-service" + +describe("OpenCodeSharedService", () => { + it("lazily ensures one authenticated service for concurrent callers", async () => { + let ensureCalls = 0 + let makeCalls = 0 + const client = { + location: { get: async () => ({ + directory: "/repo", + workspaceID: "workspace-1", + project: { id: "project-1", directory: "/repo", canonical: "/repo" }, + }) }, + } as unknown as OpenCodeClient + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => { + ensureCalls += 1 + await new Promise((resolve) => setImmediate(resolve)) + return { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "user", password: "pass" } } + }, + headers: () => ({ authorization: "Basic token" }), + stop: async () => undefined, + makeClient: (options) => { + makeCalls += 1 + assert.equal(options.baseUrl, "http://127.0.0.1:4321") + assert.deepEqual(options.headers, { authorization: "Basic token" }) + return client + }, + }) + + assert.equal(ensureCalls, 0) + const [endpoint, resolvedClient, location] = await Promise.all([ + service.endpoint(), + service.client(), + service.validateLocation({ directory: "/repo" }), + ]) + + assert.equal(endpoint.url, "http://127.0.0.1:4321") + assert.strictEqual(resolvedClient, client) + assert.equal(location.workspaceID, "workspace-1") + assert.deepEqual([ensureCalls, makeCalls], [1, 1]) + }) + + it("uses the generated location, event, and eviction APIs", async () => { + const calls: unknown[] = [] + const signal = new AbortController().signal + const events = { async *[Symbol.asyncIterator]() { yield { type: "server.connected" } as never } } + const client = { + location: { + get: async (...args: unknown[]) => { + calls.push(["get", ...args]) + return { directory: "/repo", project: { id: "p", directory: "/repo", canonical: "/repo" } } + }, + }, + event: { subscribe: (...args: unknown[]) => { calls.push(["subscribe", ...args]); return events } }, + debug: { location: { evict: async (...args: unknown[]) => { calls.push(["evict", ...args]) } } }, + } as unknown as OpenCodeClient + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => ({ url: "https://localhost:4321" }), + headers: () => undefined, + stop: async () => undefined, + makeClient: () => client, + }) + + await service.validateLocation({ directory: "/repo", workspaceID: "ws" }, { signal }) + const subscribed = await service.subscribe({ signal }) + const iterator = subscribed[Symbol.asyncIterator]() + assert.deepEqual(await iterator.next(), { value: { type: "server.connected" }, done: false }) + await iterator.return?.() + await service.evict({ directory: "/repo", workspaceID: "ws" }, { signal }) + + assert.deepEqual(calls, [ + ["get", { location: { directory: "/repo", workspace: "ws" } }, { signal }], + ["subscribe", { signal }], + ["evict", { location: { directory: "/repo", workspace: "ws" } }, { signal }], + ]) + }) + + it("rejects malformed endpoints and locations", async () => { + const invalidEndpoint = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => ({ url: "file:///tmp/opencode" }), + headers: () => undefined, + stop: async () => undefined, + makeClient: () => { throw new Error("client should not be created") }, + }) + await assert.rejects(invalidEndpoint.endpoint(), /Unsupported OpenCode service protocol/) + + const invalidLocation = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => ({ url: "http://localhost:4321" }), + headers: () => undefined, + stop: async () => undefined, + makeClient: () => ({ location: { get: async () => ({ directory: "/repo" }) } }) as unknown as OpenCodeClient, + }) + await assert.rejects(invalidLocation.validateLocation({ directory: "/repo" }), /invalid location/) + }) + + it("clears a failed ensure so the next caller can retry", async () => { + let calls = 0 + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => { + calls += 1 + if (calls === 1) throw new Error("not started") + return { url: "http://localhost:4321" } + }, + headers: () => undefined, + stop: async () => undefined, + makeClient: () => ({} as OpenCodeClient), + }) + + await assert.rejects(service.endpoint(), /not started/) + assert.equal((await service.endpoint()).url, "http://localhost:4321") + assert.equal(calls, 2) + }) + + it("rediscovers after transport failure", async () => { + let ensures = 0 + let gets = 0 + const endpoint = { url: "http://localhost:4321" } + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => { + ensures += 1 + return endpoint + }, + headers: () => undefined, + stop: async () => undefined, + makeClient: () => ({ + location: { get: async () => { + gets += 1 + if (gets === 1) throw new TypeError("fetch failed") + return { directory: "/repo", project: { id: "p", directory: "/repo", canonical: "/repo" } } + } }, + }) as unknown as OpenCodeClient, + }) + + await assert.rejects(service.validateLocation({ directory: "/repo" }), /fetch failed/) + await service.validateLocation({ directory: "/repo" }) + assert.equal(ensures, 2) + }) + + it("stops only when registration proves its contender won", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-")) + const file = path.join(root, "service.json") + const contenders = path.join(root, "contenders.txt") + const previous = process.env.CODENOMAD_SERVICE_TEST + const stopFiles: Array = [] + const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" } + const createService = (contenderPid: number) => new OpenCodeSharedService({ + discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }), + ensure: async (options) => { + assert.equal(process.env.CODENOMAD_SERVICE_TEST, "configured") + options?.onStart?.("missing") + await writeFile(contenders, `${contenderPid}\n`) + await writeFile(file, JSON.stringify(info)) + return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } } + }, + headers: () => undefined, + stop: async (options) => { stopFiles.push(options?.file) }, + makeClient: () => ({} as OpenCodeClient), + }) + + try { + const lost = createService(9999) + await lost.endpoint({ file, environment: { + CODENOMAD_SERVICE_TEST: "configured", + CODENOMAD_SERVICE_CONTENDERS: contenders, + } }) + assert.equal(process.env.CODENOMAD_SERVICE_TEST, previous) + await lost.shutdown() + assert.deepEqual(stopFiles, []) + + const won = createService(info.pid) + await won.endpoint({ file, environment: { + CODENOMAD_SERVICE_TEST: "configured", + CODENOMAD_SERVICE_CONTENDERS: contenders, + } }) + await won.shutdown() + assert.deepEqual(stopFiles, [file]) + } finally { + if (previous === undefined) delete process.env.CODENOMAD_SERVICE_TEST + else process.env.CODENOMAD_SERVICE_TEST = previous + await rm(root, { recursive: true, force: true }) + } + }) + + it("retains possible ownership after discovery failure and retries shutdown", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-retry-")) + const file = path.join(root, "service.json") + const contenders = path.join(root, "contenders.txt") + const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" } + let stops = 0 + const service = new OpenCodeSharedService({ + discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }), + ensure: async (options) => { + options?.onStart?.("missing") + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, JSON.stringify(info)) + await writeFile(contenders, `${info.pid}\n`) + return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } } + }, + headers: () => undefined, + stop: async () => { stops += 1 }, + makeClient: () => ({} as OpenCodeClient), + }) + + try { + await service.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: contenders } }) + await rm(file) + await service.shutdown() + assert.equal(stops, 0) + + await writeFile(file, JSON.stringify({ ...info, id: "replacement", pid: 5678 })) + await service.shutdown() + assert.equal(stops, 0) + + await writeFile(file, JSON.stringify(info)) + await Promise.all([service.shutdown(), service.shutdown()]) + assert.equal(stops, 1) + await service.shutdown() + assert.equal(stops, 1) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it("lets only the CodeNomad whose contender won stop a concurrent service", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-election-")) + const file = path.join(root, "service.json") + const firstContenders = path.join(root, "first.txt") + const secondContenders = path.join(root, "second.txt") + const info = { id: "winner", url: "http://localhost:4321", pid: 2222, password: "secret" } + const starts = deferred() + let ready = false + let stops = 0 + const createService = (contenderFile: string, pid: number) => new OpenCodeSharedService({ + discover: async () => ready + ? { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } } + : undefined, + ensure: async (options) => { + options?.onStart?.("missing") + await writeFile(contenderFile, `${pid}\n`) + await starts.promise + return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } } + }, + headers: () => undefined, + stop: async () => { stops += 1 }, + makeClient: () => ({} as OpenCodeClient), + }) + const first = createService(firstContenders, info.pid) + const second = createService(secondContenders, 3333) + + try { + const connections = [ + first.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: firstContenders } }), + second.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: secondContenders } }), + ] + await Promise.all([readWhenPresent(firstContenders), readWhenPresent(secondContenders)]) + await writeFile(file, JSON.stringify(info)) + ready = true + starts.resolve() + await Promise.all(connections) + + await Promise.all([first.shutdown(), second.shutdown()]) + assert.equal(stops, 1) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} + +async function readWhenPresent(file: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + await access(file) + return + } catch { + await new Promise((resolve) => setImmediate(resolve)) + } + } + throw new Error(`Timed out waiting for ${file}`) +} diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts new file mode 100644 index 000000000..760cc31ca --- /dev/null +++ b/packages/server/src/workspaces/opencode-service.ts @@ -0,0 +1,271 @@ +import { + OpenCode, + type LocationGetOutput, + type LocationRef, + type OpenCodeClient, + type OpenCodeEvent, +} from "@opencode-ai/client" +import { Service, type Endpoint, type EnsureOptions, type Info, type StopOptions } from "@opencode-ai/client/service" +import { readFile } from "node:fs/promises" + +type RequestOptions = { signal?: AbortSignal } +export type OpenCodeEnsureOptions = EnsureOptions & { environment?: NodeJS.ProcessEnv } + +interface ServiceConnection { + endpoint: Endpoint + client: OpenCodeClient + stopOptions: StopOptions +} + +export interface OpenCodeSharedServiceDependencies { + discover: typeof Service.discover + ensure: typeof Service.ensure + headers: typeof Service.headers + stop: typeof Service.stop + makeClient: typeof OpenCode.make +} + +export class OpenCodeSharedService { + private connection?: Promise + private connected?: ServiceConnection + private healthCheck?: Promise + private ensureOptions?: OpenCodeEnsureOptions + private owned?: { stopOptions: StopOptions; info: Info } + private shutdownAttempt?: Promise + + constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { + discover: Service.discover, + ensure: Service.ensure, + headers: Service.headers, + stop: Service.stop, + makeClient: OpenCode.make, + }) {} + + endpoint(options?: OpenCodeEnsureOptions): Promise { + return this.connect(options).then(({ endpoint }) => endpoint) + } + + client(options?: OpenCodeEnsureOptions): Promise { + return this.connect(options).then(({ client }) => client) + } + + async headers(options?: OpenCodeEnsureOptions): Promise> { + return this.dependencies.headers(await this.endpoint(options)) + } + + async validateLocation( + location: LocationRef, + requestOptions?: RequestOptions, + ensureOptions?: OpenCodeEnsureOptions, + ): Promise { + const result = await this.withClient(ensureOptions, (client) => client.location.get({ + location: { directory: location.directory, workspace: location.workspaceID }, + }, 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") + } + return result + } + + async subscribe(requestOptions?: RequestOptions, ensureOptions?: OpenCodeEnsureOptions): Promise> { + let connection: ServiceConnection | undefined + try { + connection = await this.connect(ensureOptions) + const events = connection.client.event.subscribe(requestOptions) + return this.invalidateAfterStream(events, connection) + } catch (error) { + if (connection) this.invalidateConnection(connection) + throw error + } + } + + async evict( + location: LocationRef, + requestOptions?: RequestOptions, + ensureOptions?: OpenCodeEnsureOptions, + ): Promise { + await this.withClient(ensureOptions, (client) => client.debug.location.evict({ + location: { directory: location.directory, workspace: location.workspaceID }, + }, requestOptions)) + } + + shutdown(): Promise { + if (this.shutdownAttempt) return this.shutdownAttempt + const attempt = this.stopOwnedService().finally(() => { + if (this.shutdownAttempt === attempt) this.shutdownAttempt = undefined + }) + this.shutdownAttempt = attempt + return attempt + } + + private async stopOwnedService(): Promise { + const owned = this.owned + if (!owned) return + const current = await this.readInfo(owned.stopOptions.file).catch(() => undefined) + // ponytail: uncertain discovery retains ownership for a later shutdown retry. + if (!current) return + if (!this.sameInfo(current, owned.info)) return + await this.dependencies.stop(owned.stopOptions) + if (this.owned === owned) this.owned = undefined + this.clear() + } + + private connect(options?: OpenCodeEnsureOptions): Promise { + const selectedOptions = options ?? this.ensureOptions ?? {} + if (!this.connected) return this.connection ?? this.startConnection(selectedOptions) + if (this.healthCheck) return this.healthCheck + + const current = this.connection! + const { environment: _environment, onStart: _onStart, command: _command, ...discoverOptions } = selectedOptions + const check = this.dependencies.discover(discoverOptions).then((endpoint) => { + if (endpoint && this.sameEndpoint(endpoint, this.connected!.endpoint)) return this.connected! + this.invalidate(current) + return this.startConnection(selectedOptions) + }, () => { + this.invalidate(current) + return this.startConnection(selectedOptions) + }) + const healthCheck = check.finally(() => { + if (this.healthCheck === healthCheck) this.healthCheck = undefined + }) + this.healthCheck = healthCheck + return healthCheck + } + + private startConnection(options: OpenCodeEnsureOptions): Promise { + this.ensureOptions = options + const { environment, ...ensureOptions } = options + const onStart = ensureOptions.onStart + const pending = this.withEnvironment(environment, () => this.dependencies.ensure({ + ...ensureOptions, + onStart: (reason, previousVersion) => { + onStart?.(reason, previousVersion) + }, + })).then((endpoint) => { + const url = new URL(endpoint.url) + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Unsupported OpenCode service protocol: ${url.protocol}`) + } + const connection = { + endpoint, + stopOptions: { file: ensureOptions.file }, + client: this.dependencies.makeClient({ + baseUrl: endpoint.url, + headers: this.dependencies.headers(endpoint), + }), + } + const contenderFile = environment?.CODENOMAD_SERVICE_CONTENDERS + return this.proveOwnership(connection.stopOptions.file, contenderFile).then((info) => { + if (info) { + this.owned = { stopOptions: connection.stopOptions, info } + } + this.connected = connection + return connection + }) + }) + const connection = pending.catch((error) => { + this.invalidate(connection) + throw error + }) + this.connection = connection + return connection + } + + private async withClient( + options: OpenCodeEnsureOptions | undefined, + run: (client: OpenCodeClient) => Promise, + ): Promise { + let connection: ServiceConnection | undefined + try { + connection = await this.connect(options) + 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 invalidate(pending: Promise): void { + if (this.connection !== pending) return + this.clear() + } + + private clear(): void { + this.connection = undefined + this.connected = undefined + this.healthCheck = 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 async proveOwnership(file: string | undefined, contenderFile: string | undefined): Promise { + if (!file || !contenderFile) return undefined + const [info, contenders] = await Promise.all([ + this.readInfo(file).catch(() => undefined), + readFile(contenderFile, "utf8").catch(() => ""), + ]) + if (!info || !contenders.split(/\r?\n/).includes(String(info.pid))) return undefined + return info + } + + private async readInfo(file: string | undefined): Promise { + if (!file) return undefined + const value: unknown = JSON.parse(await readFile(file, "utf8")) + if (typeof value !== "object" || value === null) return undefined + if (!("url" in value) || typeof value.url !== "string") return undefined + if (!("id" in value) || typeof value.id !== "string" || !value.id) return undefined + if (!("pid" in value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined + return value as Info + } + + private sameInfo(left: Info, right: Info): boolean { + return left.id === right.id + && left.version === right.version + && left.url === right.url + && left.pid === right.pid + && left.password === right.password + } + + private async withEnvironment(environment: NodeJS.ProcessEnv | undefined, run: () => Promise): Promise { + const entries = Object.entries(environment ?? {}) + if (!entries.length) return run() + + // ponytail: Service.ensure has no env option, so overlay only for its one + // shared launch and restore immediately. Replace when the SDK accepts env. + const previous = new Map(entries.map(([key]) => [key, process.env[key]])) + for (const [key, value] of entries) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + try { + return await run() + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + } +} 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/spawn.ts b/packages/server/src/workspaces/spawn.ts index 52ec18d79..f2fb6bbe0 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -7,13 +7,7 @@ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i -const 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 WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", "XDG_STATE_HOME"]) const WINDOWS_DIRECT_EXTENSIONS = new Set([".com", ".exe"]) const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" const WINDOWS_SHELL_NAMES = new Set([ @@ -41,18 +35,20 @@ export interface SpawnSpec { } cwd?: string env?: NodeJS.ProcessEnv - wsl?: { - distro: string - pidMarker?: string - } + wsl?: { distro: string } +} + +export interface ServiceLaunchSpec { + command: string[] + env?: NodeJS.ProcessEnv } interface BuildSpawnSpecOptions { cwd?: string env?: NodeJS.ProcessEnv propagateEnvKeys?: string[] - wslPidMarker?: string platform?: NodeJS.Platform + contenderFile?: string } interface WslPath { @@ -155,6 +151,46 @@ export function buildSpawnSpec(binaryPath: string, args: string[], options: Buil return buildWindowsSpawnSpec(binaryPath, args, options) } +export function buildServiceLaunchSpec( + binaryPath: string, + args: string[], + options: BuildSpawnSpecOptions = {}, +): ServiceLaunchSpec { + const spec = buildSpawnSpec(binaryPath, args, options) + if (spec.processKind === "wsl") { + return { command: [spec.command, ...spec.args], env: spec.env } + } + const contenderFile = spec.processKind === "posix" || spec.processKind === "windows-direct" + ? options.contenderFile + : undefined + if (!spec.options.windowsVerbatimArguments && !contenderFile) { + return { command: [spec.command, ...spec.args], env: spec.env } + } + + // Service.ensure cannot pass spawn options or expose contender PIDs. A Node + // trampoline supplies both for commands whose child PID is the service PID. + const launcher = [ + 'const { spawn } = require("node:child_process")', + 'const { appendFileSync } = require("node:fs")', + 'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { stdio: "inherit", windowsVerbatimArguments: process.argv[4] === "true" })', + 'if (process.argv[3]) appendFileSync(process.argv[3], `${child.pid}\\n`)', + 'child.once("error", (error) => { console.error(error); process.exit(1) })', + 'child.once("exit", (code) => process.exit(code ?? 1))', + ].join(";") + return { + command: [ + process.execPath, + "-e", + launcher, + spec.command, + JSON.stringify(spec.args), + contenderFile ?? "", + String(Boolean(spec.options.windowsVerbatimArguments)), + ], + env: spec.env, + } +} + export function probeBinaryVersion(binaryPath: string): { valid: boolean version?: string @@ -212,7 +248,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 +255,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" || Boolean(options.contenderFile) if (!shouldWrapWithShell && workingDirectory?.kind === "linux") { wslArgs.push("--cd", workingDirectory.path) } if (shouldWrapWithShell) { - const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker, shouldTranslatePluginPath) + const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, Boolean(options.contenderFile)) wslArgs.push( "--exec", "sh", @@ -235,6 +270,9 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn launchScript, "codenomad-wsl-launch", ) + if (options.contenderFile) { + wslArgs.push(options.contenderFile) + } if (workingDirectory) { wslArgs.push(workingDirectory.path) } @@ -252,7 +290,7 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn processKind: "wsl", options: {}, env, - wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker }, + wsl: { distro: wslPath.distro }, } } @@ -316,17 +354,12 @@ function unquoteWindowsPathEntry(entry: string): string { : trimmed } -function buildWslLaunchScript( - workingDirectory: WslWorkingDirectory | undefined, - pidMarker: string | undefined, - translatePluginPath: boolean, -): string { +function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, recordContender: boolean): 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 (recordContender) { + steps.push('printf "%s\\n" "$$" >> "$(wslpath -au "$1")"') + steps.push("shift") } if (workingDirectory?.kind === "linux") { @@ -337,12 +370,6 @@ 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(" && ") } @@ -360,20 +387,16 @@ function normalizeWindowsPath(input: string): string | null { return null } -function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined { +function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys?: string[]): 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), - ]), - ) + const keysToPropagate = Array.from(new Set([ + ...(propagateEnvKeys ?? []), + ...WSL_PATH_ENV_KEYS, + ])).filter((key) => next[key] !== undefined) if (keysToPropagate.length === 0) { return next } @@ -394,22 +417,6 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey 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 @@ -422,7 +429,3 @@ function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): str 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/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/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/ui/package.json b/packages/ui/package.json index 8c3653908..b90817730 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-next-17288", "@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 38fde7ff0..2fa50eec4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -384,8 +384,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 ?? "" @@ -400,19 +400,19 @@ const App: Component = () => { return recent?.projectName?.trim() || getPathBasename(folderPath) } - async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) { + async function handleSelectFolder(folderPath: string, options?: { forceNew?: boolean }) { if (!folderPath) { return } - 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, { forceNew: options?.forceNew }) + recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, folderPath) if (result.reused) { selectInstanceTab(result.instanceId) setShowFolderSelection(false) @@ -441,10 +441,10 @@ const App: Component = () => { } } - function handleSelectExistingInstance(instanceId: string, recentPath: string, binaryPath: string) { + 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 }) diff --git a/packages/ui/src/components/background-process-output-dialog.tsx b/packages/ui/src/components/background-process-output-dialog.tsx deleted file mode 100644 index 64ff1e4f9..000000000 --- a/packages/ui/src/components/background-process-output-dialog.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { Dialog } from "@kobalte/core/dialog" -import { Show, createEffect, createSignal, onCleanup } from "solid-js" -import type { BackgroundProcess } from "../../../server/src/api-types" -import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client" -import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi" -import { useI18n } from "../lib/i18n" - -interface BackgroundProcessOutputDialogProps { - open: boolean - instanceId: string - process: BackgroundProcess | null - onClose: () => void -} - -export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) { - const { t } = useI18n() - const [output, setOutput] = createSignal("") - const [outputHtml, setOutputHtml] = createSignal("") - const [ansiEnabled, setAnsiEnabled] = createSignal(false) - const [truncated, setTruncated] = createSignal(false) - const [loading, setLoading] = createSignal(false) - let ansiRenderer = createAnsiStreamRenderer() - - createEffect(() => { - const process = props.process - if (!props.open || !process) { - return - } - - let eventSource: EventSource | null = null - let active = true - - let rawOutput = "" - - const setRawOutput = (next: string) => { - rawOutput = next - setOutput(next) - } - - const appendRawOutput = (chunk: string) => { - rawOutput += chunk - setOutput(rawOutput) - } - - setAnsiEnabled(false) - setOutputHtml("") - setRawOutput("") - ansiRenderer.reset() - - setLoading(true) - serverApi - .fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined }) - .then((response) => { - if (!active) return - - setRawOutput(response.content) - setTruncated(response.truncated) - - const detectedAnsi = hasAnsi(response.content) - if (detectedAnsi) { - setAnsiEnabled(true) - ansiRenderer.reset() - setOutputHtml(ansiRenderer.render(response.content)) - } else { - setAnsiEnabled(false) - setOutputHtml("") - ansiRenderer.reset() - } - }) - .catch(() => { - if (!active) return - setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback")) - setAnsiEnabled(false) - setOutputHtml("") - }) - .finally(() => { - if (!active) return - setLoading(false) - }) - - eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any) - eventSource.onmessage = (event) => { - try { - const payload = JSON.parse(event.data) as { type?: string; content?: string } - if (payload?.type !== "chunk" || typeof payload.content !== "string") { - return - } - - const chunk = payload.content - const wasAnsiEnabled = ansiEnabled() - - if (!wasAnsiEnabled) { - appendRawOutput(chunk) - - if (hasAnsi(chunk)) { - setAnsiEnabled(true) - ansiRenderer.reset() - setOutputHtml(ansiRenderer.render(rawOutput)) - } - - return - } - - appendRawOutput(chunk) - const htmlChunk = ansiRenderer.render(chunk) - setOutputHtml((prev) => `${prev}${htmlChunk}`) - } catch { - // ignore parse errors - } - } - - onCleanup(() => { - active = false - eventSource?.close() - }) - }) - - return ( - !open && props.onClose()} modal> - - -
- -
-
- {t("backgroundProcessOutputDialog.title")} - - - {props.process?.title} · {props.process?.id} - - - {props.process?.command} - - -
- - -
-
- -

{t("backgroundProcessOutputDialog.loading")}

-
- - -

{t("backgroundProcessOutputDialog.truncatedNotice")}

-
- - {output()} - - } - > -
-                
-              
-            
-
-
-
-
- ) -} diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index af499dcfe..92e29c2b6 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -30,8 +30,8 @@ type HomeTab = "local" | "servers" interface FolderSelectionViewProps { - onSelectFolder: (folder: string, binaryPath?: string, options?: { forceNew?: boolean }) => void - onSelectExistingInstance: (instanceId: string, recentPath: string, binaryPath: string) => void + onSelectFolder: (folder: string, options?: { forceNew?: boolean }) => void + onSelectExistingInstance: (instanceId: string, recentPath: string) => void onOpenSidecar?: () => void isLoading?: boolean onClose?: () => void @@ -42,7 +42,6 @@ const FolderSelectionView: Component = (props) => { recentFolders, removeRecentFolder, renameRecentFolderProject, - serverSettings, } = useConfig() const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() const { t } = useI18n() @@ -50,7 +49,6 @@ const FolderSelectionView: Component = (props) => { const [hoveredRecentActionPath, setHoveredRecentActionPath] = createSignal(null) const [focusedRecentActionPath, setFocusedRecentActionPath] = createSignal(null) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") - const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isCloneDialogOpen, setIsCloneDialogOpen] = createSignal(false) const [isCloneDestinationBrowserOpen, setIsCloneDestinationBrowserOpen] = createSignal(false) @@ -76,14 +74,6 @@ const FolderSelectionView: Component = (props) => { return activeTab() === "local" ? folders().length : serverList().length } - // Update selected binary when preferences change - createEffect(() => { - const lastUsed = serverSettings().opencodeBinary - if (!lastUsed) return - setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) - }) - - function scrollToIndex(index: number) { const container = recentListRef if (!container) return @@ -291,12 +281,12 @@ const FolderSelectionView: Component = (props) => { function handleFolderSelect(path: string, forceNew = false) { if (isLoading()) return - props.onSelectFolder(path, selectedBinary(), forceNew ? { forceNew: true } : undefined) + props.onSelectFolder(path, forceNew ? { forceNew: true } : undefined) } function handleExistingInstanceSelect(instanceId: string, recentPath: string) { if (isLoading()) return - props.onSelectExistingInstance(instanceId, recentPath, selectedBinary()) + props.onSelectExistingInstance(instanceId, recentPath) } function setRecentActionHovered(path: string, active: boolean) { diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index 15e3d17b1..3918eeee2 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -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) => { @@ -65,7 +61,6 @@ const InstanceServiceStatus: Component = (props) => 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 ?? []) @@ -91,10 +86,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) { @@ -118,33 +117,10 @@ const InstanceServiceStatus: Component = (props) => 0} - fallback={renderEmptyState(isLspLoading() ? t("instanceServiceStatus.lsp.loading") : t("instanceServiceStatus.lsp.empty"))} + when={isLspLoading()} + fallback={renderEmptyState(t("instanceServiceStatus.lsp.empty"))} > -
- - {(server) => ( -
-
-
- {server.name ?? server.id} - - {server.root} - -
-
-
- - {server.status === "connected" - ? t("instanceServiceStatus.lsp.status.connected") - : t("instanceServiceStatus.lsp.status.error")} - -
-
-
- )} - -
+ {renderEmptyState(t("instanceServiceStatus.lsp.loading"))} ) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 004d85d1e..d47ee6ba5 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" @@ -35,9 +34,6 @@ 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" @@ -119,8 +115,6 @@ 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) const [now, setNow] = createSignal(Date.now()) const [sessionPromptApis, setSessionPromptApis] = createSignal>({}) @@ -141,7 +135,6 @@ const InstanceShell2: Component = (props) => { activeSessionForInstance, latestTodoState, tokenStats, - backgroundProcessList, handleSessionSelect, } = useInstanceSessionContext({ instanceId: () => props.instance.id, @@ -247,13 +240,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 @@ -561,32 +547,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 +741,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 +805,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 +1312,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..0b20164cd 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -14,7 +14,6 @@ interface CoreStatusSectionRenderers { renderYoloModeSection: () => JSX.Element renderProviderUsage: () => JSX.Element renderPlanSectionContent: () => JSX.Element - renderBackgroundProcesses: () => JSX.Element renderMcpStatus: () => JSX.Element renderLspStatus: () => JSX.Element renderPluginStatus: () => JSX.Element @@ -61,7 +60,6 @@ export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRend "yolo-mode": renderers.renderYoloModeSection, "provider-usage": renderers.renderProviderUsage, plan: renderers.renderPlanSectionContent, - "background-processes": renderers.renderBackgroundProcesses, mcp: renderers.renderMcpStatus, lsp: renderers.renderLspStatus, plugins: renderers.renderPluginStatus, diff --git a/packages/ui/src/components/instance/shell/right-panel/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..cc35638ee 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -70,7 +70,6 @@ describe("right panel plugin manifests", () => { renderYoloModeSection: render, renderProviderUsage: render, renderPlanSectionContent: render, - renderBackgroundProcesses: render, renderMcpStatus: render, renderLspStatus: render, renderPluginStatus: render, @@ -84,7 +83,6 @@ describe("right panel plugin manifests", () => { "yolo-mode", "provider-usage", "plan", - "background-processes", "mcp", "lsp", "plugins", diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index b9f6ceec2..bdc4f3ee5 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, RefreshCw, Save, Search, WrapText } from "lucide-solid" @@ -18,11 +17,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 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..d958d0d15 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,5 @@ import { For, Show, createMemo, type Accessor, type Component } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk/v2" +import type { ToolState } from "../../../../../types/tool-state" import { DragDropProvider, DragDropSensors, @@ -12,10 +12,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 } 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" @@ -38,11 +37,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 @@ -133,89 +127,6 @@ 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 renderProviderUsage = () => { const session = props.activeSession() if (!session) { @@ -233,7 +144,6 @@ const StatusTab: Component = (props) => { renderYoloModeSection, renderProviderUsage, renderPlanSectionContent, - renderBackgroundProcesses, renderMcpStatus: () => , renderLspStatus: () => , renderPluginStatus: () => ( 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 462b20a1e..e697db7cc 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,11 +1,12 @@ import { createEffect, createMemo, createSignal, lazy, type Accessor, type JSX } from "solid-js" -import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" 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" @@ -34,7 +35,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 +62,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 +121,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 +142,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 +159,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 +254,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) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts index 87933d77f..0c7ffe087 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/status-sections.ts @@ -19,12 +19,6 @@ export const CORE_STATUS_SECTION_ITEMS: readonly (RightPanelItem & { tooltipKey: tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", order: 30, }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", - order: 40, - }, { id: "mcp", labelKey: "instanceShell.rightPanel.sections.mcp", diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts index de1899893..171832567 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -1,11 +1,10 @@ import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" -import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { PromptInputApi } from "../../../prompt-input/types" import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types" 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 { serverEvents } from "../../../../lib/server-events" import { showToastNotification } from "../../../../lib/notifications" @@ -42,6 +41,13 @@ export function useGitChanges(options: UseGitChangesOptions) { 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 +174,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 diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index 3544ac482..6826eb197 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 } @@ -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..972a7ab1f 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,7 @@ 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" const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" @@ -44,11 +34,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 +274,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 +371,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 +387,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 +427,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() - ?
+ + diff --git a/packages/ui/src/renderer/prototype/prototype.css b/packages/ui/src/renderer/prototype/prototype.css new file mode 100644 index 000000000..186708065 --- /dev/null +++ b/packages/ui/src/renderer/prototype/prototype.css @@ -0,0 +1,378 @@ +@import "../../styles/tokens.css"; +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + overflow: hidden; + background: var(--surface-base); + color: var(--text-primary); + font: var(--font-size-base)/var(--line-height-normal) var(--font-family-sans); +} +button, textarea { color: inherit; font: inherit; } +button { cursor: pointer; background: transparent; border: 0; } +a { color: inherit; text-decoration: none; } +h1, h2, h3, ul, ol { margin: 0; } +ul, ol { padding: 0; list-style: none; } +small { color: var(--text-muted); font-size: var(--font-size-xs); } +:is(button, a, summary, textarea, input):focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; +} +.sr-only { + position: absolute; + width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip: rect(0, 0, 0, 0); + white-space: nowrap; border: 0; +} +.app-shell { + display: grid; + grid-template-rows: 48px minmax(0, 1fr); + height: 100%; min-width: 320px; +} +.workspace-strip { + position: relative; z-index: 2; + display: grid; + grid-template-columns: 232px minmax(0, 1fr) auto; + min-width: 0; + background: var(--surface-muted); + border-bottom: 1px solid var(--border-base); +} +:where(.brand, .workspace-tabs, .workspace-actions, .workspace-tab, + .sidebar-heading, .conversation-header, .trace-header, .message-heading, + .section-title-row, .composer-actions, .yolo-section, .conversation-meta, + .live-state, .composer-submit, .usage-list div, .switch-control) { + display: flex; + align-items: center; +} +:where(.sidebar-heading, .conversation-header, .trace-header, .section-title-row, + .composer-actions, .yolo-section, .usage-list div) { + justify-content: space-between; +} +.brand { + gap: var(--space-sm); + padding: 0 var(--space-lg); + font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); +} +.brand-mark { + display: grid; place-items: center; + width: 24px; height: 24px; + background: var(--accent-primary); color: var(--text-on-accent); + font: 9px var(--font-family-mono); letter-spacing: -.04em; +} +.workspace-tabs { min-width: 0; gap: var(--space-2xs); padding-top: 5px; } +.workspace-tab, .workspace-add { + height: 43px; + color: var(--text-muted); + font-size: var(--font-size-sm); +} +.workspace-tab { + position: relative; + gap: 7px; min-width: 0; max-width: 220px; + padding: 0 var(--space-md); + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; +} +.workspace-add { width: 40px; font-size: var(--font-size-lg); } +.workspace-actions { gap: var(--space-xs); padding: 0 var(--space-sm); } +:is(.workspace-tab, .workspace-add, .quiet-button, .icon-button, + .new-session, .row-action, .run-profile, .composer-button, .inspector-tab):hover { + background: var(--surface-hover); + color: var(--text-primary); +} +.workspace-tab.is-active { background: var(--surface-base); color: var(--text-primary); } +:is(.workspace-tab, .inspector-tab).is-active::after { + position: absolute; right: 0; bottom: 0; left: 0; + height: 2px; background: var(--accent-primary); content: ""; +} +:is(.workspace-state, .live-state span, .shell-state) { + width: 6px; height: 6px; flex: 0 0 auto; + background: var(--status-success); +} +:is(.eyebrow, .session-group, .branch-label, .message-heading time, .trace-count, + .trace-line code, .trace-line time, .trace-error strong, .section-title-row > span, + .usage-list dd, .switch-control, .shell-list strong, .plan-list li::before) { + font-family: var(--font-family-mono); +} +.branch-label { + padding: var(--space-xs) var(--space-sm); + color: var(--text-secondary); font-size: var(--font-size-xs); +} +.quiet-button, .icon-button, .new-session { + min-height: 30px; padding: 0 var(--space-sm); + color: var(--text-secondary); font-size: var(--font-size-sm); +} +.workbench { + display: grid; + grid-template-columns: 232px minmax(460px, 1fr) 280px; + min-height: 0; +} +.session-sidebar, .inspector, .conversation { min-width: 0; min-height: 0; } +.session-sidebar { + display: flex; flex-direction: column; + background: var(--surface-muted); + border-right: 1px solid var(--border-base); +} +.sidebar-heading { min-height: 72px; padding: var(--space-md) var(--space-lg); } +.eyebrow { + margin: 0 0 var(--space-2xs); + color: var(--text-muted); font-size: 10px; font-weight: var(--font-weight-medium); + letter-spacing: .11em; text-transform: uppercase; +} +.sidebar-heading h1 { font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); } +.new-session { background: var(--surface-secondary); color: var(--text-primary); } +.session-nav { flex: 1; overflow: auto; padding: 0 var(--space-sm) var(--space-lg); } +.session-group { + margin: var(--space-lg) var(--space-sm) var(--space-xs); + color: var(--text-muted); font-size: 10px; + letter-spacing: .08em; text-transform: uppercase; +} +.session-row { + display: flex; align-items: center; justify-content: space-between; + min-height: 52px; gap: var(--space-sm); padding: var(--space-sm); + border-left: 2px solid transparent; +} +.session-link { display: block; min-width: 0; flex: 1; } +.session-row:is(:hover, :focus-within) { background: var(--surface-secondary); } +.session-row.is-active { + background: var(--list-item-highlight-bg); + border-left-color: var(--accent-primary); +} +.session-copy { display: grid; min-width: 0; gap: var(--space-2xs); } +.session-copy strong { + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); +} +.row-action { + width: 26px; height: 26px; flex: 0 0 auto; + color: var(--text-secondary); opacity: 0; +} +.session-row:is(:hover, :focus-within) .row-action, .row-action:focus-visible { opacity: 1; } +.run-profile { + display: flex; align-items: center; + min-height: 46px; gap: 5px; padding: 0 var(--space-lg); + background: var(--surface-secondary); border-top: 1px solid var(--border-base); + color: var(--text-secondary); font-size: var(--font-size-xs); white-space: nowrap; +} +.profile-label { margin-right: auto; color: var(--text-muted); } +.conversation { + display: grid; + grid-template-rows: 58px minmax(0, 1fr) auto; + background: var(--surface-base); +} +.conversation-header { + padding: 0 clamp(var(--space-lg), 4vw, 44px); + border-bottom: 1px solid var(--border-base); +} +.conversation-header h2 { + font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); + letter-spacing: -.01em; +} +.conversation-meta { gap: var(--space-md); } +.live-state { gap: 6px; color: var(--status-ready-fg); font-size: var(--font-size-xs); } +.quiet-button.compact { min-height: 26px; border: 1px solid var(--border-base); } +.stream { overflow-y: auto; scrollbar-color: var(--border-base) transparent; scrollbar-width: thin; } +.stream > * { width: min(720px, calc(100% - 48px)); margin-inline: auto; } +.message { padding: 34px 0; } +.message-heading { justify-content: flex-start; gap: var(--space-sm); margin-bottom: var(--space-md); } +.message-heading h3 { font-size: var(--font-size-sm); letter-spacing: .02em; } +.message-heading time { color: var(--text-muted); font-size: 10px; } +.message p { margin: 0; color: var(--text-secondary); line-height: 1.68; } +.user-message { position: relative; padding-left: var(--space-lg); } +.user-message::before { + position: absolute; top: 34px; bottom: 34px; left: 0; + width: 2px; background: var(--accent-primary); content: ""; +} +.assistant-message { padding-bottom: 64px; } +.assistant-message .message-heading h3 { color: var(--accent-primary); } +.assistant-message p + p { margin-top: var(--space-md); } +.response-points { + display: grid; gap: var(--space-sm); + margin-top: var(--space-lg); padding-top: var(--space-lg); + border-top: 1px solid var(--border-base); + color: var(--text-secondary); font-size: var(--font-size-sm); +} +.response-points strong { + color: var(--text-primary); font: var(--font-weight-medium) var(--font-size-xs) var(--font-family-mono); + text-transform: uppercase; +} +.work-trace { + padding: var(--space-lg) 0 18px; + border-block: 1px solid var(--border-base); +} +.trace-header { margin-bottom: var(--space-md); } +.trace-header h3 { font-size: var(--font-size-xl); letter-spacing: -.02em; } +.trace-count { color: var(--text-muted); font-size: var(--font-size-xs); } +.trace-list { --trace-gutter: 28px; } +.trace-item { + position: relative; + display: grid; grid-template-columns: var(--trace-gutter) minmax(0, 1fr); + min-height: 46px; +} +.trace-item:not(:last-child)::before { + position: absolute; top: 17px; bottom: -9px; left: 5px; + width: 1px; background: var(--border-strong); content: ""; +} +.trace-marker { + position: relative; z-index: 1; + width: 11px; height: 11px; margin-top: 7px; + background: var(--surface-base); border: 2px solid var(--text-muted); +} +.is-done .trace-marker { background: var(--status-success); border-color: var(--status-success); } +.is-error .trace-marker { background: var(--status-error); border-color: var(--status-error); } +.is-active .trace-marker { + background: var(--accent-primary); border-color: var(--accent-primary); + outline: 3px solid color-mix(in oklab, var(--accent-primary) 20%, transparent); +} +.trace-operation, .trace-item details { min-width: 0; padding-bottom: var(--space-sm); } +.trace-line { + display: grid; grid-template-columns: 68px minmax(0, 1fr) auto; + align-items: baseline; gap: var(--space-sm); min-width: 0; +} +.trace-line code { color: var(--accent-primary); font-size: var(--font-size-xs); } +.trace-line strong { + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); +} +.trace-line time { color: var(--text-muted); font-size: 10px; } +.trace-operation > p { + margin: var(--space-2xs) 0 0 76px; + color: var(--text-muted); font-size: var(--font-size-xs); +} +.trace-item summary { cursor: pointer; list-style: none; } +.trace-item summary::-webkit-details-marker { display: none; } +.trace-item summary .trace-line::before { + position: absolute; right: 0; transform: translateX(18px); + color: var(--status-error); content: "-"; +} +.trace-item details:not([open]) summary .trace-line::before { content: "+"; } +.trace-item.is-error :is(code, time) { color: var(--status-error); } +.trace-error { + margin: var(--space-sm) 0 var(--space-xs) 76px; padding: var(--space-md); + background: var(--message-error-bg); border-left: 2px solid var(--status-error); +} +.trace-error strong { color: var(--status-error-fg); font-size: var(--font-size-xs); } +.trace-error p { margin: var(--space-xs) 0 0; color: var(--text-secondary); font-size: var(--font-size-xs); } +.composer { + width: min(760px, calc(100% - 48px)); + margin: 0 auto var(--space-lg); + background: var(--surface-secondary); border: 1px solid var(--border-strong); +} +.composer textarea { + display: block; width: 100%; min-height: 68px; resize: vertical; + padding: var(--space-md) var(--space-lg) var(--space-sm); + background: transparent; border: 0; line-height: var(--line-height-normal); +} +.composer textarea::placeholder, .composer-submit span { color: var(--text-muted); } +.composer textarea:focus-visible { outline-offset: -2px; } +.composer-actions { min-height: 38px; padding: 0 var(--space-sm) var(--space-sm); } +.composer-button { + min-height: 28px; padding: 0 var(--space-sm); + color: var(--text-secondary); font-size: var(--font-size-xs); +} +.composer-submit { gap: var(--space-sm); } +.composer-submit span { font-size: 10px; } +.send-button { + min-height: 30px; padding: 0 var(--space-md); + background: var(--accent-primary); color: var(--text-on-accent); + font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); +} +.send-button:hover { background: var(--accent-hover); } +.inspector { + overflow: auto; + background: var(--surface-muted); border-left: 1px solid var(--border-base); +} +.inspector-tabs { + position: sticky; z-index: 1; top: 0; + display: grid; grid-template-columns: repeat(3, 1fr); height: 58px; + background: var(--surface-muted); border-bottom: 1px solid var(--border-base); +} +.inspector-tab { position: relative; color: var(--text-muted); font-size: var(--font-size-xs); } +.inspector-tab.is-active { color: var(--text-primary); } +.inspector-tab.is-active::after { right: var(--space-md); left: var(--space-md); } +.inspector-tab span { margin-left: 3px; color: var(--text-muted); font: 9px var(--font-family-mono); } +.inspector-content { padding: 0 var(--space-lg) var(--space-xl); } +.inspector-section { padding: var(--space-xl) 0; border-bottom: 1px solid var(--border-base); } +.inspector-section:last-child { border: 0; } +.inspector-section h3 { font-size: var(--font-size-sm); } +.section-title-row > span { color: var(--text-muted); font-size: 10px; } +progress { + display: block; width: 100%; height: 4px; + margin: var(--space-md) 0 var(--space-lg); + appearance: none; overflow: hidden; background: var(--surface-hover); border: 0; +} +progress::-webkit-progress-bar { background: var(--surface-hover); } +progress::-webkit-progress-value { background: var(--accent-primary); } +progress::-moz-progress-bar { background: var(--accent-primary); } +.usage-list { display: grid; gap: var(--space-sm); margin: 0; } +.usage-list :is(dt, dd) { margin: 0; font-size: var(--font-size-xs); } +.usage-list dt { color: var(--text-muted); } +.usage-list dd { color: var(--text-secondary); } +.yolo-section { gap: var(--space-md); } +.yolo-section p { margin: var(--space-xs) 0 0; color: var(--text-muted); font-size: var(--font-size-xs); } +.switch-control { gap: var(--space-xs); color: var(--text-secondary); font-size: var(--font-size-xs); } +.switch-control input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent-primary); } +.shell-list { display: grid; gap: var(--space-xs); margin-top: var(--space-md); } +.shell-list li { + display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; + align-items: center; gap: var(--space-sm); min-height: 44px; +} +.shell-list div { display: grid; gap: var(--space-2xs); } +.shell-list strong { font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); } +.shell-list button { padding: var(--space-xs); color: var(--text-muted); font-size: 10px; opacity: 0; } +.shell-list li:is(:hover, :focus-within) button, .shell-list button:focus-visible { opacity: 1; } +.shell-list button:hover { color: var(--status-error-fg); } +.plan-list { display: grid; gap: var(--space-md); margin-top: var(--space-lg); counter-reset: plan; } +.plan-list li { + position: relative; padding-left: 26px; + color: var(--text-muted); font-size: var(--font-size-xs); counter-increment: plan; +} +.plan-list li::before { + position: absolute; top: 0; left: 0; + color: var(--text-muted); content: "0" counter(plan); +} +.plan-list .is-complete { color: var(--text-secondary); text-decoration: line-through; } +.plan-list .is-complete::before { color: var(--status-success); } +.plan-list .is-current { color: var(--text-primary); } +.plan-list .is-current::before { color: var(--accent-primary); } +@media (max-width: 1100px) { + .workspace-strip { grid-template-columns: 200px minmax(0, 1fr) auto; } + .workbench { grid-template-columns: 200px minmax(0, 1fr); } + .inspector, .branch-label, .icon-button { display: none; } +} +@media (max-width: 760px) { + body { overflow: auto; } + .app-shell { min-height: 100%; } + .workspace-strip { grid-template-columns: auto minmax(0, 1fr) auto; } + .brand { padding: 0 var(--space-sm); } + .brand > span:last-child, .secondary-workspace, .workspace-add, + .workspace-actions .quiet-button, .session-sidebar { display: none; } + .workspace-tabs { justify-content: stretch; } + .workspace-tab { max-width: none; } + .workspace-actions { padding: 0 var(--space-xs); } + .workbench { display: block; } + .conversation { + min-height: calc(100vh - 48px); + grid-template-rows: 54px minmax(0, 1fr) auto; + } + .conversation-header { padding: 0 var(--space-md); } + .conversation-meta .live-state, .trace-line time, .composer-submit span { display: none; } + .stream > * { width: calc(100% - 28px); } + .message { padding-block: var(--space-xl); } + .user-message::before { top: var(--space-xl); bottom: var(--space-xl); } + .trace-header { align-items: flex-end; } + .trace-count { max-width: 90px; text-align: right; } + .trace-line { grid-template-columns: 60px minmax(0, 1fr); } + .trace-operation > p, .trace-error { margin-left: 68px; } + .trace-item summary .trace-line::before { transform: none; } + .composer { + position: sticky; bottom: 0; + width: calc(100% - 16px); margin-bottom: var(--space-sm); + } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: .01ms !important; + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 4dfb6411c..329b67882 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -91,7 +91,7 @@ justify-content: center; padding: 0; border: 0; - border-radius: 0; + border-radius: var(--radius-md); color: var(--text-muted); background-color: var(--control-ghost-bg); } @@ -208,7 +208,7 @@ .stop-button { @apply w-7 h-7 border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; - border-radius: 0; + border-radius: var(--radius-md); background-color: var(--button-danger-bg, rgba(239, 68, 68, 0.85)); color: var(--button-danger-text, var(--text-inverted, #ffffff)); } @@ -234,7 +234,7 @@ .send-button { @apply w-7 h-7 border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; - border-radius: 0; + border-radius: var(--radius-md); background-color: var(--accent-primary); color: var(--text-inverted); } From 29f853945e67ddef0362042eb2beb2df867e9cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 29 Aug 2026 09:50:34 +0200 Subject: [PATCH 128/131] feat(automation): add guided desktop developer runs Add Developer Automation to Advanced Settings for Electron and Windows Tauri. Each run uses an isolated profile and automatic loopback CDP port, reports bounded launch logs and target metadata, and is stopped with its full process tree during explicit stop or host shutdown. Expose inspect, act, screenshot, and restart feedback to CodeNomad-owned OpenCode sessions through a token-authenticated loopback automation adapter. Session ownership, exact CDP target identity, navigation-safe accessibility refs, bounded diagnostics, and screenshot limits prevent cross-session or stale-target control. Keep autonomous browser previews out of DEV-v2: no browser controller, child webview, browser IPC, browser permissions, or codenomad.browser tool is included. Cover the native protocol, launch managers, CDP lifecycle, adapter authentication, ownership fencing, UI integration, and cross-platform launch arguments with focused tests. --- .../codenomad-architecture-guide/SKILL.md | 6 +- .../references/architecture-overview.md | 4 +- .../references/server-conventions.md | 3 +- dev-docs/DEVELOPER_AUTOMATION.md | 46 + dev-docs/INDEX.md | 4 + dev-docs/architecture.md | 7 +- .../main/developer-run-manager.test.ts | 163 +++ .../electron/main/developer-run-manager.ts | 371 +++++++ packages/electron-app/electron/main/ipc.ts | 21 + packages/electron-app/electron/main/main.ts | 9 +- .../electron/main/multiwindow-lifecycle.ts | 2 + .../electron/main/native-request.test.ts | 43 + .../electron/main/native-request.ts | 57 + .../electron/main/process-manager.ts | 34 +- .../electron/main/process-output.test.ts | 11 + .../electron/main/process-output.ts | 8 + .../electron-app/electron/preload/index.cjs | 13 + .../electron/preload/index.test.ts | 7 +- packages/electron-app/package.json | 2 +- packages/server/src/developer-cdp.test.ts | 259 +++++ packages/server/src/developer-cdp.ts | 444 ++++++++ packages/server/src/index.ts | 33 +- packages/server/src/native-parent.test.ts | 18 + packages/server/src/native-parent.ts | 76 ++ .../src/opencode/automation-plugin.test.ts | 76 ++ .../server/src/opencode/automation-plugin.ts | 267 +++++ packages/server/src/server/http-server.ts | 20 +- .../server/routes/automation-plugin.test.ts | 75 ++ .../src/server/routes/automation-plugin.ts | 134 +++ packages/tauri-app/src-tauri/build.rs | 3 + .../src-tauri/capabilities/main-window.json | 5 +- .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 36 + .../src-tauri/gen/schemas/windows-schema.json | 36 + .../autogenerated/developer_run_get.toml | 11 + .../autogenerated/developer_run_start.toml | 11 + .../autogenerated/developer_run_stop.toml | 11 + .../tauri-app/src-tauri/src/cli_manager.rs | 136 ++- .../tauri-app/src-tauri/src/developer_run.rs | 975 ++++++++++++++++++ packages/tauri-app/src-tauri/src/main.rs | 95 +- .../tauri-app/src-tauri/src/native_request.rs | 91 ++ packages/tauri-app/src-tauri/src/shutdown.rs | 42 +- .../settings/advanced-settings-section.tsx | 4 + .../settings/developer-automation-card.tsx | 305 ++++++ .../ui/src/lib/i18n/messages/de/settings.ts | 36 + .../ui/src/lib/i18n/messages/en/settings.ts | 36 + .../ui/src/lib/i18n/messages/es/settings.ts | 36 + .../ui/src/lib/i18n/messages/fr/settings.ts | 36 + .../ui/src/lib/i18n/messages/he/settings.ts | 36 + .../ui/src/lib/i18n/messages/ja/settings.ts | 36 + .../ui/src/lib/i18n/messages/ne/settings.ts | 36 + .../ui/src/lib/i18n/messages/ru/settings.ts | 36 + .../ui/src/lib/i18n/messages/tr/settings.ts | 36 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 36 + packages/ui/src/lib/native/developer-run.ts | 63 ++ 56 files changed, 4364 insertions(+), 37 deletions(-) create mode 100644 dev-docs/DEVELOPER_AUTOMATION.md create mode 100644 packages/electron-app/electron/main/developer-run-manager.test.ts create mode 100644 packages/electron-app/electron/main/developer-run-manager.ts create mode 100644 packages/electron-app/electron/main/native-request.test.ts create mode 100644 packages/electron-app/electron/main/native-request.ts create mode 100644 packages/electron-app/electron/main/process-output.test.ts create mode 100644 packages/electron-app/electron/main/process-output.ts create mode 100644 packages/server/src/developer-cdp.test.ts create mode 100644 packages/server/src/developer-cdp.ts create mode 100644 packages/server/src/native-parent.test.ts create mode 100644 packages/server/src/native-parent.ts create mode 100644 packages/server/src/opencode/automation-plugin.test.ts create mode 100644 packages/server/src/opencode/automation-plugin.ts create mode 100644 packages/server/src/server/routes/automation-plugin.test.ts create mode 100644 packages/server/src/server/routes/automation-plugin.ts create mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_get.toml create mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_start.toml create mode 100644 packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_stop.toml create mode 100644 packages/tauri-app/src-tauri/src/developer_run.rs create mode 100644 packages/tauri-app/src-tauri/src/native_request.rs create mode 100644 packages/ui/src/components/settings/developer-automation-card.tsx create mode 100644 packages/ui/src/lib/native/developer-run.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index c020cd5ed..288d692fa 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -12,12 +12,13 @@ description: | - 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`. +- Developer Automation: read `../../../dev-docs/DEVELOPER_AUTOMATION.md`. ## Native OpenCode V2 Baseline - The only OpenCode client dependency is the experimental `@opencode-ai/client@beta` protocol. Server and UI follow that dependency together; refresh the client lock before API audits or release validation. The runtime CLI is managed independently and startup has no exact version gate. The public `@opencode-ai/sdk` describes an alternative embedded host. - 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. +- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The reviewed Developer Automation adapter is the sole narrow exception; see `dev-docs/DEVELOPER_AUTOMATION.md`. - 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. @@ -46,6 +47,7 @@ description: | - 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/` +- Developer Automation: `packages/server/src/opencode/automation-plugin.ts`, `packages/server/src/developer-cdp.ts` ## Rules @@ -65,7 +67,7 @@ description: | | 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 | +| Reintroducing the V1 `packages/opencode-plugin` or general server plugin/background-process paths | Native OpenCode APIs; the reviewed Developer Automation adapter only for desktop feedback | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index 79ac3f279..abb91b18a 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -21,11 +21,11 @@ Client-state V3 is a per-window envelope over the V2 content-addressed partition | Owner | Responsibilities | Main paths | |---|---|---| | OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | latest experimental `@opencode-ai/client@beta` contract across server and UI | -| CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` | +| CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing, Developer Automation bridge | `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. +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 legacy server plugin/background-process integration remain deleted and must not be restored or used as extension points. Developer Automation is the sole reviewed adapter exception. Native Forms are the interruption API. Allowlisted Question request/reply/reject routes are compatibility-only; do not build new Question queue architecture. diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index 41b63b708..9ca9252d1 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -12,7 +12,7 @@ - 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. +- Never spawn or stop OpenCode per workspace and never add general plugin installation/packaging. Developer Automation's reviewed, location-gated adapter is the sole exception. - 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`. @@ -45,5 +45,6 @@ - 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` +- Developer Automation: `packages/server/src/opencode/automation-plugin.ts`, `packages/server/src/server/routes/automation-plugin.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/dev-docs/DEVELOPER_AUTOMATION.md b/dev-docs/DEVELOPER_AUTOMATION.md new file mode 100644 index 000000000..3217db279 --- /dev/null +++ b/dev-docs/DEVELOPER_AUTOMATION.md @@ -0,0 +1,46 @@ +# Developer Automation + +Developer Automation lets a running CodeNomad desktop host launch and inspect an isolated packaged CodeNomad build. It replaces the manual debug launch environment with one Advanced Settings workflow for Electron and Windows Tauri. + +## Launch Contract + +Every run receives a private profile, update channel, config path, and automatic loopback CDP port. + +Tauri sets: + +- `WEBVIEW2_USER_DATA_FOLDER` +- `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-address=127.0.0.1 --remote-debugging-port=` +- `RUST_BACKTRACE=1` +- `NODE_OPTIONS=--enable-source-maps` + +Electron passes the equivalent `--remote-debugging-address`, `--remote-debugging-port`, `--user-data-dir`, and `--enable-logging` arguments and enables Node source maps. + +The host waits for the exact CDP page target, keeps bounded stdout/stderr logs, and stops the complete process tree on explicit stop or host shutdown. + +## Agent Feedback + +For CodeNomad-owned OpenCode locations, a small automation adapter exposes: + +- `codenomad.inspect`: accessibility tree, runtime diagnostics, target metadata, and recent launch logs. +- `codenomad.act`: click, type, or restart using refs from the latest inspection. +- `codenomad.screenshot`: PNG capture of the connected build. + +The adapter is intentionally narrower than the removed V1 plugin runtime. It does not own OpenCode lifecycle or state, spawn one daemon per workspace, or expose autonomous browser previews. + +## Trust Boundaries + +- Discovery registrations contain random tokens and accept loopback requests only. +- The bridge verifies the OpenCode session and its location against the current CodeNomad workspace manager. +- One OpenCode session owns a developer run until that run stops or is replaced. +- CDP uses the exact target ID reported by the native host. +- Accessibility refs are invalidated by navigation. +- Diagnostics, accessibility snapshots, screenshots, lines, and log histories are bounded. + +## Main Paths + +- Electron lifecycle: `packages/electron-app/electron/main/developer-run-manager.ts` +- Tauri lifecycle: `packages/tauri-app/src-tauri/src/developer_run.rs` +- Shared CDP controller: `packages/server/src/developer-cdp.ts` +- Automation adapter: `packages/server/src/opencode/automation-plugin.ts` +- Authenticated bridge route: `packages/server/src/server/routes/automation-plugin.ts` +- UI: `packages/ui/src/components/settings/developer-automation-card.tsx` diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 45b1a6586..42e95b66e 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -24,6 +24,10 @@ Incremental comparison with official OpenCode Desktop V2, including parity, clos Measured DEV-v2 growth analysis, maintainer position on test volume, ranked reduction candidates, and guardrails for later simplification without product regressions. +### [DEVELOPER_AUTOMATION.md](DEVELOPER_AUTOMATION.md) + +Isolated Electron/Tauri developer runs, CDP feedback tools, lifecycle guarantees, and trust boundaries. + --- ## Specification Documents diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index af02ac051..d33835817 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -11,7 +11,7 @@ Desktop host -> CodeNomad server -> one shared OpenCode service +------ UI clients through /workspaces/:id/instance/api/* ``` -There is no `@opencode-ai/sdk` integration and no `packages/opencode-plugin` package. +There is no `@opencode-ai/sdk` integration and no legacy `packages/opencode-plugin` package. The narrow CodeNomad-owned Developer Automation adapter is documented in [DEVELOPER_AUTOMATION.md](DEVELOPER_AUTOMATION.md); it does not own the OpenCode daemon or restore the V1 compatibility runtime. ## Shared Service And Locations @@ -44,6 +44,7 @@ CodeNomad control APIs live under `/api/*`. Important routes include: - `/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` +- `/api/opencode-plugin/automation`, authenticated by a per-process loopback token and restricted to CodeNomad-owned locations 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. @@ -70,8 +71,9 @@ Current native events include session lifecycle/output events (`session.created` | Git status/diff/stage/unstage/commit | CodeNomad server | | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | +| Developer Automation launch and CDP feedback | CodeNomad desktop hosts and authenticated automation adapter | -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. +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; Developer Automation is the only reviewed adapter exception. ## Persistence @@ -86,6 +88,7 @@ CodeNomad configuration resolves through `packages/server/src/config/location.ts - `packages/server/src/workspaces/instance-events.ts` - `packages/server/src/workspaces/git-mutations.ts` - `packages/server/src/permissions/auto-accept-manager.ts` +- `packages/server/src/opencode/automation-plugin.ts` - `packages/ui/src/lib/sdk-manager.ts` - `packages/ui/src/lib/api-client.ts` - `packages/ui/src/stores/session-api.ts` diff --git a/packages/electron-app/electron/main/developer-run-manager.test.ts b/packages/electron-app/electron/main/developer-run-manager.test.ts new file mode 100644 index 000000000..fb518f528 --- /dev/null +++ b/packages/electron-app/electron/main/developer-run-manager.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { PassThrough } from "node:stream" +import test from "node:test" +import { join } from "node:path" +import { DeveloperRunManager, type DeveloperRunManagerDependencies } from "./developer-run-manager" + +class FakeChild extends EventEmitter { + pid: number + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + stdout = new PassThrough() + stderr = new PassThrough() + + constructor(pid: number) { + super() + this.pid = pid + } +} + +function response(body: unknown, ok = true): Response { + return { ok, json: async () => body } as Response +} + +function readyFetch(url: string | URL | Request): Promise { + return Promise.resolve(String(url).endsWith("/json/version") + ? response({ Browser: "test" }) + : response([{ id: "page-1", title: "CodeNomad", type: "page", url: "http://app.test/", webSocketDebuggerUrl: "ws://debug/page" }])) +} + +function harness(overrides: DeveloperRunManagerDependencies = {}) { + const children: FakeChild[] = [] + const launches: Array<{ executablePath: string; args: string[]; options: any }> = [] + let nextPid = 100 + const dependencies: DeveloperRunManagerDependencies = { + runId: () => "run-1", + allocatePort: async () => 9223, + fetch: readyFetch as typeof fetch, + stopTree: async () => {}, + spawn: (executablePath, args, options) => { + const child = new FakeChild(nextPid++) + children.push(child) + launches.push({ executablePath, args, options }) + return child + }, + ...overrides, + } + return { manager: new DeveloperRunManager(dependencies), children, launches } +} + +test("builds isolated Electron and Tauri debug launches", async () => { + const electron = harness() + await electron.manager.start({ target: "electron", executable: "electron.exe", tempRoot: "C:\\runs" }) + const electronLaunch = electron.launches[0] + assert.equal(electronLaunch.executablePath, "electron.exe") + assert.deepEqual(electronLaunch.args, [ + "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9223", + `--user-data-dir=${join("C:\\runs", "run-1")}`, "--enable-logging", + ]) + assert.equal(electronLaunch.options.env.CODENOMAD_UPDATE_CHANNEL, "developer-automation-run-1") + assert.equal(electronLaunch.options.env.CLI_CONFIG, join("C:\\runs", "run-1", "config.yaml")) + assert.match(electronLaunch.options.env.NODE_OPTIONS, /--enable-source-maps/) + + const tauri = harness() + await tauri.manager.start({ target: "tauri", executable: "tauri.exe", tempRoot: "C:\\runs" }) + const tauriLaunch = tauri.launches[0] + assert.deepEqual(tauriLaunch.args, []) + assert.equal(tauriLaunch.options.env.WEBVIEW2_USER_DATA_FOLDER, join("C:\\runs", "run-1", "webview2")) + assert.equal(tauriLaunch.options.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, "--remote-debugging-address=127.0.0.1 --remote-debugging-port=9223") + assert.equal(tauriLaunch.options.env.RUST_BACKTRACE, "1") +}) + +test("polls version and target endpoints until a top-level page is ready", async () => { + const requests: string[] = [] + let lists = 0 + const { manager } = harness({ + fetch: (async (url) => { + requests.push(String(url)) + if (String(url).endsWith("/json/version")) return response({ Browser: "test" }) + lists += 1 + return response(lists === 1 + ? [{ id: "loading", title: "", type: "page", url: "tauri://localhost/loading.html", webSocketDebuggerUrl: "ws://loading" }] + : [{ id: "ready", title: "CodeNomad", type: "page", url: "http://ready.test/", webSocketDebuggerUrl: "ws://ready" }]) + }) as typeof fetch, + }) + + const status = await manager.start({ target: "electron", executable: "electron.exe", timeoutMs: 1_000 }) + assert.equal(status.state, "ready") + assert.equal(status.targetUrl, "http://ready.test/") + assert.equal(status.targetId, "ready") + assert.deepEqual(requests.slice(0, 2), ["http://127.0.0.1:9223/json/version", "http://127.0.0.1:9223/json/list"]) + assert.ok(requests.length >= 4) +}) + +test("stale process exits cannot overwrite a newer run", async () => { + let id = 0 + const { manager, children } = harness({ runId: () => `run-${++id}` }) + await manager.start({ target: "electron", executable: "first.exe" }) + await manager.start({ target: "electron", executable: "second.exe" }) + + children[0].emit("exit", 1, null) + assert.equal(manager.status().state, "ready") + assert.equal(manager.status().runId, "run-2") +}) + +test("restarts with the same run, profile, and CDP endpoint", async () => { + let stops = 0 + const { manager, launches } = harness({ stopTree: async () => { stops += 1 } }) + const first = await manager.start({ target: "electron", executable: "first.exe", tempRoot: "C:\\runs" }) + const restarted = await manager.restart() + + assert.equal(stops, 1) + assert.equal(restarted.runId, first.runId) + assert.equal(restarted.profilePath, first.profilePath) + assert.equal(restarted.cdpUrl, first.cdpUrl) + assert.deepEqual(launches.map((launch) => launch.executablePath), ["first.exe", "first.exe"]) + assert.match(manager.logs().map((entry) => entry.message).join("\n"), /Restarting developer build/) +}) + +test("keeps only the latest 1000 stdout and stderr log lines", async () => { + const { manager, children } = harness() + await manager.start({ target: "electron", executable: "electron.exe" }) + children[0].stdout.write(Array.from({ length: 700 }, (_, index) => `out-${index}`).join("\n") + "\n") + children[0].stderr.write(Array.from({ length: 400 }, (_, index) => `err-${index}`).join("\n") + "\n") + + const logs = manager.logs() + assert.equal(logs.length, 1_000) + assert.deepEqual(logs[0], { runId: "run-1", timestamp: logs[0].timestamp, stream: "stdout", message: "out-100" }) + assert.deepEqual(logs.at(-1), { runId: "run-1", timestamp: logs.at(-1)!.timestamp, stream: "stderr", message: "err-399" }) + logs.length = 0 + assert.equal(manager.logs().length, 1_000) +}) + +test("bounds output before a process emits a newline", async () => { + const { manager, children } = harness() + await manager.start({ target: "electron", executable: "electron.exe" }) + children[0].stdout.write("x".repeat(100_000)) + children[0].stdout.write("\n") + + const message = manager.logs().at(-1)!.message + assert.equal(message.length, 512) + assert.match(message, /\.\.\.$/) +}) + +test("serializes overlapping lifecycle operations", async () => { + let releaseStop!: () => void + const stopGate = new Promise((resolve) => { releaseStop = resolve }) + let stops = 0 + const { manager, launches } = harness({ stopTree: async () => { stops += 1; await stopGate } }) + await manager.start({ target: "electron", executable: "first.exe" }) + + const second = manager.start({ target: "electron", executable: "second.exe" }) + const third = manager.start({ target: "electron", executable: "third.exe" }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(launches.length, 1) + assert.equal(stops, 1) + releaseStop() + await Promise.all([second, third]) + + assert.deepEqual(launches.map((launch) => launch.executablePath), ["first.exe", "second.exe", "third.exe"]) + assert.equal(stops, 2) + assert.equal(manager.status().state, "ready") +}) diff --git a/packages/electron-app/electron/main/developer-run-manager.ts b/packages/electron-app/electron/main/developer-run-manager.ts new file mode 100644 index 000000000..d3521d9c4 --- /dev/null +++ b/packages/electron-app/electron/main/developer-run-manager.ts @@ -0,0 +1,371 @@ +import { spawn } from "node:child_process" +import { mkdir, rm } from "node:fs/promises" +import { createServer } from "node:net" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { EventEmitter } from "node:events" +import { + captureInitialProcessTree, + captureProcessTree, + forceCapturedProcessTree, + mergeCapturedProcessTrees, + type CapturedProcessTree, +} from "./process-stop" + +export type DeveloperRunTarget = "electron" | "tauri" + +export interface DeveloperRunStatus { + state: "stopped" | "starting" | "ready" | "stopping" | "error" + runId?: string + target?: DeveloperRunTarget + executable?: string + profilePath?: string + pid?: number + cdpUrl?: string + targetId?: string + targetTitle?: string + targetUrl?: string + error?: string +} + +export interface DeveloperRunLog { + runId: string + timestamp: number + stream: "system" | "stdout" | "stderr" + message: string +} + +export interface DeveloperRunStartOptions { + target: DeveloperRunTarget + executable: string + tempRoot?: string + timeoutMs?: number +} + +interface RunChild { + pid?: number + exitCode: number | null + signalCode: NodeJS.Signals | null + stdout: NodeJS.ReadableStream | null + stderr: NodeJS.ReadableStream | null + on(event: "error", listener: (error: Error) => void): unknown + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown +} + +interface RunningProcess { + child: RunChild + generation: number + profilePath: string + initialTree: Promise<{ tree?: CapturedProcessTree; rootStartIdentity?: string }> +} + +interface ReusableRun { + runId: string + port: number + profilePath: string +} + +export interface DeveloperRunManagerDependencies { + spawn?: (executablePath: string, args: string[], options: Parameters[2]) => RunChild + fetch?: typeof globalThis.fetch + allocatePort?: () => Promise + stopTree?: (run: RunningProcess) => Promise + runId?: () => string +} + +const LOOPBACK = "127.0.0.1" +const LOG_LIMIT = 1_000 +const LOG_MESSAGE_LIMIT = 512 +const DEFAULT_TIMEOUT_MS = 30_000 +const STOP_TIMEOUT_MS = 10_000 + +function allocateLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.once("error", reject) + server.listen({ host: LOOPBACK, port: 0, exclusive: true }, () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate developer debugging port"))) + return + } + server.close((error) => error ? reject(error) : resolve(address.port)) + }) + }) +} + +async function stopProcessTree(run: RunningProcess): Promise { + const pid = run.child.pid + if (!pid) return + const deadlineAt = Date.now() + STOP_TIMEOUT_MS + const initial = await run.initialTree + const latest = await captureProcessTree(pid, process.platform, undefined, Math.max(0, Math.min(1_500, deadlineAt - Date.now()))) + let tree = mergeCapturedProcessTrees(initial.tree, latest, pid, initial.rootStartIdentity) + if (!tree && initial.rootStartIdentity) { + tree = { platform: process.platform, members: [{ pid, startIdentity: initial.rootStartIdentity }] } + } + if (!tree || !await forceCapturedProcessTree(tree, undefined, undefined, process.kill, { deadlineAt })) { + throw new Error(`Developer process tree termination was not confirmed (pid=${pid})`) + } +} + +export class DeveloperRunManager extends EventEmitter { + private currentStatus: DeveloperRunStatus = { state: "stopped" } + private entries: DeveloperRunLog[] = [] + private running?: RunningProcess + private generation = 0 + private queue: Promise = Promise.resolve() + private pendingStart?: AbortController + private readonly spawnProcess: NonNullable + private readonly fetchUrl: typeof globalThis.fetch + private readonly allocatePort: () => Promise + private readonly stopTree: (run: RunningProcess) => Promise + private readonly createRunId: () => string + + constructor(dependencies: DeveloperRunManagerDependencies = {}) { + super() + this.spawnProcess = dependencies.spawn ?? ((command, args, options) => spawn(command, args, options)) + this.fetchUrl = dependencies.fetch ?? globalThis.fetch + this.allocatePort = dependencies.allocatePort ?? allocateLoopbackPort + this.stopTree = dependencies.stopTree ?? stopProcessTree + this.createRunId = dependencies.runId ?? randomUUID + } + + status(): DeveloperRunStatus { + return { ...this.currentStatus } + } + + logs(): DeveloperRunLog[] { + return this.entries.map((entry) => ({ ...entry })) + } + + start(options: DeveloperRunStartOptions): Promise { + return this.enqueue(() => this.startNow(options)) + } + + stop(): Promise { + this.pendingStart?.abort() + return this.enqueue(() => this.stopNow()) + } + + restart(): Promise { + return this.enqueue(async () => { + const run = this.running + const status = this.currentStatus + const port = status.cdpUrl ? Number(new URL(status.cdpUrl).port) : NaN + if (!run || !status.runId || !status.target || !status.executable || !Number.isInteger(port)) { + throw new Error("Developer Automation is not running") + } + this.pushLog("system", "Restarting developer build") + await this.stopRun(run, false, false) + return this.startNow( + { target: status.target, executable: status.executable }, + { runId: status.runId, port, profilePath: run.profilePath }, + ) + }) + } + + private enqueue(operation: () => Promise): Promise { + const queued = this.queue.catch(() => {}).then(operation) + this.queue = queued.then(() => {}, () => {}) + return queued + } + + private async startNow(options: DeveloperRunStartOptions, reusable?: ReusableRun): Promise { + if (this.running) await this.stopNow() + + const runId = reusable?.runId ?? this.createRunId() + const port = reusable?.port ?? await this.allocatePort() + const profilePath = reusable?.profilePath ?? join(options.tempRoot ?? join(tmpdir(), "codenomad-developer-runs"), runId) + await mkdir(profilePath, { recursive: true, mode: 0o700 }) + + const generation = ++this.generation + const cdpUrl = `http://${LOOPBACK}:${port}` + const args = options.target === "electron" + ? [`--remote-debugging-address=${LOOPBACK}`, `--remote-debugging-port=${port}`, `--user-data-dir=${profilePath}`, "--enable-logging"] + : [] + const env: NodeJS.ProcessEnv = { + ...process.env, + CODENOMAD_UPDATE_CHANNEL: `developer-automation-${runId}`, + CLI_CONFIG: join(profilePath, "config.yaml"), + NODE_OPTIONS: [process.env.NODE_OPTIONS, "--enable-source-maps"].filter(Boolean).join(" "), + ...(options.target === "tauri" ? { + WEBVIEW2_USER_DATA_FOLDER: join(profilePath, "webview2"), + WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: [ + process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS?.split(/\s+/) + .filter((value) => value && !value.startsWith("--remote-debugging-address=") && !value.startsWith("--remote-debugging-port=")) + .join(" "), + `--remote-debugging-address=${LOOPBACK} --remote-debugging-port=${port}`, + ].filter(Boolean).join(" "), + RUST_BACKTRACE: "1", + } : {}), + } + if (!reusable) this.entries = [] + this.setStatus({ state: "starting", runId, target: options.target, executable: options.executable, profilePath, cdpUrl }) + this.pushLog("system", `Starting ${options.target} developer build`) + let child: RunChild + try { + child = this.spawnProcess(options.executable, args, { + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: false, + detached: process.platform !== "win32", + }) + } catch (error) { + await rm(profilePath, { recursive: true, force: true }) + this.setStatus({ ...this.currentStatus, state: "error", error: error instanceof Error ? error.message : String(error) }) + throw error + } + const initialTree = child.pid + ? captureInitialProcessTree(child.pid, process.platform).catch(() => ({})) + : Promise.resolve({}) + const run: RunningProcess = { child, generation, profilePath, initialTree } + this.running = run + this.setStatus({ ...this.currentStatus, pid: child.pid }) + + const buffers = { + stdout: { message: "", truncated: false }, + stderr: { message: "", truncated: false }, + } + const append = (stream: "stdout" | "stderr", message: string) => { + const buffer = buffers[stream] + const remaining = LOG_MESSAGE_LIMIT - 3 - buffer.message.length + if (remaining > 0) buffer.message += message.slice(0, remaining) + if (message.length > remaining) buffer.truncated = true + } + const flush = (stream: "stdout" | "stderr") => { + const buffer = buffers[stream] + if (buffer.message || buffer.truncated) this.pushLog(stream, `${buffer.message}${buffer.truncated ? "..." : ""}`) + buffer.message = "" + buffer.truncated = false + } + const pipe = (stream: "stdout" | "stderr", chunk: unknown) => { + if (this.running !== run || generation !== this.generation) return + const lines = String(chunk).split(/\r?\n/) + append(stream, lines.shift() ?? "") + while (lines.length) { + flush(stream) + append(stream, lines.shift() ?? "") + } + } + child.stdout?.on("data", (chunk) => pipe("stdout", chunk)) + child.stderr?.on("data", (chunk) => pipe("stderr", chunk)) + child.on("error", (error) => { + if (this.running !== run || generation !== this.generation) return + this.pushLog("system", error.message) + this.setStatus({ ...this.currentStatus, state: "error", error: error.message }) + }) + child.once("exit", (code, signal) => { + if (this.running !== run || generation !== this.generation) return + for (const stream of ["stdout", "stderr"] as const) flush(stream) + const error = `Developer process exited before stop (code=${code ?? "null"}, signal=${signal ?? "null"})` + this.pushLog("system", error) + this.setStatus({ ...this.currentStatus, state: "error", pid: undefined, error }) + }) + + const controller = new AbortController() + this.pendingStart = controller + try { + const page = await this.waitUntilReady(run, port, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, controller.signal) + this.pushLog("system", `Connected to ${page.title || page.url}`) + this.setStatus({ + ...this.currentStatus, + state: "ready", + targetId: page.id, + targetTitle: page.title, + targetUrl: page.url, + }) + return this.status() + } catch (error) { + if (this.running === run) { + const message = error instanceof Error ? error.message : String(error) + this.pushLog("system", message) + this.setStatus({ ...this.currentStatus, state: "error", error: message }) + await this.stopRun(run, false) + } + throw error + } finally { + if (this.pendingStart === controller) this.pendingStart = undefined + } + } + + private async waitUntilReady(run: RunningProcess, port: number, timeoutMs: number, signal: AbortSignal): Promise<{ id: string; title: string; url: string }> { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (signal.aborted) throw new Error("Developer run startup interrupted") + if (this.running !== run || run.generation !== this.generation) throw new Error("Developer run superseded") + if (this.currentStatus.state === "error") throw new Error(this.currentStatus.error ?? "Developer process failed") + try { + const base = `http://${LOOPBACK}:${port}` + const version = await this.fetchUrl(`${base}/json/version`, { signal: AbortSignal.timeout(Math.min(1_000, Math.max(1, deadline - Date.now()))) }) + if (version.ok && await version.json()) { + const targets = await this.fetchUrl(`${base}/json/list`, { signal: AbortSignal.timeout(Math.min(1_000, Math.max(1, deadline - Date.now()))) }) + if (targets.ok) { + const page = (await targets.json() as Array>).find((target) => + target.type === "page" && typeof target.url === "string" && target.url !== "about:blank" + && !new URL(target.url).pathname.endsWith("/loading.html") + && typeof target.id === "string" && typeof target.title === "string" + && typeof target.webSocketDebuggerUrl === "string") + if (page) return { id: page.id as string, title: page.title as string, url: page.url as string } + } + } + } catch (error) { + if (signal.aborted) throw new Error("Developer run startup interrupted") + } + await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now())))) + } + throw new Error(`Developer run did not expose a top-level debug page within ${timeoutMs}ms`) + } + + private async stopNow(): Promise { + const run = this.running + if (!run) { + this.setStatus({ state: "stopped" }) + return + } + this.setStatus({ ...this.currentStatus, state: "stopping" }) + await this.stopRun(run, true) + } + + private async stopRun(run: RunningProcess, updateStatus: boolean, removeProfile = true): Promise { + if (this.running === run) ++this.generation + await this.stopTree(run) + if (removeProfile) await rm(run.profilePath, { recursive: true, force: true }) + if (this.running === run) this.running = undefined + if (updateStatus) { + this.pushLog("system", "Developer build stopped") + this.setStatus({ state: "stopped" }) + } + } + + private setStatus(status: DeveloperRunStatus): void { + this.currentStatus = status + this.emit("status", this.status()) + } + + private pushLog(stream: DeveloperRunLog["stream"], message: string): void { + const runId = this.currentStatus.runId + if (!runId) return + const entry = { + runId, + timestamp: Date.now(), + stream, + message: message.length > LOG_MESSAGE_LIMIT ? `${message.slice(0, LOG_MESSAGE_LIMIT - 3)}...` : message, + } + this.entries.push(entry) + if (this.entries.length > LOG_LIMIT) this.entries.splice(0, this.entries.length - LOG_LIMIT) + this.emit("log", { ...entry }) + } +} + +export async function handleNativeDeveloperRunRequest( + manager: DeveloperRunManager, + method: string, +): Promise { + if (method === "developer.status") return { status: manager.status(), logs: manager.logs() } + if (method === "developer.restart") return manager.restart() + throw new Error(`Unsupported native developer request: ${method}`) +} diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index cf939b85d..81d01eff9 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -5,6 +5,8 @@ import type { CliProcessManager } from "./process-manager" import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" import { setWorkspaceMenuEnabled } from "./menu" import { requireHttpUrl } from "./navigation-security" +import type { DeveloperRunManager, DeveloperRunTarget } from "./developer-run-manager" +import path from "node:path" interface LocalSender { id: string @@ -18,6 +20,7 @@ interface CliIPCDependencies { newWindow(): Promise nextFolder(windowId: string): string | null acknowledgeFolder(windowId: string, folder: string, opened: boolean): void + developerRunManager: DeveloperRunManager } interface DialogOpenRequest { @@ -99,6 +102,24 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD dependencies.acknowledgeFolder(id, folder, opened) return { ok: true } }) + ipcMain.handle("developer-run:get", async (event) => { + local(event) + return { status: dependencies.developerRunManager.status(), logs: dependencies.developerRunManager.logs() } + }) + ipcMain.handle("developer-run:start", async (event, payload: { target?: unknown; executable?: unknown }) => { + local(event) + const target = payload?.target + const executable = payload?.executable + if ((target !== "electron" && target !== "tauri") || typeof executable !== "string" || executable.length > 32_768 + || !path.isAbsolute(executable) || !fs.statSync(executable, { throwIfNoEntry: false })?.isFile()) { + throw new Error("Developer Automation requires a valid Electron or Tauri executable") + } + return dependencies.developerRunManager.start({ target: target as DeveloperRunTarget, executable }) + }) + ipcMain.handle("developer-run:stop", async (event) => { + local(event) + await dependencies.developerRunManager.stop() + }) ipcMain.handle("dialog:open", async (event, request: DialogOpenRequest): Promise => { const { window } = local(event) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index a2614791c..b1de3ce40 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,9 +1,11 @@ +import "./process-output" 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 "node:fs" import { dirname, join } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" +import { DeveloperRunManager, handleNativeDeveloperRunRequest } from "./developer-run-manager" import { ClientStateManager } from "./client-state" import { setupClientStateIPC } from "./client-state-ipc" import { ClientStateNavigationController } from "./client-state-navigation" @@ -62,8 +64,9 @@ function runPrimary(firstIntent: LaunchIntent) { 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 developerRunManager = new DeveloperRunManager() + const cli = new CliProcessManager((method) => handleNativeDeveloperRunRequest(developerRunManager, method)) const remoteOrigins = new Map>() const insecureOrigins = new Map>() const navigationLifecycle = new SerializedLifecycle() @@ -88,6 +91,7 @@ function runPrimary(firstIntent: LaunchIntent) { removeWindowState: (id) => clientState.removeWindow(id), getAllowedRendererOrigins: getAllowedOrigins, isTrustedRendererOrigin: isAllowedRendererOrigin, navigationLifecycle, + stopDeveloperRun: () => developerRunManager.stop(), }) const bindClientState = setupClientStateIPC(ipcMain, clientState, (sender) => registry.resolve(sender), getAllowedOrigins) @@ -219,6 +223,7 @@ function runPrimary(firstIntent: LaunchIntent) { 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), + developerRunManager, }) lifecycle.registerAppEvents() app.on("second-instance", (_event, argv, workingDirectory) => { @@ -252,6 +257,8 @@ function runPrimary(firstIntent: LaunchIntent) { } }) cli.on("error", (error) => registry.fanout("cli:error", { message: error.message })) + developerRunManager.on("status", (status) => registry.fanout("developer-run:status", status)) + developerRunManager.on("log", (entry) => registry.fanout("developer-run:log", entry)) app.whenReady().then(async () => { try { app.setAppUserModelId("ai.neuralnomads.codenomad.client") } catch {} diff --git a/packages/electron-app/electron/main/multiwindow-lifecycle.ts b/packages/electron-app/electron/main/multiwindow-lifecycle.ts index 7b3fccf47..7aaa89845 100644 --- a/packages/electron-app/electron/main/multiwindow-lifecycle.ts +++ b/packages/electron-app/electron/main/multiwindow-lifecycle.ts @@ -25,6 +25,7 @@ interface Dependencies { sessionEndCleanupTimeoutMs?: number isWindows?: boolean navigationLifecycle?: SerializedLifecycle + stopDeveloperRun?(): Promise } export class MultiwindowLifecycle { @@ -103,6 +104,7 @@ export class MultiwindowLifecycle { const cleanup = async () => { await (preparedFlush ?? this.flushLocalWindows()) await this.run("aggregate state flush", () => this.dependencies.clientStateManager.flush()) + await this.dependencies.stopDeveloperRun?.() await this.dependencies.cliManager.shutdown() try { await this.releasePrimary() diff --git a/packages/electron-app/electron/main/native-request.test.ts b/packages/electron-app/electron/main/native-request.test.ts new file mode 100644 index 000000000..bd8b43258 --- /dev/null +++ b/packages/electron-app/electron/main/native-request.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import type { ChildProcess } from "node:child_process" +import { dispatchNativeRequest, NATIVE_REQUEST_PREFIX, parseNativeRequest } from "./native-request" + +test("native requests are validated and answered on the child stdin", async () => { + const request = parseNativeRequest(`${NATIVE_REQUEST_PREFIX}{"v":1,"id":"request-1","method":"developer.status","deadline":${Date.now() + 10_000}}`) + assert.ok(request) + assert.equal(parseNativeRequest(`${NATIVE_REQUEST_PREFIX}{"v":2}`), undefined) + + let written = "" + const child = { + stdin: { + writable: true, + write(chunk: string) { + written += chunk + return true + }, + }, + } as unknown as ChildProcess + await dispatchNativeRequest(child, request, async (method, params) => ({ method, params }), () => true) + + assert.deepEqual(JSON.parse(written.slice("CODENOMAD_NATIVE_RESPONSE:".length)), { + v: 1, + id: "request-1", + ok: true, + result: { method: "developer.status" }, + }) +}) + +test("native response ignores a child stdin closed during dispatch", async () => { + const request = parseNativeRequest(`${NATIVE_REQUEST_PREFIX}{"v":1,"id":"request-2","method":"developer.status","deadline":${Date.now() + 10_000}}`) + assert.ok(request) + const child = { + stdin: { + writable: true, + write() { + throw Object.assign(new Error("closed"), { code: "EPIPE" }) + }, + }, + } as unknown as ChildProcess + await dispatchNativeRequest(child, request, async () => ({}), () => true) +}) diff --git a/packages/electron-app/electron/main/native-request.ts b/packages/electron-app/electron/main/native-request.ts new file mode 100644 index 000000000..a19ef3857 --- /dev/null +++ b/packages/electron-app/electron/main/native-request.ts @@ -0,0 +1,57 @@ +import type { ChildProcess } from "node:child_process" + +export const NATIVE_REQUEST_PREFIX = "CODENOMAD_NATIVE_REQUEST:" +const NATIVE_RESPONSE_PREFIX = "CODENOMAD_NATIVE_RESPONSE:" + +interface NativeRequest { + v: 1 + id: string + method: string + params?: unknown + deadline: number +} + +export function isClosedPipeError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code === "EPIPE" || code === "ERR_STREAM_DESTROYED" || code === "ERR_STREAM_WRITE_AFTER_END" +} + +export function parseNativeRequest(line: string): NativeRequest | undefined { + if (!line.startsWith(NATIVE_REQUEST_PREFIX) || line.length > 20 * 1024 * 1024) return undefined + try { + const value = JSON.parse(line.slice(NATIVE_REQUEST_PREFIX.length)) as Partial + if (value.v !== 1 || typeof value.id !== "string" || !value.id || typeof value.method !== "string" || !value.method + || typeof value.deadline !== "number" || !Number.isSafeInteger(value.deadline)) return undefined + return value as NativeRequest + } catch { + return undefined + } +} + +export async function dispatchNativeRequest( + child: ChildProcess, + request: NativeRequest, + handler: (method: string, params: unknown, deadline: number) => Promise, + isCurrent: () => boolean, +): Promise { + let response: Record + try { + if (Date.now() >= request.deadline) throw new Error("Native request expired before execution") + response = { v: 1, id: request.id, ok: true, result: await handler(request.method, request.params, request.deadline) } + } catch (error) { + response = { + v: 1, + id: request.id, + ok: false, + error: { code: "native_error", message: error instanceof Error ? error.message : String(error) }, + } + } + if (!isCurrent() || !child.stdin?.writable) return + try { + child.stdin.write(`${NATIVE_RESPONSE_PREFIX}${JSON.stringify(response)}\n`, (error) => { + if (error && !isClosedPipeError(error)) console.warn("[cli] failed to write native response", error) + }) + } catch (error) { + if (!isClosedPipeError(error)) throw error + } +} diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 85103919c..168e3fb45 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -20,6 +20,7 @@ import { import { SerializedLifecycle } from "./serialized-lifecycle" import { resolveManagedProcessExit, shouldReportManagedProcessError } from "./process-exit" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" +import { dispatchNativeRequest, isClosedPipeError, parseNativeRequest } from "./native-request" const nodeRequire = createRequire(import.meta.url) const mainFilename = fileURLToPath(import.meta.url) @@ -148,6 +149,10 @@ export class CliProcessManager extends EventEmitter { private shutdownStatus: "complete" | "incomplete" | null = null private lifecycle = new SerializedLifecycle() + constructor(private readonly nativeRequestHandler?: (method: string, params: unknown, deadline: number) => Promise) { + super() + } + start(options: StartOptions): Promise { return this.lifecycle.enqueue(() => this.startNow(options)) } @@ -204,6 +209,7 @@ export class CliProcessManager extends EventEmitter { const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } env.ELECTRON_RUN_AS_NODE = "1" + env.CODENOMAD_NATIVE_PARENT = "1" const spawnDetails = supportsUserShell() ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) @@ -228,15 +234,18 @@ export class CliProcessManager extends EventEmitter { const stdout = child.stdout as NodeJS.ReadableStream | undefined const stderr = child.stderr as NodeJS.ReadableStream | undefined + child.stdin?.on("error", (error) => { + if (!isClosedPipeError(error)) console.warn("[cli] stdin error", error) + }) stdout?.on("data", (data: Buffer) => { if (this.child !== child) return - this.handleStream(data.toString(), "stdout") + this.handleStream(data.toString(), "stdout", child) }) stderr?.on("data", (data: Buffer) => { if (this.child !== child) return - this.handleStream(data.toString(), "stderr") + this.handleStream(data.toString(), "stderr", child) }) child.on("error", (error) => { @@ -414,17 +423,17 @@ export class CliProcessManager extends EventEmitter { this.emit("error", new Error("CLI did not start in time")) } - private handleStream(chunk: string, stream: "stdout" | "stderr") { + private handleStream(chunk: string, stream: "stdout" | "stderr", child: ChildProcess) { if (stream === "stdout") { this.stdoutBuffer += chunk - this.processBuffer("stdout") + this.processBuffer("stdout", child) } else { this.stderrBuffer += chunk - this.processBuffer("stderr") + this.processBuffer("stderr", child) } } - private processBuffer(stream: "stdout" | "stderr") { + private processBuffer(stream: "stdout" | "stderr", child: ChildProcess) { const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer const lines = buffer.split("\n") const trailing = lines.pop() ?? "" @@ -439,6 +448,19 @@ export class CliProcessManager extends EventEmitter { const trimmed = line.trim() if (!trimmed) continue + if (stream === "stdout" && trimmed.startsWith("CODENOMAD_NATIVE_REQUEST:")) { + const request = parseNativeRequest(trimmed) + if (request) { + void dispatchNativeRequest( + child, + request, + this.nativeRequestHandler ?? (async (method) => { throw new Error(`Unsupported native method: ${method}`) }), + () => this.child === child && !this.requestedStop, + ) + } + continue + } + if (trimmed === SERVER_SHUTDOWN_COMPLETE) { if (this.shutdownStatus === "incomplete") continue this.shutdownStatus = "complete" diff --git a/packages/electron-app/electron/main/process-output.test.ts b/packages/electron-app/electron/main/process-output.test.ts new file mode 100644 index 000000000..16d62ae6d --- /dev/null +++ b/packages/electron-app/electron/main/process-output.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import test from "node:test" +import { tolerateBrokenPipe } from "./process-output" + +test("ignores broken output pipes without hiding other stream errors", () => { + const stream = new EventEmitter() + tolerateBrokenPipe(stream) + assert.doesNotThrow(() => stream.emit("error", Object.assign(new Error("closed"), { code: "EPIPE" }))) + assert.throws(() => stream.emit("error", Object.assign(new Error("bad descriptor"), { code: "EBADF" })), /bad descriptor/) +}) diff --git a/packages/electron-app/electron/main/process-output.ts b/packages/electron-app/electron/main/process-output.ts new file mode 100644 index 000000000..09ea71c95 --- /dev/null +++ b/packages/electron-app/electron/main/process-output.ts @@ -0,0 +1,8 @@ +export function tolerateBrokenPipe(stream: NodeJS.EventEmitter): void { + stream.on("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EPIPE") throw error + }) +} + +tolerateBrokenPipe(process.stdout) +tolerateBrokenPipe(process.stderr) diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 5e3164eec..83ff8a904 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -65,6 +65,19 @@ const localElectronAPI = { setClientStateRestoreEnabled: (token, enabled) => ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)), clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token), + getDeveloperRun: () => ipcRenderer.invoke("developer-run:get"), + startDeveloperRun: (input) => ipcRenderer.invoke("developer-run:start", input), + stopDeveloperRun: () => ipcRenderer.invoke("developer-run:stop"), + onDeveloperRunStatus: (callback) => { + const handler = (_event, status) => callback(status) + ipcRenderer.on("developer-run:status", handler) + return () => ipcRenderer.removeListener("developer-run:status", handler) + }, + onDeveloperRunLog: (callback) => { + const handler = (_event, log) => callback(log) + ipcRenderer.on("developer-run:log", handler) + return () => ipcRenderer.removeListener("developer-run:log", handler) + }, } const remoteElectronAPI = { diff --git a/packages/electron-app/electron/preload/index.test.ts b/packages/electron-app/electron/preload/index.test.ts index 2d0e56983..cdc594abb 100644 --- a/packages/electron-app/electron/preload/index.test.ts +++ b/packages/electron-app/electron/preload/index.test.ts @@ -24,7 +24,12 @@ test("CLI event disposers remove only their own wrapper listeners", () => { process: { argv: [] }, }) - for (const [subscribe, channel] of [["onCliStatus", "cli:status"], ["onCliError", "cli:error"]] as const) { + for (const [subscribe, channel] of [ + ["onCliStatus", "cli:status"], + ["onCliError", "cli:error"], + ["onDeveloperRunStatus", "developer-run:status"], + ["onDeveloperRunLog", "developer-run:log"], + ] as const) { const calls: string[] = [] const disposeFirst = api![subscribe]((value: string) => calls.push(`first:${value}`)) api![subscribe]((value: string) => calls.push(`second:${value}`)) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 14114cd6f..9df743099 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/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", + "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/developer-run-manager.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/process-exit.test.ts electron/main/process-output.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", diff --git a/packages/server/src/developer-cdp.test.ts b/packages/server/src/developer-cdp.test.ts new file mode 100644 index 000000000..039460905 --- /dev/null +++ b/packages/server/src/developer-cdp.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { DeveloperCdp } from "./developer-cdp" + +interface Command { + id: number + method: string + params?: Record +} + +const page = (id: string) => ({ + id, + title: `Page ${id}`, + type: "page", + url: `http://app.test/${id}`, + webSocketDebuggerUrl: `ws://chrome.test/${id}`, +}) + +class FakeSocket { + onopen: ((event: unknown) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: ((event: unknown) => void) | null = null + onerror: ((event: unknown) => void) | null = null + closed = false + + constructor(private readonly chrome: FakeChrome) { + queueMicrotask(() => this.onopen?.({})) + } + + send(data: string): void { + this.chrome.receive(this, JSON.parse(data) as Command) + } + + close(): void { + this.closed = true + this.onclose?.({}) + } + + respond(command: Command, result: Record = {}): void { + this.onmessage?.({ data: JSON.stringify({ id: command.id, result }) }) + } + + emit(method: string, params: Record = {}): void { + this.onmessage?.({ data: JSON.stringify({ method, params }) }) + } +} + +class FakeChrome { + target = page("one") + sockets: FakeSocket[] = [] + commands: Command[] = [] + deferred = new Map() + screenshotData = Buffer.from("png").toString("base64") + axNodes: Array> = [ + { backendDOMNodeId: 42, role: { value: "button" }, name: { value: "Save" } }, + ] + + fetch = async (input: string | URL | Request): Promise => { + assert.equal(String(input), "http://127.0.0.1:9222/json/list") + return new Response(JSON.stringify([this.target])) + } + + connect = (_url: string): FakeSocket => { + const socket = new FakeSocket(this) + this.sockets.push(socket) + return socket + } + + receive(socket: FakeSocket, command: Command): void { + this.commands.push(command) + if (this.deferred.has(command.method)) { + this.deferred.set(command.method, { socket, command }) + return + } + queueMicrotask(() => socket.respond(command, this.result(command.method))) + } + + result(method: string): Record { + if (method === "Accessibility.getFullAXTree") return { + nodes: this.axNodes, + } + if (method === "Page.captureScreenshot") return { data: this.screenshotData } + if (method === "DOM.getBoxModel") return { model: { content: [0, 0, 20, 0, 20, 10, 0, 10] } } + return {} + } + + client(overrides: Record = {}): DeveloperCdp { + return new DeveloperCdp({ + fetch: this.fetch as typeof globalThis.fetch, + connect: this.connect, + timeoutMs: 1_000, + ...overrides, + }) + } +} + +const identity = { endpoint: "http://127.0.0.1:9222", runId: "run-1", targetId: "one" } + +describe("DeveloperCdp", () => { + it("matches out-of-order protocol responses and drains runtime diagnostics on inspect", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + await client.inspect(identity) + const socket = chrome.sockets[0] + for (let index = 0; index < 1_001; index++) { + socket.emit("Runtime.consoleAPICalled", { type: "warning", args: [{ value: `message-${index}` }] }) + } + socket.emit("Runtime.exceptionThrown", { + exceptionDetails: { exception: { description: "Error: broken" } }, + }) + + chrome.deferred.set("Accessibility.getFullAXTree", undefined as never) + chrome.deferred.set("Page.captureScreenshot", undefined as never) + const inspectionPromise = client.inspect(identity) + const screenshotPromise = client.screenshot(identity.runId) + while ([...chrome.deferred.values()].some((value) => !value)) { + await new Promise((resolve) => setImmediate(resolve)) + } + + const screenshotCommand = chrome.deferred.get("Page.captureScreenshot")! + const inspectCommand = chrome.deferred.get("Accessibility.getFullAXTree")! + screenshotCommand.socket.respond(screenshotCommand.command, { data: Buffer.from("shot").toString("base64") }) + inspectCommand.socket.respond(inspectCommand.command, chrome.result("Accessibility.getFullAXTree")) + + const [inspection, screenshot] = await Promise.all([inspectionPromise, screenshotPromise]) + assert.equal(inspection.nodes[0].name, "Save") + assert.equal(inspection.diagnostics.length, 1_000) + assert.deepEqual(inspection.diagnostics[0], { level: "warning", text: "message-2" }) + assert.deepEqual(inspection.diagnostics.at(-1), { level: "error", text: "Error: broken" }) + assert.equal(Buffer.from(screenshot.data, "base64").toString(), "shot") + + chrome.deferred.clear() + assert.deepEqual((await client.inspect(identity)).diagnostics, []) + socket.emit("Runtime.consoleAPICalled", { type: "log", args: [{ value: "x".repeat(10_000) }] }) + assert.equal((await client.inspect(identity)).diagnostics[0].text.length, 512) + }) + + it("reconnects when the page target is replaced and rejects target-scoped refs", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + const first = await client.inspect(identity) + const oldRef = first.nodes[0].ref! + const oldSocket = chrome.sockets[0] + + chrome.target = page("two") + const second = await client.inspect({ ...identity, targetId: "two" }) + + assert.equal(second.target.id, "two") + assert.equal(oldSocket.closed, true) + assert.equal(chrome.sockets.length, 2) + await assert.rejects(client.act({ runId: identity.runId, kind: "click", ref: oldRef }), /stale accessibility ref/) + }) + + it("refreshes target metadata without reconnecting", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + await client.inspect(identity) + chrome.target = { ...chrome.target, title: "Renamed", url: "http://app.test/current" } + + const inspection = await client.inspect(identity) + + assert.equal(inspection.target.title, "Renamed") + assert.equal(inspection.target.url, "http://app.test/current") + assert.equal(chrome.sockets.length, 1) + }) + + it("retries a failed WebSocket connection", async () => { + const chrome = new FakeChrome() + let attempts = 0 + const client = chrome.client({ + connect: (url: string) => { + attempts += 1 + if (attempts > 1) return chrome.connect(url) + const socket = { + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + send() {}, + close() {}, + } as unknown as FakeSocket + queueMicrotask(() => socket.onerror?.({})) + return socket + }, + }) + + await assert.rejects(client.inspect(identity), /connection failed/) + assert.equal((await client.inspect(identity)).target.id, "one") + assert.equal(attempts, 2) + }) + + it("invalidates accessibility refs on frame navigation", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + const inspection = await client.inspect(identity) + chrome.sockets[0].emit("Page.frameNavigated", { frame: { id: "main" } }) + + await assert.rejects( + client.act({ runId: identity.runId, kind: "type", ref: inspection.nodes[0].ref!, text: "hello" }), + /stale accessibility ref/, + ) + }) + + it("invalidates refs when the target socket reconnects", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + const inspection = await client.inspect(identity) + chrome.sockets[0].close() + + await assert.rejects( + client.act({ runId: identity.runId, kind: "click", ref: inspection.nodes[0].ref! }), + /stale accessibility ref/, + ) + assert.equal(chrome.sockets.length, 2) + }) + + it("aborts actions when navigation occurs between CDP commands", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + const inspection = await client.inspect(identity) + chrome.deferred.set("DOM.getBoxModel", undefined as never) + const action = client.act({ runId: identity.runId, kind: "click", ref: inspection.nodes[0].ref! }) + while (!chrome.deferred.get("DOM.getBoxModel")) await new Promise((resolve) => setImmediate(resolve)) + const deferred = chrome.deferred.get("DOM.getBoxModel")! + deferred.socket.emit("Page.frameNavigated", { frame: { id: "main" } }) + deferred.socket.respond(deferred.command, chrome.result("DOM.getBoxModel")) + + await assert.rejects(action, /Page changed during action/) + assert.equal(chrome.commands.some((command) => command.method === "Input.dispatchMouseEvent"), false) + }) + + it("rejects screenshots over the configured PNG byte limit", async () => { + const chrome = new FakeChrome() + chrome.screenshotData = Buffer.from("12345").toString("base64") + const client = chrome.client({ maxScreenshotBytes: 4 }) + await client.inspect(identity) + + await assert.rejects(client.screenshot(identity.runId), /exceeds 4 byte limit/) + }) + + it("bounds accessibility snapshots", async () => { + const chrome = new FakeChrome() + chrome.axNodes = Array.from({ length: 800 }, (_, index) => ({ + backendDOMNodeId: index + 1, + role: { value: "button" }, + name: { value: `Button ${index}` }, + })) + const inspection = await chrome.client().inspect(identity) + assert.equal(inspection.nodes.length, 750) + }) + + it("closes active target connections", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + await client.inspect(identity) + client.close() + assert.equal(chrome.sockets[0].closed, true) + }) +}) diff --git a/packages/server/src/developer-cdp.ts b/packages/server/src/developer-cdp.ts new file mode 100644 index 000000000..039593f1b --- /dev/null +++ b/packages/server/src/developer-cdp.ts @@ -0,0 +1,444 @@ +import { WebSocket } from "undici" + +const DEFAULT_TIMEOUT_MS = 5_000 +const DEFAULT_MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024 +const DIAGNOSTIC_LIMIT = 1_000 +const SNAPSHOT_NODE_LIMIT = 750 +const SNAPSHOT_TEXT_LIMIT = 64 * 1024 +const DIAGNOSTIC_TEXT_LIMIT = 512 + +export interface DeveloperCdpIdentity { + endpoint: string + runId: string + targetId: string +} + +export interface DeveloperCdpNode { + ref?: string + role: string + name: string + description?: string + value?: string + states?: string[] +} + +export interface DeveloperCdpDiagnostic { + level: "log" | "warning" | "error" + text: string +} + +export interface DeveloperCdpInspection { + target: { id: string; title: string; url: string } + nodes: DeveloperCdpNode[] + diagnostics: DeveloperCdpDiagnostic[] +} + +export type DeveloperCdpAction = + | { runId: string; kind: "click"; ref: string } + | { runId: string; kind: "type"; ref: string; text: string } + +export interface DeveloperCdpScreenshot { + mediaType: "image/png" + data: string + bytes: number +} + +interface CdpSocket { + onopen: ((event: unknown) => void) | null + onmessage: ((event: { data: unknown }) => void) | null + onclose: ((event: unknown) => void) | null + onerror: ((event: unknown) => void) | null + send(data: string): void + close(): void +} + +interface DeveloperCdpDependencies { + fetch: typeof globalThis.fetch + connect: (url: string) => CdpSocket + timeoutMs: number + maxScreenshotBytes: number +} + +interface CdpTarget { + id: string + title: string + type: string + url: string + webSocketDebuggerUrl: string +} + +interface CdpResponse { + id?: number + method?: string + params?: Record + result?: Record + error?: { code?: number; message?: string } +} + +interface PendingCommand { + resolve: (value: Record) => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +interface RunState { + endpoint: string + preferredTargetId: string + target?: CdpTarget + socket?: CdpSocket + open?: Promise + nextCommandId: number + navigationEpoch: number + pending: Map + refs: Map + diagnostics: DeveloperCdpDiagnostic[] +} + +interface AxValue { value?: unknown } +interface AxNode { + ignored?: boolean + backendDOMNodeId?: number + role?: AxValue + name?: AxValue + description?: AxValue + value?: AxValue + properties?: Array<{ name?: string; value?: AxValue }> +} + +export class DeveloperCdp { + private readonly dependencies: DeveloperCdpDependencies + private readonly runs = new Map() + private nextRefId = 0 + + constructor(dependencies: Partial = {}) { + this.dependencies = { + fetch: globalThis.fetch, + connect: (url) => new WebSocket(url) as unknown as CdpSocket, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxScreenshotBytes: DEFAULT_MAX_SCREENSHOT_BYTES, + ...dependencies, + } + } + + async inspect(identity: DeveloperCdpIdentity): Promise { + const state = await this.ensure(identity) + const epoch = state.navigationEpoch + const result = await this.command(state, "Accessibility.getFullAXTree") + if (epoch !== state.navigationEpoch) throw new Error("Page navigated during inspection; inspect again") + + state.refs.clear() + const nodes = Array.isArray(result.nodes) ? result.nodes as AxNode[] : [] + const snapshot: DeveloperCdpNode[] = [] + let textSize = 0 + for (const node of nodes) { + if (node.ignored || !Number.isInteger(node.backendDOMNodeId)) continue + const role = valueOf(node.role) + const name = valueOf(node.name) + if (!role || (!name && ["generic", "none", "StaticText", "InlineTextBox"].includes(role))) continue + const description = valueOf(node.description) + const value = valueOf(node.value) + const states = (node.properties ?? []) + .filter((property) => ["checked", "disabled", "expanded", "focused", "selected"].includes(property.name ?? "")) + .map((property) => `${property.name}=${String(property.value?.value)}`) + const size = role.length + name.length + description.length + value.length + states.join(" ").length + if (snapshot.length >= SNAPSHOT_NODE_LIMIT || textSize + size > SNAPSHOT_TEXT_LIMIT) break + textSize += size + const ref = `ax${++this.nextRefId}` + state.refs.set(ref, { + backendNodeId: node.backendDOMNodeId!, + targetId: state.target!.id, + epoch, + }) + snapshot.push({ + ref, + role, + name, + ...(description ? { description } : {}), + ...(value ? { value } : {}), + ...(states.length ? { states } : {}), + }) + } + return { + target: { id: state.target!.id, title: state.target!.title, url: state.target!.url }, + nodes: snapshot, + diagnostics: state.diagnostics.splice(0), + } + } + + async act(action: DeveloperCdpAction): Promise { + const state = await this.ensureRun(action.runId) + const node = this.resolveRef(state, action.ref) + if (action.kind === "click") { + await this.click(state, node.backendNodeId, node.epoch) + return + } + await this.command(state, "DOM.focus", { backendNodeId: node.backendNodeId }) + this.assertActionCurrent(state, node.epoch) + await this.command(state, "Input.insertText", { text: action.text }) + } + + async screenshot(runId: string): Promise { + const state = await this.ensureRun(runId) + const result = await this.command(state, "Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }) + if (typeof result.data !== "string") throw new Error("Chrome returned an invalid screenshot") + const bytes = Buffer.from(result.data, "base64").byteLength + if (bytes > this.dependencies.maxScreenshotBytes) { + throw new Error(`Screenshot exceeds ${this.dependencies.maxScreenshotBytes} byte limit`) + } + return { mediaType: "image/png", data: result.data, bytes } + } + + close(runId?: string): void { + for (const [id, state] of this.runs) { + if (runId && id !== runId) continue + this.disconnect(state, new Error("Developer CDP controller closed")) + this.runs.delete(id) + } + } + + private async ensure(identity: DeveloperCdpIdentity): Promise { + if (!identity.runId.trim()) throw new Error("runId is required") + const endpoint = discoveryUrl(identity.endpoint) + for (const [runId, previous] of this.runs) { + if (runId === identity.runId) continue + this.disconnect(previous, new Error("Developer run was replaced")) + this.runs.delete(runId) + } + let state = this.runs.get(identity.runId) + if (!state || state.endpoint !== endpoint || state.preferredTargetId !== identity.targetId) { + if (state) this.disconnect(state, new Error("CDP endpoint changed")) + state = this.newState(endpoint, identity.targetId) + this.runs.set(identity.runId, state) + } + return this.ensureTarget(state) + } + + private async ensureRun(runId: string): Promise { + const state = this.runs.get(runId) + if (!state) throw new Error(`Run ${runId} has not been inspected`) + return this.ensureTarget(state) + } + + private newState(endpoint: string, preferredTargetId: string): RunState { + return { + endpoint, + preferredTargetId, + nextCommandId: 0, + navigationEpoch: 0, + pending: new Map(), + refs: new Map(), + diagnostics: [], + } + } + + private async ensureTarget(state: RunState): Promise { + const response = await this.dependencies.fetch(state.endpoint, { + signal: AbortSignal.timeout(this.dependencies.timeoutMs), + }) + if (!response.ok) throw new Error(`Chrome target discovery failed (HTTP ${response.status})`) + const candidates = await response.json() as unknown + if (!Array.isArray(candidates)) throw new Error("Chrome target discovery returned invalid JSON") + const targets = candidates.filter(isTarget) + const target = targets.find((item) => item.id === state.preferredTargetId && item.type === "page") + if (!target) throw new Error(`Chrome target ${state.preferredTargetId} is unavailable`) + + const replaced = state.target?.id !== target.id || state.target.webSocketDebuggerUrl !== target.webSocketDebuggerUrl + if (replaced) { + this.disconnect(state, new Error("Chrome target was replaced")) + } + state.target = target + if (replaced) { + await this.connect(state, target) + } else if (!state.socket) { + await this.connect(state, target) + } else { + await state.open + } + return state + } + + private async connect(state: RunState, target: CdpTarget): Promise { + const socket = this.dependencies.connect(target.webSocketDebuggerUrl) + state.socket = socket + state.open = new Promise((resolve, reject) => { + const fail = (error: Error) => { + clearTimeout(timer) + if (state.socket === socket) { + state.socket = undefined + state.open = undefined + } + socket.close() + reject(error) + } + const timer = setTimeout(() => fail(new Error("CDP WebSocket connection timed out")), this.dependencies.timeoutMs) + socket.onopen = () => { clearTimeout(timer); resolve() } + socket.onerror = () => fail(new Error("CDP WebSocket connection failed")) + }) + socket.onmessage = (event) => this.onMessage(state, socket, event.data) + socket.onclose = () => { + if (state.socket !== socket) return + state.socket = undefined + state.open = undefined + this.invalidateRefs(state) + this.rejectPending(state, new Error("CDP WebSocket closed")) + } + await state.open + await this.command(state, "Page.enable") + await this.command(state, "Runtime.enable") + await this.command(state, "Accessibility.enable") + } + + private command( + state: RunState, + method: string, + params?: Record, + ): Promise> { + if (!state.socket) return Promise.reject(new Error("CDP WebSocket is not connected")) + const id = ++state.nextCommandId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + state.pending.delete(id) + reject(new Error(`CDP command ${method} timed out`)) + }, this.dependencies.timeoutMs) + state.pending.set(id, { resolve, reject, timer }) + state.socket!.send(JSON.stringify({ id, method, ...(params ? { params } : {}) })) + }) + } + + private onMessage(state: RunState, socket: CdpSocket, data: unknown): void { + if (state.socket !== socket) return + let message: CdpResponse + try { + const text = typeof data === "string" ? data : Buffer.from(data as ArrayBuffer).toString("utf8") + message = JSON.parse(text) as CdpResponse + } catch { + return + } + if (typeof message.id === "number") { + const pending = state.pending.get(message.id) + if (!pending) return + clearTimeout(pending.timer) + state.pending.delete(message.id) + if (message.error) pending.reject(new Error(message.error.message ?? `CDP error ${message.error.code ?? "unknown"}`)) + else pending.resolve(message.result ?? {}) + return + } + if (message.method === "Page.frameNavigated") { + state.navigationEpoch++ + state.refs.clear() + return + } + const diagnostic = readDiagnostic(message) + if (diagnostic) { + state.diagnostics.push(diagnostic) + if (state.diagnostics.length > DIAGNOSTIC_LIMIT) state.diagnostics.splice(0, state.diagnostics.length - DIAGNOSTIC_LIMIT) + } + } + + private resolveRef(state: RunState, ref: string): { backendNodeId: number; epoch: number } { + const node = state.refs.get(ref) + if (!node || node.targetId !== state.target?.id || node.epoch !== state.navigationEpoch) { + throw new Error(`Unknown or stale accessibility ref: ${ref}`) + } + return node + } + + private async click(state: RunState, backendNodeId: number, epoch: number): Promise { + await this.command(state, "DOM.scrollIntoViewIfNeeded", { backendNodeId }) + this.assertActionCurrent(state, epoch) + const result = await this.command(state, "DOM.getBoxModel", { backendNodeId }) + this.assertActionCurrent(state, epoch) + const content = (result.model as { content?: unknown } | undefined)?.content + if (!Array.isArray(content) || content.length < 8 || content.some((value) => typeof value !== "number")) { + throw new Error("Chrome could not determine the element bounds") + } + const x = (content[0] + content[2] + content[4] + content[6]) / 4 + const y = (content[1] + content[3] + content[5] + content[7]) / 4 + await this.command(state, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y }) + this.assertActionCurrent(state, epoch) + await this.command(state, "Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 }) + this.assertActionCurrent(state, epoch) + await this.command(state, "Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 }) + } + + private assertActionCurrent(state: RunState, epoch: number): void { + if (state.navigationEpoch !== epoch || !state.socket) { + throw new Error("Page changed during action; inspect again") + } + } + + private invalidateRefs(state: RunState): void { + state.navigationEpoch++ + state.refs.clear() + } + + private disconnect(state: RunState, error: Error): void { + const socket = state.socket + state.socket = undefined + state.open = undefined + this.invalidateRefs(state) + this.rejectPending(state, error) + socket?.close() + } + + private rejectPending(state: RunState, error: Error): void { + for (const pending of state.pending.values()) { + clearTimeout(pending.timer) + pending.reject(error) + } + state.pending.clear() + } +} + +function discoveryUrl(endpoint: string): string { + let url: URL + try { + url = new URL(endpoint) + } catch { + throw new Error("CDP endpoint must be an HTTP or HTTPS URL") + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("CDP endpoint must be an HTTP or HTTPS URL") + } + if (url.hostname !== "127.0.0.1") throw new Error("CDP endpoint must use the IPv4 loopback address") + if (url.username || url.password) throw new Error("CDP endpoint must not contain credentials") + return new URL("/json/list", url).href +} + +function isTarget(value: unknown): value is CdpTarget { + const target = value as Partial | null + return !!target + && typeof target.id === "string" + && typeof target.title === "string" + && typeof target.type === "string" + && typeof target.url === "string" + && typeof target.webSocketDebuggerUrl === "string" +} + +function valueOf(value: AxValue | undefined): string { + return typeof value?.value === "string" ? value.value : "" +} + +function readDiagnostic(message: CdpResponse): DeveloperCdpDiagnostic | undefined { + if (message.method === "Runtime.consoleAPICalled") { + const params = message.params as { type?: unknown; args?: Array<{ value?: unknown; description?: unknown }> } | undefined + const text = params?.args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? "" + const level = params?.type === "error" || params?.type === "assert" ? "error" + : params?.type === "warning" ? "warning" : "log" + return { level, text: boundedText(text) } + } + if (message.method === "Runtime.exceptionThrown") { + const params = message.params as { exceptionDetails?: { text?: unknown; exception?: { description?: unknown } } } | undefined + const detail = params?.exceptionDetails + return { level: "error", text: boundedText(String(detail?.exception?.description ?? detail?.text ?? "Runtime exception")) } + } + return undefined +} + +function boundedText(text: string): string { + return text.length > DIAGNOSTIC_TEXT_LIMIT ? `${text.slice(0, DIAGNOSTIC_TEXT_LIMIT - 3)}...` : text +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a0f812886..63cf4b736 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -34,6 +34,8 @@ import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShut import { AutoAcceptManager } from "./permissions/auto-accept-manager" import { createOpencodePermissionReplier } from "./permissions/opencode-replier" import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata" +import { NativeParent } from "./native-parent" +import { AUTOMATION_BRIDGE_PATH, createAutomationBridgeRegistration, installAutomationPlugin, publishAutomationBridge } from "./opencode/automation-plugin" const require = createRequire(import.meta.url) @@ -98,6 +100,7 @@ interface ShutdownStdinSource { export function installShutdownStdinHandler( source: ShutdownStdinSource, shutdown: (signal: ServerShutdownTrigger) => Promise, + handleLine?: (line: string) => boolean, ): void { let buffer = "" let requested = false @@ -106,7 +109,12 @@ export function installShutdownStdinHandler( buffer += chunk.toString() const lines = buffer.split(/\r?\n/) buffer = lines.pop() ?? "" - if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND)) return + let shutdownRequested = false + for (const line of lines) { + if (line.trim() === STDIN_SHUTDOWN_COMMAND) shutdownRequested = true + else handleLine?.(line) + } + if (!shutdownRequested) return requested = true source.off?.("data", onData) @@ -369,6 +377,8 @@ async function main() { eventBus, logger: workspaceLogger, }) + const nativeParent = new NativeParent() + const automationBridge = createAutomationBridgeRegistration("http://127.0.0.1") const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot, @@ -486,6 +496,8 @@ async function main() { uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, + nativeParent, + automationBridgeToken: automationBridge.token, }) : null @@ -512,6 +524,8 @@ async function main() { uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, + nativeParent, + automationBridgeToken: automationBridge.token, }) : null @@ -566,6 +580,19 @@ async function main() { serverMeta.host = options.host serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" + let removeAutomationBridge: (() => Promise) | undefined + if (nativeParent.available) { + try { + await installAutomationPlugin() + removeAutomationBridge = await publishAutomationBridge({ + ...automationBridge, + url: new URL(AUTOMATION_BRIDGE_PATH, localUrl).href, + }) + } catch (error) { + logger.warn({ err: error }, "Failed to install the OpenCode automation plugin") + } + } + if (serverMeta.remotePort && remoteUrl) { serverMeta.addresses = remoteAddresses.length ? remoteAddresses @@ -609,6 +636,8 @@ async function main() { stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(), stopWorkspaces: () => workspaceManager.shutdown(), stopHttpServers: async () => { + nativeParent.close() + await removeAutomationBridge?.() yoloManager.stop() const results = await Promise.allSettled(servers.map((srv) => srv.stop())) const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) @@ -626,7 +655,7 @@ async function main() { }) installShutdownSignalHandlers(process, shutdown) - installShutdownStdinHandler(process.stdin, shutdown) + installShutdownStdinHandler(process.stdin, shutdown, (line) => nativeParent.handleLine(line)) } if (path.resolve(process.argv[1] ?? "") === __filename) { diff --git a/packages/server/src/native-parent.test.ts b/packages/server/src/native-parent.test.ts new file mode 100644 index 000000000..ba5d6e51f --- /dev/null +++ b/packages/server/src/native-parent.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { NativeParent, NATIVE_REQUEST_PREFIX, NATIVE_RESPONSE_PREFIX } from "./native-parent" + +test("matches native responses to requests and rejects pending work on shutdown", async () => { + const lines: string[] = [] + const parent = new NativeParent({ write: (line: string | Uint8Array) => { lines.push(String(line)); return true } }, true) + + const request = parent.request<{ available: boolean }>("developer.status", {}) + const envelope = JSON.parse(lines[0].slice(NATIVE_REQUEST_PREFIX.length)) as { id: string; deadline: number } + assert.ok(envelope.deadline > Date.now()) + assert.equal(parent.handleLine(`${NATIVE_RESPONSE_PREFIX}${JSON.stringify({ v: 1, id: envelope.id, ok: true, result: { available: true } })}`), true) + assert.deepEqual(await request, { available: true }) + + const pending = parent.request("developer.status", {}) + parent.close() + await assert.rejects(pending, /shutting down/) +}) diff --git a/packages/server/src/native-parent.ts b/packages/server/src/native-parent.ts new file mode 100644 index 000000000..3f7f6d300 --- /dev/null +++ b/packages/server/src/native-parent.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto" + +export const NATIVE_REQUEST_PREFIX = "CODENOMAD_NATIVE_REQUEST:" +export const NATIVE_RESPONSE_PREFIX = "CODENOMAD_NATIVE_RESPONSE:" +const DEFAULT_TIMEOUT_MS = 90_000 +const MAX_PENDING = 32 + +interface NativeResponse { + v: 1 + id: string + ok: boolean + result?: unknown + error?: { code?: string; message?: string } +} + +interface PendingRequest { + resolve(value: unknown): void + reject(error: Error): void + timeout: NodeJS.Timeout +} + +export class NativeParent { + private readonly pending = new Map() + private closed = false + + constructor( + private readonly output: Pick = process.stdout, + readonly available = process.env.CODENOMAD_NATIVE_PARENT === "1", + ) {} + + request(method: string, params: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + if (!this.available) return Promise.reject(new Error("Native control is unavailable in this host")) + if (this.closed) return Promise.reject(new Error("Native parent is shutting down")) + if (this.pending.size >= MAX_PENDING) return Promise.reject(new Error("Too many pending native requests")) + + const id = randomUUID() + const deadline = Date.now() + timeoutMs + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Native request timed out: ${method}`)) + }, timeoutMs) + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout }) + this.output.write(`${NATIVE_REQUEST_PREFIX}${JSON.stringify({ v: 1, id, method, params, deadline })}\n`) + }) + } + + handleLine(line: string): boolean { + const trimmed = line.trim() + if (!trimmed.startsWith(NATIVE_RESPONSE_PREFIX)) return false + let response: NativeResponse + try { + response = JSON.parse(trimmed.slice(NATIVE_RESPONSE_PREFIX.length)) as NativeResponse + } catch { + return true + } + if (response.v !== 1 || typeof response.id !== "string") return true + const pending = this.pending.get(response.id) + if (!pending) return true + this.pending.delete(response.id) + clearTimeout(pending.timeout) + if (response.ok) pending.resolve(response.result) + else pending.reject(new Error(response.error?.message || response.error?.code || "Native request failed")) + return true + } + + close(): void { + if (this.closed) return + this.closed = true + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout) + pending.reject(new Error("Native parent is shutting down")) + } + this.pending.clear() + } +} diff --git a/packages/server/src/opencode/automation-plugin.test.ts b/packages/server/src/opencode/automation-plugin.test.ts new file mode 100644 index 000000000..c059b03bb --- /dev/null +++ b/packages/server/src/opencode/automation-plugin.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import test from "node:test" +import { installAutomationPlugin, parseDeveloperAction, setupAutomationPlugin } from "./automation-plugin" + +test("validates developer automation actions", () => { + assert.deepEqual(parseDeveloperAction({ action: "type", ref: "e4", text: "CodeNomad" }), { + action: "type", + ref: "e4", + text: "CodeNomad", + }) + assert.deepEqual(parseDeveloperAction({ action: "restart" }), { action: "restart" }) + assert.throws(() => parseDeveloperAction({ action: "click" }), /click requires ref/) +}) + +test("registers developer tools only for CodeNomad-owned locations", async () => { + let skill: Record | undefined + const tools: string[] = [] + await setupAutomationPlugin({ + location: { directory: "D:\\project" }, + skill: { transform: async (callback) => callback({ add: (value) => { skill = value } }) }, + tool: { transform: async (callback) => callback({ add: (value) => tools.push(value.name) }) }, + }, async () => true) + + assert.equal(skill?.id, "codenomad-automation") + assert.equal(skill?.autoinvoke, true) + assert.match(String(skill?.content), /codenomad\.inspect/) + assert.deepEqual(tools, ["inspect", "act", "screenshot"]) + + let transformed = false + await setupAutomationPlugin({ + location: { directory: "D:\\other" }, + skill: { transform: async () => { transformed = true } }, + tool: { transform: async () => { transformed = true } }, + }, async () => false) + assert.equal(transformed, false) +}) + +test("includes the OpenCode workspace identity in location discovery", async () => { + let location: [string, string | undefined] | undefined + await setupAutomationPlugin({ + location: { directory: "D:\\project", workspaceID: "workspace-1" }, + skill: { transform: async () => undefined }, + tool: { transform: async () => undefined }, + }, async (directory, workspaceID) => { + location = [directory, workspaceID] + return false + }) + assert.deepEqual(location, ["D:\\project", "workspace-1"]) +}) + +test("installs the automation plugin and removes obsolete browser wrappers", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-plugin-")) + const previous = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = root + + try { + const directory = path.join(root, "opencode", "plugins") + const legacySkill = path.join(root, "opencode", "skills", "codenomad-browser") + await installAutomationPlugin() + await writeFile(path.join(directory, "codenomad-browser.mjs"), "legacy") + await mkdir(legacySkill, { recursive: true }) + await writeFile(path.join(legacySkill, "SKILL.md"), "legacy") + await installAutomationPlugin() + + assert.match(await readFile(path.join(directory, "codenomad-automation.ts"), "utf8"), /^export \{ default \} from "file:/) + await assert.rejects(readFile(path.join(directory, "codenomad-browser.mjs"), "utf8"), { code: "ENOENT" }) + await assert.rejects(readFile(path.join(legacySkill, "SKILL.md"), "utf8"), { code: "ENOENT" }) + } finally { + if (previous === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previous + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/server/src/opencode/automation-plugin.ts b/packages/server/src/opencode/automation-plugin.ts new file mode 100644 index 000000000..5a4413112 --- /dev/null +++ b/packages/server/src/opencode/automation-plugin.ts @@ -0,0 +1,267 @@ +import { randomBytes } from "node:crypto" +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +export const AUTOMATION_BRIDGE_PATH = "/api/opencode-plugin/automation" +const PLUGIN_FILENAME = "codenomad-automation.ts" +const REQUEST_TIMEOUT_MS = 95_000 +const DISCOVERY_TIMEOUT_MS = 35_000 +const SKILL_ID = "codenomad-automation" +const SKILL_CONTENT = `# CodeNomad Automation + +When a desktop build is connected in Advanced Settings, use \`codenomad.inspect\` to read its accessibility tree, runtime diagnostics, and recent launch logs. Use refs only from the latest inspection with \`codenomad.act\`, and use \`codenomad.screenshot\` for visual evidence. After changing the desktop implementation, call \`codenomad.act\` with \`restart\`, wait for readiness, and inspect again. +` + +export type DeveloperAction = + | { action: "inspect" } + | { action: "click"; ref: string } + | { action: "type"; ref: string; text: string } + | { action: "restart" } + | { action: "screenshot" } + +export interface AutomationBridgeRegistration { + version: 1 + url: string + token: string + pid: number + startedAt: number +} + +interface ToolContext { + readonly sessionID: string +} + +interface ToolDraft { + add(tool: { + name: string + description: string + input: Record + options: { namespace: string; codemode: false } + execute(input: unknown, context: ToolContext): Promise<{ content: string | Array> }> + }): void +} + +interface AutomationPluginContext { + location: { directory: string; workspaceID?: string } + skill: { + transform(callback: (draft: { + add(skill: { + id: string + name: string + description: string + slash: boolean + autoinvoke: boolean + location: string + content: string + }): void + }) => void): Promise + } + tool: { + transform(callback: (draft: ToolDraft) => void): Promise + } +} + +interface BridgeResponse { + result?: unknown + error?: string +} + +export function automationBridgeDirectory(): string { + if (process.platform === "win32" && process.env.LOCALAPPDATA) { + return path.join(process.env.LOCALAPPDATA, "CodeNomad", "automation-bridges") + } + if (process.env.XDG_RUNTIME_DIR) return path.join(process.env.XDG_RUNTIME_DIR, "codenomad", "automation-bridges") + return path.join(os.homedir(), ".config", "codenomad", "automation-bridges") +} + +export function createAutomationBridgeRegistration(url: string): AutomationBridgeRegistration { + return { + version: 1, + url: new URL(AUTOMATION_BRIDGE_PATH, url).href, + token: randomBytes(32).toString("base64url"), + pid: process.pid, + startedAt: Date.now(), + } +} + +export async function installAutomationPlugin(): Promise { + const configRoot = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config") + const pluginDirectory = path.join(configRoot, "opencode", "plugins") + const pluginPath = path.join(pluginDirectory, PLUGIN_FILENAME) + const implementation = pathToFileURL(fileURLToPath(import.meta.url)).href + const source = `export { default } from ${JSON.stringify(implementation)}\n` + await mkdir(pluginDirectory, { recursive: true }) + await writeAtomic(pluginPath, source) + await Promise.all([ + rm(path.join(pluginDirectory, "codenomad-browser.ts"), { force: true }), + rm(path.join(pluginDirectory, "codenomad-browser.mjs"), { force: true }), + rm(path.join(configRoot, "opencode", "skills", "codenomad-browser"), { recursive: true, force: true }), + ]) +} + +export async function publishAutomationBridge(registration: AutomationBridgeRegistration): Promise<() => Promise> { + const directory = automationBridgeDirectory() + const target = path.join(directory, `${registration.pid}-${registration.token.slice(0, 12)}.json`) + await mkdir(directory, { recursive: true }) + await writeAtomic(target, `${JSON.stringify(registration)}\n`) + return () => rm(target, { force: true }) +} + +async function writeAtomic(target: string, content: string): Promise { + const temporary = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp` + await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 }) + await rename(temporary, target) +} + +export function parseDeveloperAction(input: unknown): DeveloperAction { + if (!input || typeof input !== "object") throw new Error("Developer automation input must be an object") + const value = input as Record + switch (value.action) { + case "inspect": + case "restart": + case "screenshot": + return { action: value.action } + case "click": + if (typeof value.ref !== "string") throw new Error("click requires ref from the latest inspection") + return { action: "click", ref: value.ref } + case "type": + if (typeof value.ref !== "string" || typeof value.text !== "string") throw new Error("type requires ref and text") + return { action: "type", ref: value.ref, text: value.text } + default: + throw new Error("Unsupported developer automation action") + } +} + +async function registrations(): Promise { + const directory = automationBridgeDirectory() + const names = await readdir(directory).catch(() => [] as string[]) + const found: AutomationBridgeRegistration[] = [] + for (const name of names) { + if (!name.endsWith(".json")) continue + try { + const value = JSON.parse(await readFile(path.join(directory, name), "utf8")) as Partial + if (value.version !== 1 || typeof value.url !== "string" || typeof value.token !== "string" + || typeof value.pid !== "number" || typeof value.startedAt !== "number") continue + const url = new URL(value.url) + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.pathname !== AUTOMATION_BRIDGE_PATH) continue + found.push(value as AutomationBridgeRegistration) + } catch { + // Stale or partially-written registrations are ignored. + } + } + return found.sort((left, right) => right.startedAt - left.startedAt).slice(0, 64) +} + +async function callBridge( + registration: AutomationBridgeRegistration, + body: Record, + timeoutMs = DISCOVERY_TIMEOUT_MS, +): Promise<{ status: number; body: BridgeResponse }> { + const response = await fetch(registration.url, { + method: "POST", + headers: { "content-type": "application/json", "x-codenomad-automation-token": registration.token }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }) + return { status: response.status, body: await response.json() as BridgeResponse } +} + +async function ownsLocation(directory: string, workspaceID?: string): Promise { + const active = await registrations() + const claims = await Promise.all(active.map(async (registration) => { + try { + return (await callBridge(registration, { mode: "location", directory, workspaceID })).status === 200 + } catch { + return false + } + })) + return claims.some(Boolean) +} + +function formatBridgeResult(resultValue: unknown) { + const result = resultValue as { image?: { data: string; mime: string }; [key: string]: unknown } | undefined + if (result?.image) { + const content: Array> = [ + { type: "text", text: "Captured the connected CodeNomad build." }, + { type: "file", uri: `data:${result.image.mime};base64,${result.image.data}`, mime: result.image.mime, name: "codenomad.png" }, + ] + return { + content, + } + } + return { content: JSON.stringify(result ?? null, null, 2) } +} + +async function executeDeveloperTool(sessionID: string, command: DeveloperAction) { + const active = await registrations() + const probes = await Promise.all(active.map(async (registration) => { + try { + return (await callBridge(registration, { mode: "developer-probe", sessionID })).status === 200 ? registration : undefined + } catch { + return undefined + } + })) + const targets = probes.filter((value): value is AutomationBridgeRegistration => Boolean(value)) + if (targets.length === 0) throw new Error("Developer Automation is not running for this CodeNomad session") + if (targets.length > 1) throw new Error("Multiple CodeNomad instances expose Developer Automation for this session") + const response = await callBridge(targets[0], { mode: "developer-execute", sessionID, command }, REQUEST_TIMEOUT_MS) + if (response.status !== 200) throw new Error(response.body.error || `Developer automation failed (${response.status})`) + return formatBridgeResult(response.body.result) +} + +export async function setupAutomationPlugin( + context: AutomationPluginContext, + locationOwned: (directory: string, workspaceID?: string) => Promise = ownsLocation, +): Promise { + if (!await locationOwned(context.location.directory, context.location.workspaceID)) return + await context.skill.transform((draft) => { + draft.add({ + id: SKILL_ID, + name: "CodeNomad Automation", + description: "Inspect and control desktop builds connected through Developer Automation.", + slash: false, + autoinvoke: true, + location: import.meta.url, + content: SKILL_CONTENT, + }) + }) + await context.tool.transform((draft) => { + draft.add({ + name: "inspect", + description: "Inspect the accessibility tree, runtime feedback, and connected target of the CodeNomad build running in Developer Automation.", + input: { type: "object", properties: {}, additionalProperties: false }, + options: { namespace: "codenomad", codemode: false }, + execute: (_input, tool) => executeDeveloperTool(tool.sessionID, { action: "inspect" }), + }) + draft.add({ + name: "act", + description: "Click or type using a ref from the latest codenomad.inspect result, or restart the connected CodeNomad build after changes.", + input: { + type: "object", + properties: { + action: { type: "string", enum: ["click", "type", "restart"] }, + ref: { type: "string", description: "Element ref from the latest inspection" }, + text: { type: "string", description: "Text for type" }, + }, + required: ["action"], + additionalProperties: false, + }, + options: { namespace: "codenomad", codemode: false }, + execute: (input, tool) => executeDeveloperTool(tool.sessionID, parseDeveloperAction(input)), + }) + draft.add({ + name: "screenshot", + description: "Capture the visible page of the CodeNomad build running in Developer Automation.", + input: { type: "object", properties: {}, additionalProperties: false }, + options: { namespace: "codenomad", codemode: false }, + execute: (_input, tool) => executeDeveloperTool(tool.sessionID, { action: "screenshot" }), + }) + }) +} + +export default { + id: "codenomad.automation", + setup: setupAutomationPlugin, +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 88bef6078..19d1d8b53 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -45,6 +45,9 @@ import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaS import type { RemoteProxySessionManager } from "./remote-proxy" import { createOpenCodeUpdateService } from "../opencode-update/service" import { WorktreeDeletionFence } from "../workspaces/worktree-session-evacuation" +import type { NativeParent } from "../native-parent" +import { isAutomationPluginRequest, registerAutomationPluginRoute } from "./routes/automation-plugin" +import { DeveloperCdp } from "../developer-cdp" interface HttpServerDeps { bindHost: string @@ -69,6 +72,8 @@ interface HttpServerDeps { uiStaticDir: string uiDevServerUrl?: string logger: Logger + nativeParent: NativeParent + automationBridgeToken: string } interface HttpServerStartResult { @@ -240,7 +245,11 @@ export function createHttpServer(deps: HttpServerDeps) { return } - if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete || isPreviewCapability) { + const isAutomationBridge = isAutomationPluginRequest(request, { + authManager: deps.authManager, + bridgeToken: deps.automationBridgeToken, + }) + if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete || isPreviewCapability || isAutomationBridge) { done() return } @@ -310,6 +319,15 @@ export function createHttpServer(deps: HttpServerDeps) { registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) registerPreviewRoutes(app, { previewManager: deps.previewManager }) + const developerCdp = new DeveloperCdp() + registerAutomationPluginRoute(app, { + authManager: deps.authManager, + bridgeToken: deps.automationBridgeToken, + nativeParent: deps.nativeParent, + developerCdp, + workspaceManager: deps.workspaceManager, + }) + app.addHook("onClose", async () => developerCdp.close()) registerUsageRoutes(app) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/automation-plugin.test.ts b/packages/server/src/server/routes/automation-plugin.test.ts new file mode 100644 index 000000000..d641b385a --- /dev/null +++ b/packages/server/src/server/routes/automation-plugin.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import { AUTOMATION_BRIDGE_PATH } from "../../opencode/automation-plugin" +import { registerAutomationPluginRoute } from "./automation-plugin" + +test("fences developer automation by owned session and forwards CDP actions", async () => { + const app = Fastify({ logger: false }) + const nativeCalls: Array<{ method: string; params: unknown }> = [] + let state = "ready" + let runId = "run-1" + let inspectedIdentity: unknown + registerAutomationPluginRoute(app, { + authManager: { isLoopbackRequest: () => true }, + bridgeToken: "secret", + nativeParent: { + request: async (method: string, params: unknown) => { + nativeCalls.push({ method, params }) + if (method === "developer.restart") { + runId = "run-2" + return { state: "starting", runId } + } + return { + status: { state, runId, cdpUrl: "http://127.0.0.1:9222", targetId: "page-1" }, + logs: [{ stream: "system", message: "ready" }], + } + }, + }, + developerCdp: { + inspect: async (identity: unknown) => { + inspectedIdentity = identity + return { target: { id: "page-1", title: "CodeNomad", url: "http://app.test/" }, nodes: [], diagnostics: [] } + }, + close: () => undefined, + }, + workspaceManager: { + getSharedServiceClient: async () => ({ session: { get: async () => ({ location: { directory: "D:\\project" } }) } }), + list: () => [{ id: "workspace-1" }], + ownsLocation: async () => true, + }, + } as never) + + const request = async (body: Record) => app.inject({ + method: "POST", + url: AUTOMATION_BRIDGE_PATH, + headers: { "x-codenomad-automation-token": "secret" }, + payload: body, + }) + + assert.equal((await request({ mode: "location", directory: "D:\\project", workspaceID: "workspace-1" })).statusCode, 200) + assert.equal((await request({ mode: "developer-probe", sessionID: "session-1" })).statusCode, 200) + const inspect = await request({ mode: "developer-execute", sessionID: "session-1", command: { action: "inspect" } }) + assert.equal(inspect.statusCode, 200) + assert.deepEqual(inspectedIdentity, { endpoint: "http://127.0.0.1:9222", runId: "run-1", targetId: "page-1" }) + assert.deepEqual(inspect.json().result.logs, [{ stream: "system", message: "ready" }]) + + state = "error" + const restart = await request({ mode: "developer-execute", sessionID: "session-1", command: { action: "restart" } }) + assert.equal(restart.statusCode, 200) + assert.deepEqual(nativeCalls.slice(-2), [ + { method: "developer.status", params: {} }, + { method: "developer.restart", params: {} }, + ]) + assert.equal((await request({ mode: "developer-probe", sessionID: "session-2" })).statusCode, 409) + + state = "stopped" + assert.equal((await request({ mode: "developer-probe", sessionID: "session-2" })).statusCode, 404) + state = "ready" + runId = "run-3" + assert.equal((await request({ mode: "developer-probe", sessionID: "session-2" })).statusCode, 200) + + const unauthorized = await app.inject({ method: "POST", url: AUTOMATION_BRIDGE_PATH, payload: { mode: "developer-probe", sessionID: "session-1" } }) + assert.equal(unauthorized.statusCode, 401) + await app.close() +}) diff --git a/packages/server/src/server/routes/automation-plugin.ts b/packages/server/src/server/routes/automation-plugin.ts new file mode 100644 index 000000000..9215c9cbc --- /dev/null +++ b/packages/server/src/server/routes/automation-plugin.ts @@ -0,0 +1,134 @@ +import { timingSafeEqual } from "node:crypto" +import type { FastifyInstance, FastifyRequest } from "fastify" +import type { AuthManager } from "../../auth/manager" +import type { DeveloperCdp } from "../../developer-cdp" +import type { NativeParent } from "../../native-parent" +import { AUTOMATION_BRIDGE_PATH, parseDeveloperAction } from "../../opencode/automation-plugin" +import type { WorkspaceManager } from "../../workspaces/manager" + +interface AutomationPluginRouteDeps { + authManager: AuthManager + bridgeToken: string + nativeParent: NativeParent + workspaceManager: WorkspaceManager + developerCdp: DeveloperCdp +} + +interface DeveloperNativeStatus { + status: { + state: string + runId?: string + cdpUrl?: string + targetId?: string + } + logs?: unknown[] +} + +export function isAutomationPluginRequest( + request: FastifyRequest, + deps: Pick, +): boolean { + if (request.method !== "POST" || request.url.split("?")[0] !== AUTOMATION_BRIDGE_PATH) return false + if (!deps.authManager.isLoopbackRequest(request)) return false + const supplied = request.headers["x-codenomad-automation-token"] + if (typeof supplied !== "string") return false + const actual = Buffer.from(supplied) + const expected = Buffer.from(deps.bridgeToken) + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +export function registerAutomationPluginRoute(app: FastifyInstance, deps: AutomationPluginRouteDeps): void { + let developerOwner: { runId: string; sessionID: string } | undefined + app.post(AUTOMATION_BRIDGE_PATH, { bodyLimit: 32 * 1024 }, async (request, reply) => { + if (!isAutomationPluginRequest(request, deps)) return reply.code(401).send({ error: "Unauthorized automation bridge" }) + const body = request.body as { mode?: unknown; directory?: unknown; workspaceID?: unknown; sessionID?: unknown; command?: unknown } | undefined + if (body?.mode === "location") { + if (typeof body.directory !== "string" || body.directory.length === 0 || body.directory.length > 32_768) { + return reply.code(400).send({ error: "Invalid automation bridge location" }) + } + if (body.workspaceID !== undefined && (typeof body.workspaceID !== "string" || body.workspaceID.length === 0 || body.workspaceID.length > 256)) { + return reply.code(400).send({ error: "Invalid automation bridge workspace" }) + } + const directory = body.directory + const workspaceID = body.workspaceID as string | undefined + const owned = await Promise.all(deps.workspaceManager.list().map((workspace) => + deps.workspaceManager.ownsLocation(workspace.id, { directory, workspaceID }), + )) + if (owned.some(Boolean)) return reply.send({ result: { available: true } }) + return reply.code(404).send({ error: "Location is not owned by this CodeNomad instance" }) + } + if (!body || !["developer-probe", "developer-execute"].includes(String(body.mode)) + || typeof body.sessionID !== "string" || body.sessionID.length > 256) { + return reply.code(400).send({ error: "Invalid automation bridge request" }) + } + + let location + try { + location = (await (await deps.workspaceManager.getSharedServiceClient()).session.get({ sessionID: body.sessionID })).location + } catch { + return reply.code(404).send({ error: "Session not found" }) + } + const owned = (await Promise.all(deps.workspaceManager.list().map((workspace) => + deps.workspaceManager.ownsLocation(workspace.id, location), + ))).some(Boolean) + if (!owned) return reply.code(404).send({ error: "Session is not owned by this CodeNomad instance" }) + + let native: DeveloperNativeStatus + try { + native = await deps.nativeParent.request("developer.status", {}) + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }) + } + const status = native.status + const available = status?.state !== "stopped" && typeof status.runId === "string" + if (!available) { + if (developerOwner) deps.developerCdp.close(developerOwner.runId) + developerOwner = undefined + } else if (developerOwner?.runId !== status.runId) { + if (developerOwner) deps.developerCdp.close(developerOwner.runId) + developerOwner = { runId: status.runId!, sessionID: body.sessionID } + } + if (developerOwner && developerOwner.sessionID !== body.sessionID) { + return reply.code(409).send({ error: "Developer Automation is owned by another session" }) + } + if (body.mode === "developer-probe") { + return available + ? reply.send({ result: { available: true } }) + : reply.code(404).send({ error: "Developer Automation is not running" }) + } + + let command + try { + command = parseDeveloperAction(body.command) + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }) + } + try { + if (command.action === "restart") { + const previousRunId = status.runId + const result = await deps.nativeParent.request("developer.restart", {}) + if (previousRunId) deps.developerCdp.close(previousRunId) + if (typeof result.runId === "string") developerOwner = { runId: result.runId, sessionID: body.sessionID } + return reply.send({ result }) + } + if (!available || status.state !== "ready" || typeof status.runId !== "string" + || typeof status.cdpUrl !== "string" || typeof status.targetId !== "string") { + return reply.code(409).send({ error: "Developer Automation is not ready" }) + } + if (command.action === "inspect") { + const inspection = await deps.developerCdp.inspect({ endpoint: status.cdpUrl, runId: status.runId, targetId: status.targetId }) + return reply.send({ result: { ...inspection, logs: native.logs?.slice(-200) ?? [] } }) + } + if (command.action === "screenshot") { + const image = await deps.developerCdp.screenshot(status.runId) + return reply.send({ result: { image: { data: image.data, mime: image.mediaType } } }) + } + await deps.developerCdp.act(command.action === "type" + ? { runId: status.runId, kind: "type", ref: command.ref, text: command.text } + : { runId: status.runId, kind: "click", ref: command.ref }) + return reply.send({ result: { ok: true } }) + } catch (error) { + return reply.code(409).send({ error: error instanceof Error ? error.message : String(error) }) + } + }) +} diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index c36e77283..dff363104 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -57,6 +57,9 @@ fn main() { "install_stable_update", "open_workspace_target", "set_workspace_menu_enabled", + "developer_run_get", + "developer_run_start", + "developer_run_stop", ]), )) .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 82728cc4c..084910cf9 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -43,6 +43,9 @@ "allow-desktop-launch-acknowledge-folder", "allow-install-stable-update", "allow-open-workspace-target", - "allow-set-workspace-menu-enabled" + "allow-set-workspace-menu-enabled", + "allow-developer-run-get", + "allow-developer-run-start", + "allow-developer-run-stop" ] } 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 74aadde51..0783517cf 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 @@ -{"__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 +{"__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-developer-run-get":{"identifier":"allow-developer-run-get","description":"Enables the developer_run_get command without any pre-configured scope.","commands":{"allow":["developer_run_get"],"deny":[]}},"allow-developer-run-start":{"identifier":"allow-developer-run-start","description":"Enables the developer_run_start command without any pre-configured scope.","commands":{"allow":["developer_run_start"],"deny":[]}},"allow-developer-run-stop":{"identifier":"allow-developer-run-stop","description":"Enables the developer_run_stop command without any pre-configured scope.","commands":{"allow":["developer_run_stop"],"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-developer-run-get":{"identifier":"deny-developer-run-get","description":"Denies the developer_run_get command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_run_get"]}},"deny-developer-run-start":{"identifier":"deny-developer-run-start","description":"Denies the developer_run_start command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_run_start"]}},"deny-developer-run-stop":{"identifier":"deny-developer-run-stop","description":"Denies the developer_run_stop command without any pre-configured scope.","commands":{"allow":[],"deny":["developer_run_stop"]}},"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 98a2b2716..b079316f6 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 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 +{"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","allow-developer-run-get","allow-developer-run-start","allow-developer-run-stop"]},"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 fab3a590e..3c3752dac 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json @@ -428,6 +428,24 @@ "const": "allow-desktop-launch-ready", "markdownDescription": "Enables the desktop_launch_ready command without any pre-configured scope." }, + { + "description": "Enables the developer_run_get command without any pre-configured scope.", + "type": "string", + "const": "allow-developer-run-get", + "markdownDescription": "Enables the developer_run_get command without any pre-configured scope." + }, + { + "description": "Enables the developer_run_start command without any pre-configured scope.", + "type": "string", + "const": "allow-developer-run-start", + "markdownDescription": "Enables the developer_run_start command without any pre-configured scope." + }, + { + "description": "Enables the developer_run_stop command without any pre-configured scope.", + "type": "string", + "const": "allow-developer-run-stop", + "markdownDescription": "Enables the developer_run_stop command without any pre-configured scope." + }, { "description": "Enables the install_stable_update command without any pre-configured scope.", "type": "string", @@ -554,6 +572,24 @@ "const": "deny-desktop-launch-ready", "markdownDescription": "Denies the desktop_launch_ready command without any pre-configured scope." }, + { + "description": "Denies the developer_run_get command without any pre-configured scope.", + "type": "string", + "const": "deny-developer-run-get", + "markdownDescription": "Denies the developer_run_get command without any pre-configured scope." + }, + { + "description": "Denies the developer_run_start command without any pre-configured scope.", + "type": "string", + "const": "deny-developer-run-start", + "markdownDescription": "Denies the developer_run_start command without any pre-configured scope." + }, + { + "description": "Denies the developer_run_stop command without any pre-configured scope.", + "type": "string", + "const": "deny-developer-run-stop", + "markdownDescription": "Denies the developer_run_stop command without any pre-configured scope." + }, { "description": "Denies the install_stable_update command without any pre-configured scope.", "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 fab3a590e..3c3752dac 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/windows-schema.json @@ -428,6 +428,24 @@ "const": "allow-desktop-launch-ready", "markdownDescription": "Enables the desktop_launch_ready command without any pre-configured scope." }, + { + "description": "Enables the developer_run_get command without any pre-configured scope.", + "type": "string", + "const": "allow-developer-run-get", + "markdownDescription": "Enables the developer_run_get command without any pre-configured scope." + }, + { + "description": "Enables the developer_run_start command without any pre-configured scope.", + "type": "string", + "const": "allow-developer-run-start", + "markdownDescription": "Enables the developer_run_start command without any pre-configured scope." + }, + { + "description": "Enables the developer_run_stop command without any pre-configured scope.", + "type": "string", + "const": "allow-developer-run-stop", + "markdownDescription": "Enables the developer_run_stop command without any pre-configured scope." + }, { "description": "Enables the install_stable_update command without any pre-configured scope.", "type": "string", @@ -554,6 +572,24 @@ "const": "deny-desktop-launch-ready", "markdownDescription": "Denies the desktop_launch_ready command without any pre-configured scope." }, + { + "description": "Denies the developer_run_get command without any pre-configured scope.", + "type": "string", + "const": "deny-developer-run-get", + "markdownDescription": "Denies the developer_run_get command without any pre-configured scope." + }, + { + "description": "Denies the developer_run_start command without any pre-configured scope.", + "type": "string", + "const": "deny-developer-run-start", + "markdownDescription": "Denies the developer_run_start command without any pre-configured scope." + }, + { + "description": "Denies the developer_run_stop command without any pre-configured scope.", + "type": "string", + "const": "deny-developer-run-stop", + "markdownDescription": "Denies the developer_run_stop command without any pre-configured scope." + }, { "description": "Denies the install_stable_update command without any pre-configured scope.", "type": "string", diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_get.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_get.toml new file mode 100644 index 000000000..fbfadbdf5 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_get.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-developer-run-get" +description = "Enables the developer_run_get command without any pre-configured scope." +commands.allow = ["developer_run_get"] + +[[permission]] +identifier = "deny-developer-run-get" +description = "Denies the developer_run_get command without any pre-configured scope." +commands.deny = ["developer_run_get"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_start.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_start.toml new file mode 100644 index 000000000..7f9155dac --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_start.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-developer-run-start" +description = "Enables the developer_run_start command without any pre-configured scope." +commands.allow = ["developer_run_start"] + +[[permission]] +identifier = "deny-developer-run-start" +description = "Denies the developer_run_start command without any pre-configured scope." +commands.deny = ["developer_run_start"] diff --git a/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_stop.toml b/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_stop.toml new file mode 100644 index 000000000..dea6c0144 --- /dev/null +++ b/packages/tauri-app/src-tauri/permissions/autogenerated/developer_run_stop.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-developer-run-stop" +description = "Enables the developer_run_stop command without any pre-configured scope." +commands.allow = ["developer_run_stop"] + +[[permission]] +identifier = "deny-developer-run-stop" +description = "Denies the developer_run_stop command without any pre-configured scope." +commands.deny = ["developer_run_stop"] diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 38825cbb7..e8873ba57 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -799,6 +799,7 @@ fn cli_exit_error(status: &CliStatus, exit: &std::process::ExitStatus) -> String pub struct CliProcessManager { status: Arc>, child: Arc>>, + stdin: Arc>>, #[cfg(windows)] job: Arc>>, bootstrap_token: Arc>>, @@ -814,6 +815,7 @@ impl CliProcessManager { Self { status: Arc::new(Mutex::new(CliStatus::default())), child: Arc::new(Mutex::new(None)), + stdin: Arc::new(Mutex::new(None)), #[cfg(windows)] job: Arc::new(Mutex::new(None)), bootstrap_token: Arc::new(Mutex::new(None)), @@ -945,6 +947,7 @@ impl CliProcessManager { fn stop_tracked_child(&self, deadline: Option) -> anyhow::Result<()> { let Some(mut child) = self.child.lock().take() else { + self.stdin.lock().take(); #[cfg(windows)] if let Some(job) = self.job.lock().take() { let result = job.terminate().and_then(|()| { @@ -964,6 +967,9 @@ impl CliProcessManager { } return Ok(()); }; + if let Some(mut stdin) = self.stdin.try_lock() { + child.stdin = stdin.take(); + } #[cfg(windows)] let job = self.job.lock().take(); log_line(&format!("stopping CLI pid={}", child.id())); @@ -1006,9 +1012,11 @@ impl CliProcessManager { let pid = child.id(); let stdout = child.stdout.take().map(BufReader::new); let stderr = child.stderr.take().map(BufReader::new); + let stdin = child.stdin.take(); debug_assert!(self.child.lock().is_none()); self.status.lock().pid = Some(pid); *self.child.lock() = Some(child); + *self.stdin.lock() = stdin; #[cfg(windows)] { *self.job.lock() = Some(job); @@ -1024,6 +1032,7 @@ impl CliProcessManager { status.url = None; status.error = None; *self.local_access.lock() = None; + self.stdin.lock().take(); } fn publish_error(&self, app: &AppHandle, generation: u64, message: String) { @@ -1039,6 +1048,25 @@ impl CliProcessManager { }); } + fn write_native_response(&self, generation: u64, response: &str) { + if !self.is_current_generation(generation) { + return; + } + let mut stdin = self.stdin.lock(); + if !self.is_current_generation(generation) { + return; + } + let Some(stdin) = stdin.as_mut() else { + return; + }; + if let Err(error) = stdin + .write_all(response.as_bytes()) + .and_then(|()| stdin.flush()) + { + log_line(&format!("failed to answer native CLI request: {error}")); + } + } + pub fn status(&self) -> CliStatus { self.status.lock().clone() } @@ -1113,6 +1141,8 @@ impl CliProcessManager { .env_remove("NPM_CONFIG_PREFIX") .stdout(Stdio::piped()) .stderr(Stdio::piped()); + #[cfg(windows)] + c.env("CODENOMAD_NATIVE_PARENT", "1"); configure_spawn(&mut c); if let Some(ref cwd) = cwd { c.current_dir(cwd); @@ -1142,6 +1172,8 @@ impl CliProcessManager { c.env("ELECTRON_RUN_AS_NODE", "1") .stdout(Stdio::piped()) .stderr(Stdio::piped()); + #[cfg(windows)] + c.env("CODENOMAD_NATIVE_PARENT", "1"); configure_spawn(&mut c); if let Some(ref cwd) = cwd { c.current_dir(cwd); @@ -1364,15 +1396,43 @@ impl CliProcessManager { let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; loop { - buffer.clear(); - match reader.read_line(&mut buffer) { + match Self::read_bounded_line(&mut reader, &mut buffer) { Ok(0) => break, + Ok(size) if size > crate::native_request::MAX_LINE_BYTES => { + log_line(&format!("[cli][{stream}] discarded oversized output line")); + continue; + } Ok(_) => { if !manager.is_current_generation(generation) { break; } let line = buffer.trim_end(); if !line.is_empty() { + if stream == "stdout" + && line.starts_with(crate::native_request::REQUEST_PREFIX) + { + if let Some(request) = crate::native_request::parse(line) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(u64::MAX); + let result = if now >= request.deadline { + Err("Native request expired before execution".to_string()) + } else { + crate::handle_native_request( + app, + &request.method, + request.params, + request.deadline, + ) + }; + manager.write_native_response( + generation, + &crate::native_request::response(&request.id, result), + ); + } + continue; + } if line.starts_with(token_prefix) { let token = line.trim_start_matches(token_prefix).trim(); if !token.is_empty() { @@ -1427,6 +1487,46 @@ impl CliProcessManager { } } + fn read_bounded_line( + reader: &mut R, + buffer: &mut String, + ) -> std::io::Result { + buffer.clear(); + let mut total = 0usize; + let mut oversized = false; + let mut bytes = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(total); + } + let length = available + .iter() + .position(|byte| *byte == b'\n') + .map(|index| index + 1) + .unwrap_or(available.len()); + let ended = available.get(length - 1) == Some(&b'\n'); + total = total.saturating_add(length); + if !oversized && bytes.len() + length <= crate::native_request::MAX_LINE_BYTES { + bytes.extend_from_slice(&available[..length]); + } else { + oversized = true; + bytes.clear(); + } + reader.consume(length); + if ended { + if !oversized { + *buffer = String::from_utf8_lossy(&bytes).into_owned(); + } + return Ok(if oversized { + crate::native_request::MAX_LINE_BYTES + 1 + } else { + total + }); + } + } + } + fn mark_ready( manager: &CliProcessManager, generation: u64, @@ -1861,6 +1961,38 @@ mod tests { static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + #[test] + fn bounded_line_reader_discards_oversized_lines_without_losing_the_next_line() { + let mut input = vec![b'x'; crate::native_request::MAX_LINE_BYTES + 5]; + input.extend_from_slice(b"\nnext\n"); + let mut reader = std::io::Cursor::new(input); + let mut line = String::new(); + + assert_eq!( + CliProcessManager::read_bounded_line(&mut reader, &mut line).unwrap(), + crate::native_request::MAX_LINE_BYTES + 1 + ); + assert!(line.is_empty()); + assert_eq!( + CliProcessManager::read_bounded_line(&mut reader, &mut line).unwrap(), + 5 + ); + assert_eq!(line, "next\n"); + } + + #[test] + fn bounded_line_reader_preserves_utf8_split_across_buffers() { + let input = std::io::Cursor::new("éclair\n".as_bytes()); + let mut reader = std::io::BufReader::with_capacity(1, input); + let mut line = String::new(); + + assert_eq!( + CliProcessManager::read_bounded_line(&mut reader, &mut line).unwrap(), + 8 + ); + assert_eq!(line, "éclair\n"); + } + #[test] fn prod_entry_candidates_prefer_exe_relative_before_workspace_fallback() { let exe_dir = PathBuf::from("/opt/codenomad/bin"); diff --git a/packages/tauri-app/src-tauri/src/developer_run.rs b/packages/tauri-app/src-tauri/src/developer_run.rs new file mode 100644 index 000000000..d5a61f141 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/developer_run.rs @@ -0,0 +1,975 @@ +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +#[cfg(windows)] +use std::ffi::c_void; +use std::io::{BufRead, BufReader}; +#[cfg(windows)] +use std::mem::{size_of, zeroed}; +use std::net::TcpListener; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; + +const LOG_LIMIT: usize = 1_000; +const LOG_MESSAGE_LIMIT: usize = 512; +const READY_TIMEOUT: Duration = Duration::from_secs(30); +const POLL_INTERVAL: Duration = Duration::from_millis(100); +const STOP_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DeveloperRunTarget { + Electron, + Tauri, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DeveloperRunState { + Starting, + Ready, + Error, + #[default] + Stopped, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeveloperRunStatus { + pub state: DeveloperRunState, + pub run_id: Option, + pub target: Option, + pub executable_path: Option, + pub profile_path: Option, + pub pid: Option, + pub port: Option, + pub page_url: Option, + pub debugger_url: Option, + pub target_id: Option, + pub target_title: Option, + pub error: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeveloperRunLog { + pub run_id: String, + pub timestamp: u64, + pub stream: String, + pub message: String, +} + +#[derive(Clone, Debug)] +pub struct DeveloperRunManager { + shared: Arc, +} + +#[derive(Debug)] +struct Shared { + lifecycle: Mutex<()>, + state: Mutex, + generation: AtomicU64, +} + +#[derive(Debug, Default)] +struct ManagerState { + status: DeveloperRunStatus, + active: Option, + logs: VecDeque, + generation: u64, +} + +#[derive(Debug)] +struct ActiveRun { + generation: u64, + child: Child, + profile: PathBuf, + #[cfg(windows)] + job: WindowsJobObject, +} + +#[derive(Debug, Eq, PartialEq)] +struct LaunchSpec { + args: Vec, + environment: Vec<(&'static str, String)>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPageTarget { + #[serde(default)] + id: String, + #[serde(default)] + title: String, + #[serde(default)] + url: String, + #[serde(rename = "type")] + kind: String, + web_socket_debugger_url: Option, +} + +impl DeveloperRunManager { + pub fn new() -> Self { + Self { + shared: Arc::new(Shared { + lifecycle: Mutex::new(()), + state: Mutex::new(ManagerState::default()), + generation: AtomicU64::new(0), + }), + } + } + + pub fn status(&self) -> DeveloperRunStatus { + self.shared.state.lock().status.clone() + } + + pub fn logs(&self) -> Vec { + self.shared.state.lock().logs.iter().cloned().collect() + } + + pub fn start( + &self, + target: DeveloperRunTarget, + executable_path: impl AsRef, + ) -> anyhow::Result { + if target == DeveloperRunTarget::Tauri && !cfg!(windows) { + return Err(anyhow::anyhow!( + "Tauri developer runs currently require Windows WebView2" + )); + } + + let executable_path = executable_path + .as_ref() + .canonicalize() + .map_err(|error| anyhow::anyhow!("invalid developer executable path: {error}"))?; + if !executable_path.is_file() { + return Err(anyhow::anyhow!( + "developer executable is not a file: {}", + executable_path.display() + )); + } + + let port = allocate_loopback_port()?; + let run_id = uuid::Uuid::new_v4().to_string(); + let profile_path = std::env::temp_dir() + .join("codenomad-developer-runs") + .join(&run_id); + std::fs::create_dir_all(&profile_path)?; + #[cfg(unix)] + std::fs::set_permissions( + &profile_path, + std::os::unix::fs::PermissionsExt::from_mode(0o700), + )?; + let launch = launch_spec( + target, + port, + &profile_path, + &run_id, + std::env::var("NODE_OPTIONS").ok().as_deref(), + std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") + .ok() + .as_deref(), + ); + let _lifecycle = self.shared.lifecycle.lock(); + let generation = self.shared.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.shared.state.lock().generation = generation; + if let Err(error) = self.stop_active() { + let _ = std::fs::remove_dir_all(&profile_path); + self.publish_error( + generation, + format!("failed to replace developer run: {error}"), + ); + return Err(error); + } + + { + let mut state = self.shared.state.lock(); + state.logs.clear(); + state.generation = generation; + state.status = DeveloperRunStatus { + state: DeveloperRunState::Starting, + run_id: Some(run_id), + target: Some(target), + executable_path: Some(executable_path.clone()), + profile_path: Some(profile_path.clone()), + pid: None, + port: Some(port), + page_url: None, + debugger_url: None, + target_id: None, + target_title: None, + error: None, + }; + } + + let mut command = Command::new(&executable_path); + command + .args(&launch.args) + .envs(launch.environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(directory) = executable_path.parent() { + command.current_dir(directory); + } + configure_process_group(&mut command); + + #[cfg(windows)] + let job = match WindowsJobObject::create() { + Ok(job) => job, + Err(error) => { + let _ = std::fs::remove_dir_all(&profile_path); + self.publish_error(generation, error.to_string()); + return Err(error); + } + }; + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let _ = std::fs::remove_dir_all(&profile_path); + self.publish_error( + generation, + format!("failed to launch developer run: {error}"), + ); + return Err(error.into()); + } + }; + #[cfg(windows)] + if let Err(error) = job.assign_child(&child) { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&profile_path); + self.publish_error(generation, error.to_string()); + return Err(error); + } + + let pid = child.id(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + { + let mut state = self.shared.state.lock(); + if state.generation != generation { + terminate_child( + &mut child, + #[cfg(windows)] + &job, + )?; + return Err(anyhow::anyhow!("developer run start was superseded")); + } + state.status.pid = Some(pid); + state.active = Some(ActiveRun { + generation, + child, + profile: profile_path, + #[cfg(windows)] + job, + }); + } + + if let Some(stdout) = stdout { + spawn_log_reader(Arc::clone(&self.shared), generation, "stdout", stdout); + } + if let Some(stderr) = stderr { + spawn_log_reader(Arc::clone(&self.shared), generation, "stderr", stderr); + } + spawn_readiness_poll(Arc::clone(&self.shared), generation, port); + spawn_exit_monitor(Arc::clone(&self.shared), generation); + Ok(self.status()) + } + + pub fn stop(&self) -> anyhow::Result { + let _lifecycle = self.shared.lifecycle.lock(); + let generation = self.shared.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.shared.state.lock().generation = generation; + if let Err(error) = self.stop_active() { + self.publish_error(generation, format!("failed to stop developer run: {error}")); + return Err(error); + } + let mut state = self.shared.state.lock(); + state.generation = generation; + state.status = DeveloperRunStatus::default(); + Ok(state.status.clone()) + } + + pub fn restart(&self) -> anyhow::Result { + let status = self.status(); + self.start( + status + .target + .ok_or_else(|| anyhow::anyhow!("Developer Automation is not running"))?, + status + .executable_path + .ok_or_else(|| anyhow::anyhow!("Developer Automation has no executable"))?, + ) + } + + fn stop_active(&self) -> anyhow::Result<()> { + let active = self.shared.state.lock().active.take(); + let Some(mut active) = active else { + return Ok(()); + }; + let result = terminate_child( + &mut active.child, + #[cfg(windows)] + &active.job, + ) + .and_then(|_| std::fs::remove_dir_all(&active.profile).map_err(Into::into)); + if result.is_err() { + self.shared.state.lock().active = Some(active); + } + result + } + + fn publish_error(&self, generation: u64, message: String) { + publish_error(&self.shared, generation, message); + } +} + +impl Default for DeveloperRunManager { + fn default() -> Self { + Self::new() + } +} + +fn allocate_loopback_port() -> anyhow::Result { + Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +fn launch_spec( + target: DeveloperRunTarget, + port: u16, + profile: &Path, + run_id: &str, + node_options: Option<&str>, + webview_options: Option<&str>, +) -> LaunchSpec { + let config = profile.join("config.yaml").to_string_lossy().into_owned(); + let node_options = node_options + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("{value} --enable-source-maps")) + .unwrap_or_else(|| "--enable-source-maps".to_string()); + let mut environment = vec![ + ( + "CODENOMAD_UPDATE_CHANNEL", + format!("developer-automation-{run_id}"), + ), + ("CLI_CONFIG", config), + ("NODE_OPTIONS", node_options), + ]; + match target { + DeveloperRunTarget::Electron => LaunchSpec { + args: vec![ + "--remote-debugging-address=127.0.0.1".to_string(), + format!("--remote-debugging-port={port}"), + format!("--user-data-dir={}", profile.display()), + "--enable-logging".to_string(), + ], + environment, + }, + DeveloperRunTarget::Tauri => { + let webview_options = webview_options + .into_iter() + .flat_map(str::split_whitespace) + .filter(|value| { + !value.starts_with("--remote-debugging-address=") + && !value.starts_with("--remote-debugging-port=") + }) + .collect::>() + .join(" "); + environment.extend([ + ( + "WEBVIEW2_USER_DATA_FOLDER", + profile.join("webview2").to_string_lossy().into_owned(), + ), + ( + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + format!( + "{}--remote-debugging-address=127.0.0.1 --remote-debugging-port={port}", + (!webview_options.is_empty()) + .then(|| format!("{webview_options} ")) + .unwrap_or_default() + ), + ), + ("RUST_BACKTRACE", "1".to_string()), + ]); + LaunchSpec { + args: Vec::new(), + environment, + } + } + } +} + +fn push_log(state: &mut ManagerState, stream: impl Into, message: impl Into) { + let Some(run_id) = state.status.run_id.clone() else { + return; + }; + let message = message.into(); + let message = if message.chars().count() > LOG_MESSAGE_LIMIT { + format!( + "{}...", + message + .chars() + .take(LOG_MESSAGE_LIMIT - 3) + .collect::() + ) + } else { + message + }; + state.logs.push_back(DeveloperRunLog { + run_id, + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + stream: stream.into(), + message, + }); + if state.logs.len() > LOG_LIMIT { + state.logs.pop_front(); + } +} + +fn spawn_log_reader( + shared: Arc, + generation: u64, + stream: &'static str, + reader: impl std::io::Read + Send + 'static, +) { + thread::spawn(move || { + let mut reader = BufReader::new(reader); + loop { + let message = match read_bounded_line(&mut reader) { + Ok(Some(line)) => line, + Ok(None) => return, + Err(error) => format!("failed to read {stream}: {error}"), + }; + let mut state = shared.state.lock(); + if state.generation != generation { + return; + } + push_log(&mut state, stream, message); + } + }); +} + +fn read_bounded_line(reader: &mut impl BufRead) -> std::io::Result> { + let mut bytes = Vec::with_capacity(LOG_MESSAGE_LIMIT); + let mut truncated = false; + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + let mut line = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + line = format!( + "{}...", + line.chars().take(LOG_MESSAGE_LIMIT - 3).collect::() + ); + } + return Ok(Some(line)); + } + let end = available.iter().position(|byte| *byte == b'\n'); + let part = &available[..end.unwrap_or(available.len())]; + let remaining = LOG_MESSAGE_LIMIT.saturating_sub(bytes.len()); + bytes.extend_from_slice(&part[..part.len().min(remaining)]); + truncated |= part.len() > remaining; + let consumed = part.len() + usize::from(end.is_some()); + reader.consume(consumed); + if end.is_some() { + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + let mut line = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + line = format!( + "{}...", + line.chars().take(LOG_MESSAGE_LIMIT - 3).collect::() + ); + } + return Ok(Some(line)); + } + } +} + +fn spawn_readiness_poll(shared: Arc, generation: u64, port: u16) { + thread::spawn(move || { + let deadline = Instant::now() + READY_TIMEOUT; + let base_url = format!("http://127.0.0.1:{port}"); + let client = match reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(500)) + .build() + { + Ok(client) => client, + Err(error) => { + publish_error(&shared, generation, error.to_string()); + return; + } + }; + let mut last_error = "CDP endpoint did not respond".to_string(); + + while Instant::now() < deadline { + if shared.state.lock().generation != generation { + return; + } + match discover_page(&client, &base_url) { + Ok(page_target) => { + let mut state = shared.state.lock(); + if state.generation != generation + || state.status.state != DeveloperRunState::Starting + { + return; + } + state.status.state = DeveloperRunState::Ready; + state.status.page_url = Some(page_target.url); + state.status.debugger_url = page_target.web_socket_debugger_url; + state.status.target_id = Some(page_target.id); + state.status.target_title = Some(page_target.title); + return; + } + Err(error) => last_error = error.to_string(), + } + thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now()))); + } + publish_error( + &shared, + generation, + format!("timed out waiting for a CDP page target: {last_error}"), + ); + terminate_generation(&shared, generation); + }); +} + +fn discover_page( + client: &reqwest::blocking::Client, + base_url: &str, +) -> anyhow::Result { + client + .get(format!("{base_url}/json/version")) + .send()? + .error_for_status()? + .json::()?; + let targets = client + .get(format!("{base_url}/json/list")) + .send()? + .error_for_status()? + .json::>()?; + let page = targets + .into_iter() + .find(|target| { + target.kind == "page" + && target.url != "about:blank" + && !target + .url + .split(['?', '#']) + .next() + .is_some_and(|url| url.ends_with("/loading.html")) + && target + .web_socket_debugger_url + .as_deref() + .is_some_and(|url| !url.is_empty()) + }) + .ok_or_else(|| anyhow::anyhow!("CDP has no ready page target"))?; + Ok(page) +} + +fn spawn_exit_monitor(shared: Arc, generation: u64) { + thread::spawn(move || loop { + thread::sleep(Duration::from_millis(100)); + let mut state = shared.state.lock(); + if state.generation != generation { + return; + } + let Some(active) = state + .active + .as_mut() + .filter(|active| active.generation == generation) + else { + return; + }; + match active.child.try_wait() { + Ok(Some(exit)) => { + state.status.pid = None; + if state.status.state != DeveloperRunState::Error { + state.status.state = DeveloperRunState::Error; + state.status.error = Some(format!("developer run exited: {exit}")); + } + drop(state); + terminate_generation(&shared, generation); + return; + } + Ok(None) => {} + Err(error) => { + drop(state); + publish_error( + &shared, + generation, + format!("failed to inspect developer run: {error}"), + ); + return; + } + } + }); +} + +fn publish_error(shared: &Shared, generation: u64, message: String) { + let mut state = shared.state.lock(); + if state.generation != generation { + return; + } + state.status.state = DeveloperRunState::Error; + state.status.error = Some(message.clone()); +} + +fn terminate_generation(shared: &Shared, generation: u64) { + let _lifecycle = shared.lifecycle.lock(); + let active = { + let mut state = shared.state.lock(); + if state.generation != generation { + return; + } + state + .active + .take() + .filter(|active| active.generation == generation) + }; + if let Some(mut active) = active { + let result = terminate_child( + &mut active.child, + #[cfg(windows)] + &active.job, + ) + .and_then(|_| std::fs::remove_dir_all(&active.profile).map_err(Into::into)); + if let Err(error) = result { + let mut state = shared.state.lock(); + if state.generation == generation && state.active.is_none() { + state.active = Some(active); + state.status.state = DeveloperRunState::Error; + state.status.error = Some(format!("failed to stop developer run: {error}")); + } + } else { + let mut state = shared.state.lock(); + if state.generation == generation { + state.status.pid = None; + } + } + } +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + unsafe { + command.pre_exec(|| { + libc::umask(0o077); + if libc::setpgid(0, 0) == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); + } +} + +#[cfg(windows)] +fn configure_process_group(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(any(unix, windows)))] +fn configure_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn terminate_child(child: &mut Child) -> anyhow::Result<()> { + let pid = child.id() as i32; + unsafe { + if libc::kill(-pid, libc::SIGTERM) != 0 { + let _ = libc::kill(pid, libc::SIGTERM); + } + } + let deadline = Instant::now() + STOP_TIMEOUT; + while Instant::now() < deadline { + if child.try_wait()?.is_some() && process_group_is_gone(pid)? { + return Ok(()); + } + thread::sleep(Duration::from_millis(25)); + } + unsafe { + if libc::kill(-pid, libc::SIGKILL) != 0 { + let _ = libc::kill(pid, libc::SIGKILL); + } + } + wait_for_exit(child, || process_group_is_gone(pid)) +} + +#[cfg(unix)] +fn process_group_is_gone(pid: i32) -> anyhow::Result { + if unsafe { libc::kill(-pid, 0) } == 0 { + return Ok(false); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Ok(true), + Some(libc::EPERM) => Ok(false), + _ => Err(std::io::Error::last_os_error().into()), + } +} + +#[cfg(windows)] +fn terminate_child(child: &mut Child, job: &WindowsJobObject) -> anyhow::Result<()> { + job.terminate()?; + wait_for_exit(child, || Ok(job.active_processes()? == 0)) +} + +#[cfg(not(any(unix, windows)))] +fn terminate_child(child: &mut Child) -> anyhow::Result<()> { + child.kill()?; + wait_for_exit(child, || Ok(true)) +} + +fn wait_for_exit( + child: &mut Child, + mut descendants_gone: impl FnMut() -> anyhow::Result, +) -> anyhow::Result<()> { + let deadline = Instant::now() + STOP_TIMEOUT; + while Instant::now() < deadline { + if child.try_wait()?.is_some() && descendants_gone()? { + return Ok(()); + } + thread::sleep(Duration::from_millis(25)); + } + Err(anyhow::anyhow!( + "developer process-tree termination was not confirmed" + )) +} + +#[cfg(windows)] +#[derive(Debug)] +struct WindowsJobObject { + handle: HANDLE, +} + +#[cfg(windows)] +impl WindowsJobObject { + fn create() -> anyhow::Result { + let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error().into()); + } + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let ok = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + ) + }; + if ok == 0 { + let error = std::io::Error::last_os_error(); + unsafe { CloseHandle(handle) }; + return Err(error.into()); + } + Ok(Self { handle }) + } + + fn assign_child(&self, child: &Child) -> anyhow::Result<()> { + if unsafe { AssignProcessToJobObject(self.handle, child.as_raw_handle() as HANDLE) } == 0 { + return Err(anyhow::anyhow!( + "failed to contain developer process tree: {}", + std::io::Error::last_os_error() + )); + } + Ok(()) + } + + fn active_processes(&self) -> anyhow::Result { + let mut info: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = unsafe { zeroed() }; + if unsafe { + QueryInformationJobObject( + self.handle, + JobObjectBasicAccountingInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error().into()); + } + Ok(info.ActiveProcesses) + } + + fn terminate(&self) -> anyhow::Result<()> { + if unsafe { TerminateJobObject(self.handle, 1) } == 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) + } +} + +#[cfg(windows)] +impl Drop for WindowsJobObject { + fn drop(&mut self) { + if !self.handle.is_null() { + unsafe { CloseHandle(self.handle) }; + } + } +} + +#[cfg(windows)] +unsafe impl Send for WindowsJobObject {} + +#[cfg(windows)] +unsafe impl Sync for WindowsJobObject {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_only_supported_targets() { + assert_eq!( + serde_json::from_str::("\"electron\"").unwrap(), + DeveloperRunTarget::Electron + ); + assert_eq!( + serde_json::from_str::("\"tauri\"").unwrap(), + DeveloperRunTarget::Tauri + ); + assert!(serde_json::from_str::("\"browser\"").is_err()); + } + + #[test] + fn launch_specs_isolate_profiles_and_enable_target_cdp() { + let profile = Path::new("developer-profile"); + assert_eq!( + launch_spec( + DeveloperRunTarget::Electron, + 9223, + profile, + "run-1", + None, + None + ), + LaunchSpec { + args: vec![ + "--remote-debugging-address=127.0.0.1".to_string(), + "--remote-debugging-port=9223".to_string(), + format!("--user-data-dir={}", profile.display()), + "--enable-logging".to_string(), + ], + environment: vec![ + ( + "CODENOMAD_UPDATE_CHANNEL", + "developer-automation-run-1".to_string() + ), + ( + "CLI_CONFIG", + profile.join("config.yaml").to_string_lossy().into_owned() + ), + ("NODE_OPTIONS", "--enable-source-maps".to_string()), + ], + } + ); + assert_eq!( + launch_spec( + DeveloperRunTarget::Tauri, + 9223, + profile, + "run-1", + Some("--trace-warnings"), + Some("--remote-debugging-port=8111 --disable-features=msSmartScreenProtection") + ), + LaunchSpec { + args: Vec::new(), + environment: vec![ + ( + "CODENOMAD_UPDATE_CHANNEL", + "developer-automation-run-1".to_string() + ), + ( + "CLI_CONFIG", + profile.join("config.yaml").to_string_lossy().into_owned() + ), + ( + "NODE_OPTIONS", + "--trace-warnings --enable-source-maps".to_string() + ), + ( + "WEBVIEW2_USER_DATA_FOLDER", + profile.join("webview2").to_string_lossy().into_owned() + ), + ( + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "--disable-features=msSmartScreenProtection --remote-debugging-address=127.0.0.1 --remote-debugging-port=9223".to_string() + ), + ("RUST_BACKTRACE", "1".to_string()), + ], + } + ); + } + + #[test] + fn logs_are_bounded_to_the_latest_thousand() { + let mut state = ManagerState::default(); + state.status.run_id = Some("run-1".to_string()); + for index in 0..1_005 { + push_log(&mut state, "stdout", index.to_string()); + } + assert_eq!(state.logs.len(), LOG_LIMIT); + assert_eq!(state.logs.front().unwrap().message, "5"); + assert_eq!(state.logs.back().unwrap().message, "1004"); + } + + #[test] + fn output_lines_are_bounded_before_allocation() { + let input = format!("{}\nnext\n", "x".repeat(100_000)); + let mut reader = BufReader::new(input.as_bytes()); + let first = read_bounded_line(&mut reader).unwrap().unwrap(); + assert_eq!(first.len(), LOG_MESSAGE_LIMIT); + assert!(first.ends_with("...")); + assert_eq!(read_bounded_line(&mut reader).unwrap().unwrap(), "next"); + } + + #[test] + fn stale_generations_cannot_publish_errors() { + let manager = DeveloperRunManager::new(); + manager.shared.state.lock().generation = 2; + manager.publish_error(1, "stale".to_string()); + assert_eq!(manager.status().state, DeveloperRunState::Stopped); + assert!(manager.logs().is_empty()); + } + + #[test] + fn loopback_port_allocator_returns_bindable_port() { + let port = allocate_loopback_port().unwrap(); + assert_ne!(port, 0); + TcpListener::bind(("127.0.0.1", port)).unwrap(); + } +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 8ababa856..f6199b996 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -4,12 +4,14 @@ mod cert_manager; mod cli_manager; mod client_state; +mod developer_run; mod identity; mod launch; #[cfg(target_os = "linux")] mod linux_tls; mod local_windows; mod managed_node; +mod native_request; mod shutdown; mod windows_update; mod workspace_open; @@ -58,6 +60,7 @@ const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = pub struct AppState { pub manager: CliProcessManager, + pub(crate) developer_run_manager: developer_run::DeveloperRunManager, pub wake_lock: Mutex, remote_navigation: Mutex>, remote_navigation_generation: AtomicU64, @@ -201,6 +204,44 @@ pub(crate) fn require_local_app_window( } } +fn developer_run_status(status: &developer_run::DeveloperRunStatus) -> serde_json::Value { + json!({ + "state": status.state, + "runId": status.run_id, + "target": status.target, + "executable": status.executable_path, + "profilePath": status.profile_path, + "pid": status.pid, + "cdpUrl": status.port.map(|port| format!("http://127.0.0.1:{port}")), + "targetUrl": status.page_url, + "targetId": status.target_id, + "targetTitle": status.target_title, + "error": status.error, + }) +} + +fn developer_run_snapshot(manager: &developer_run::DeveloperRunManager) -> serde_json::Value { + json!({ "status": developer_run_status(&manager.status()), "logs": manager.logs() }) +} + +pub(crate) fn handle_native_request( + app: &AppHandle, + method: &str, + _params: Option, + _deadline: u64, +) -> Result { + let state = app.state::(); + match method { + "developer.status" => Ok(developer_run_snapshot(&state.developer_run_manager)), + "developer.restart" => state + .developer_run_manager + .restart() + .map(|status| developer_run_status(&status)) + .map_err(|error| error.to_string()), + _ => Err(format!("Unsupported native developer request: {method}")), + } +} + #[cfg(target_os = "macos")] pub(crate) fn profile_identifier(identity: &str) -> [u8; 16] { let digest = Sha256::digest(identity.as_bytes()); @@ -283,6 +324,54 @@ fn claim_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) -> bool claim_unowned_remote_proxy_session(&profiles, &mut claims, session_id) } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeveloperRunStartInput { + target: developer_run::DeveloperRunTarget, + executable: String, +} + +#[tauri::command] +fn developer_run_get( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Result { + require_local_app_window(&window, &state)?; + Ok(developer_run_snapshot(&state.developer_run_manager)) +} + +#[tauri::command] +async fn developer_run_start( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + input: DeveloperRunStartInput, +) -> Result { + require_local_app_window(&window, &state)?; + if input.executable.len() > 32_768 { + return Err("Developer executable path is too long".into()); + } + let manager = state.developer_run_manager.clone(); + tauri::async_runtime::spawn_blocking(move || manager.start(input.target, input.executable)) + .await + .map_err(|error| error.to_string())? + .map(|status| developer_run_status(&status)) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +async fn developer_run_stop( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + require_local_app_window(&window, &state)?; + let manager = state.developer_run_manager.clone(); + tauri::async_runtime::spawn_blocking(move || manager.stop()) + .await + .map_err(|error| error.to_string())? + .map(|_| ()) + .map_err(|error| error.to_string()) +} + fn release_remote_proxy_session_cleanup(app: &AppHandle, session_id: &str) { if let Ok(mut claims) = app.state::().remote_proxy_cleanup_claims.lock() { claims.remove(session_id); @@ -1292,6 +1381,7 @@ fn main() { .manage(local_windows::LocalWindows::default()) .manage(AppState { manager: CliProcessManager::new(), + developer_run_manager: developer_run::DeveloperRunManager::new(), wake_lock: Mutex::new(WakeLockState::default()), remote_navigation: Mutex::new(HashMap::new()), remote_navigation_generation: AtomicU64::new(0), @@ -1384,7 +1474,10 @@ fn main() { local_windows::desktop_launch_acknowledge_folder, windows_update::install_stable_update, workspace_open::open_workspace_target, - set_workspace_menu_enabled + set_workspace_menu_enabled, + developer_run_get, + developer_run_start, + developer_run_stop ]) .on_menu_event(|app_handle, event| { match event.id().0.as_str() { diff --git a/packages/tauri-app/src-tauri/src/native_request.rs b/packages/tauri-app/src-tauri/src/native_request.rs new file mode 100644 index 000000000..76c465b01 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/native_request.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub(crate) const REQUEST_PREFIX: &str = "CODENOMAD_NATIVE_REQUEST:"; +pub(crate) const RESPONSE_PREFIX: &str = "CODENOMAD_NATIVE_RESPONSE:"; +pub(crate) const MAX_LINE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Deserialize, PartialEq)] +pub(crate) struct NativeRequest { + pub(crate) v: u8, + pub(crate) id: String, + pub(crate) method: String, + pub(crate) params: Option, + pub(crate) deadline: u64, +} + +#[derive(Serialize)] +struct NativeError { + code: &'static str, + message: String, +} + +#[derive(Serialize)] +struct NativeResponse<'a> { + v: u8, + id: &'a str, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +pub(crate) fn parse(line: &str) -> Option { + if !line.starts_with(REQUEST_PREFIX) || line.len() > MAX_LINE_BYTES { + return None; + } + let request = serde_json::from_str::(&line[REQUEST_PREFIX.len()..]).ok()?; + (request.v == 1 + && !request.id.is_empty() + && request.id.len() <= 128 + && !request.method.is_empty() + && request.method.len() <= 128) + .then_some(request) +} + +pub(crate) fn response(id: &str, result: Result) -> String { + let response = match result { + Ok(result) => NativeResponse { + v: 1, + id, + ok: true, + result: Some(result), + error: None, + }, + Err(message) => NativeResponse { + v: 1, + id, + ok: false, + result: None, + error: Some(NativeError { + code: "native_error", + message, + }), + }, + }; + format!( + "{RESPONSE_PREFIX}{}\n", + serde_json::to_string(&response).unwrap() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn validates_requests_and_serializes_responses() { + let request = parse( + "CODENOMAD_NATIVE_REQUEST:{\"v\":1,\"id\":\"r1\",\"method\":\"developer.status\",\"deadline\":9999999999999}", + ) + .unwrap(); + assert_eq!(request.id, "r1"); + assert!(parse("CODENOMAD_NATIVE_REQUEST:{\"v\":2}").is_none()); + assert_eq!( + response("r1", Ok(json!({ "available": true }))), + "CODENOMAD_NATIVE_RESPONSE:{\"v\":1,\"id\":\"r1\",\"ok\":true,\"result\":{\"available\":true}}\n" + ); + } +} diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 76f8ea45e..cdb712ac3 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -97,7 +97,12 @@ impl ShutdownCoordinator { fn cancel_local_close(&self, label: &str, generation: u64) -> bool { let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - if state.local_closes.get(label).map(|pending| pending.generation) != Some(generation) { + if state + .local_closes + .get(label) + .map(|pending| pending.generation) + != Some(generation) + { return false; } state.local_closes.remove(label); @@ -498,10 +503,7 @@ pub(crate) fn request(app: AppHandle) { } std::thread::spawn(move || { std::thread::sleep(RENDERER_FLUSH_TIMEOUT); - match app - .state::() - .expire_pending_shutdown() - { + match app.state::().expire_pending_shutdown() { PendingShutdownTimeoutAction::Cancel(cancellations) => { emit_flush_cancellations(&app, cancellations) } @@ -533,7 +535,16 @@ fn start_cleanup(app: AppHandle, deadline_reached: bool) { 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()) + state + .developer_run_manager + .stop() + .map(|_| ()) + .map_err(|error| error.to_string()) + }) + .and_then(|_| { + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state.manager.stop().map_err(|error| error.to_string()) + }) }) } else { Ok(()) @@ -703,12 +714,19 @@ pub(crate) fn request_windows_session_end(app: AppHandle) { cleanup_app .try_state::() .map(|state| { - retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { - state - .manager - .stop_until(session_deadline) - .map_err(|error| error.to_string()) - }) + state + .developer_run_manager + .stop() + .map(|_| ()) + .map_err(|error| error.to_string()) + .and_then(|_| { + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state + .manager + .stop_until(session_deadline) + .map_err(|error| error.to_string()) + }) + }) }) .unwrap_or(Ok(())) }; diff --git a/packages/ui/src/components/settings/advanced-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx index 8612fa0f8..b98262ea4 100644 --- a/packages/ui/src/components/settings/advanced-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -4,6 +4,8 @@ import { getBehaviorSettings } from "../../lib/settings/behavior-registry" import { useConfig } from "../../stores/preferences" import EnvironmentVariablesEditor from "../environment-variables-editor" import { BehaviorSettingRows } from "./behavior-setting-rows" +import { DeveloperAutomationCard } from "./developer-automation-card" +import { supportsNativeDialogsInCurrentWindow } from "../../lib/native/native-functions" export const AdvancedSettingsSection: Component = () => { const { t } = useI18n() @@ -19,6 +21,8 @@ export const AdvancedSettingsSection: Component = () => { return (
+ {supportsNativeDialogsInCurrentWindow() && } +
diff --git a/packages/ui/src/components/settings/developer-automation-card.tsx b/packages/ui/src/components/settings/developer-automation-card.tsx new file mode 100644 index 000000000..79d6ab35f --- /dev/null +++ b/packages/ui/src/components/settings/developer-automation-card.tsx @@ -0,0 +1,305 @@ +import { For, Show, createSignal, onCleanup, onMount, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { + getDeveloperRun, + onDeveloperRunLog, + onDeveloperRunStatus, + startDeveloperRun, + stopDeveloperRun, + type DeveloperRunLog, + type DeveloperRunStatus, + type DeveloperRunTarget, +} from "../../lib/native/developer-run" +import { openNativeFileDialog } from "../../lib/native/native-functions" + +const MAX_LOG_ENTRIES = 200 + +export const DeveloperAutomationCard: Component = () => { + const { t } = useI18n() + const [target, setTarget] = createSignal("electron") + const [executable, setExecutable] = createSignal("") + const [status, setStatus] = createSignal() + const [logs, setLogs] = createSignal([]) + const [starting, setStarting] = createSignal(false) + const [stopping, setStopping] = createSignal(false) + const [error, setError] = createSignal(null) + let disposed = false + let statusRevision = 0 + let logRevision = 0 + let stopRequested = false + let refreshTimer: number | undefined + const cleanups: Array<() => void> = [] + + const active = () => { + const state = status()?.state + return state === "starting" || state === "ready" || state === "stopping" || (state === "error" && Boolean(status()?.runId)) + } + + function applyStatus(next: DeveloperRunStatus) { + setStatus(next) + if (next.target) setTarget(next.target) + if (next.executable) setExecutable(next.executable) + if (next.error) setError(next.error) + else setError(null) + } + + function reportError(cause: unknown, fallbackKey: string) { + setError(cause instanceof Error && cause.message ? cause.message : t(fallbackKey)) + } + + function register(subscription: Promise<() => void>) { + void subscription + .then((cleanup) => disposed ? cleanup() : cleanups.push(cleanup)) + .catch((cause) => { + if (!disposed) reportError(cause, "settings.developerAutomation.errors.load") + }) + } + + async function refresh() { + const currentStatusRevision = statusRevision + const currentLogRevision = logRevision + try { + const snapshot = await getDeveloperRun() + if (disposed) return + if (currentStatusRevision === statusRevision) applyStatus(snapshot.status) + if (currentLogRevision === logRevision) setLogs(snapshot.logs.slice(-MAX_LOG_ENTRIES)) + } catch (cause) { + if (!disposed) reportError(cause, "settings.developerAutomation.errors.load") + } + } + + onMount(() => { + register(onDeveloperRunStatus((next) => { + if (disposed) return + statusRevision += 1 + applyStatus(next) + })) + register(onDeveloperRunLog((entry) => { + if (disposed) return + logRevision += 1 + setLogs((current) => [...current, entry].slice(-MAX_LOG_ENTRIES)) + })) + + void refresh() + refreshTimer = window.setInterval(() => void refresh(), 750) + }) + + onCleanup(() => { + disposed = true + if (refreshTimer !== undefined) window.clearInterval(refreshTimer) + cleanups.forEach((cleanup) => cleanup()) + }) + + async function chooseExecutable() { + setError(null) + try { + const selected = await openNativeFileDialog({ title: t("settings.developerAutomation.executable.dialogTitle") }) + if (selected) setExecutable(selected) + } catch (cause) { + reportError(cause, "settings.developerAutomation.errors.pick") + } + } + + async function start() { + const path = executable().trim() + if (!path || starting() || stopping() || active()) return + stopRequested = false + setStarting(true) + setError(null) + logRevision += 1 + setLogs([]) + try { + applyStatus(await startDeveloperRun({ target: target(), executable: path })) + } catch (cause) { + if (!stopRequested) reportError(cause, "settings.developerAutomation.errors.start") + } finally { + setStarting(false) + } + } + + async function stop() { + if (stopping() || (!active() && !starting())) return + stopRequested = true + setStopping(true) + setError(null) + try { + await stopDeveloperRun() + const currentLogRevision = logRevision + const snapshot = await getDeveloperRun() + applyStatus(snapshot.status) + if (currentLogRevision === logRevision) setLogs(snapshot.logs.slice(-MAX_LOG_ENTRIES)) + } catch (cause) { + reportError(cause, "settings.developerAutomation.errors.stop") + } finally { + setStopping(false) + } + } + + function stateLabel() { + const state = status()?.state + if (!state) return t("settings.developerAutomation.state.loading") + if (state === "stopped") return t("settings.developerAutomation.state.idle") + if (state === "ready") return t("settings.developerAutomation.state.running") + if (state === "error") return t("settings.developerAutomation.state.failed") + return t(`settings.developerAutomation.state.${state}`) + } + + function formatLogTime(timestamp?: number) { + return new Date(timestamp ?? Date.now()).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + } + + function cdpTarget() { + const current = status() + const url = current?.targetUrl ?? current?.cdpUrl + return current?.targetTitle && url ? `${current.targetTitle} (${url})` : current?.targetTitle ?? url + } + + return ( +
+
+
+

{t("settings.developerAutomation.title")}

+

{t("settings.developerAutomation.subtitle")}

+
+ {t("settings.scope.device")} +
+ +
+
+
+ +
+ {t("settings.developerAutomation.target.subtitle")} +
+
+ +
+ +
+
+ +
+ {t("settings.developerAutomation.executable.subtitle")} +
+
+
+ + +
+
+ + + {(message) => } + + +
+ + +
+ +
+
+

{t("settings.developerAutomation.details.title")}

+
+
+
+
{t("settings.developerAutomation.details.state")}
+
{stateLabel()}
+
+ + {([key, value]) => ( +
+
{t(`settings.developerAutomation.details.${key}`)}
+
+ {value || t("settings.developerAutomation.details.unavailable")} +
+
+ )} +
+
+
+ +
+
+

{t("settings.developerAutomation.logs.title")}

+
+
+ {t("settings.developerAutomation.logs.empty")}
}> + + {(entry) => ( +
+ {formatLogTime(entry.timestamp)} + + {entry.message} + +
+ )} +
+ +
+
+
+
+ ) +} diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 687e900b1..55661287f 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "Schließen", "toastHistory.deleteItem": "Benachrichtigung löschen", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + "settings.section.info.title": "Über", "settings.section.info.subtitle": "Version, Laufzeit und Diagnoseinformationen anzeigen.", "settings.info.version.server": "Server-Version", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 12ada3939..bb53e5eca 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -480,6 +480,42 @@ export const settingsMessages = { "toastHistory.close": "Close", "toastHistory.deleteItem": "Delete notification", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "About", "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index b31573659..340773fd3 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "Cerrar", "toastHistory.deleteItem": "Eliminar notificación", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "Acerca de", "settings.section.info.subtitle": "Consulta la versión, el runtime y recopila información de diagnóstico.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 2f6f4f89d..774a16189 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "Fermer", "toastHistory.deleteItem": "Supprimer la notification", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "À propos", "settings.section.info.subtitle": "Consultez la version et l'environnement d'exécution, et recueillez des informations de diagnostic.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 8f2ce2d91..8b3fd078a 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "סגור", "toastHistory.deleteItem": "מחק התראה", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "אודות", "settings.section.info.subtitle": "צפה בגרסה ובסביבת הריצה, ואסוף מידע אבחון.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 66caf5ea7..5c2bd11a7 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "閉じる", "toastHistory.deleteItem": "通知を削除", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "概要", "settings.section.info.subtitle": "バージョン、ランタイムを確認し、診断情報を収集します。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 3ece81f05..8fff71e90 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "बन्द गर्नुहोस्", "toastHistory.deleteItem": "सूचना मेटाउनुहोस्", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + "settings.section.info.title": "बारेमा", "settings.section.info.subtitle": "संस्करण, रनटाइम र निदान जानकारी हेर्नुहोस्।", "settings.info.version.server": "सर्भर संस्करण", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index c473bb678..8f7578aef 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "Закрыть", "toastHistory.deleteItem": "Удалить уведомление", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "О приложении", "settings.section.info.subtitle": "Просмотрите версии, среду выполнения и соберите диагностические данные.", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index e2c3aca17..d16f52114 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -475,4 +475,40 @@ export const settingsMessages = { "settings.info.diagnostics.copy": "Panoya kopyala", "settings.info.diagnostics.download": ".txt indir", "settings.info.diagnostics.copied": "Tanılama bilgisi panoya kopyalandı.", + + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 5f325de70..55392374b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -479,6 +479,42 @@ export const settingsMessages = { "toastHistory.close": "关闭", "toastHistory.deleteItem": "删除通知", + "settings.developerAutomation.title": "Developer automation", + "settings.developerAutomation.subtitle": "Launch an isolated desktop build for interactive inspection.", + "settings.developerAutomation.target.title": "Target", + "settings.developerAutomation.target.subtitle": "Choose the desktop host to run.", + "settings.developerAutomation.target.electron": "Electron", + "settings.developerAutomation.target.tauri": "Tauri", + "settings.developerAutomation.executable.title": "Executable", + "settings.developerAutomation.executable.subtitle": "Select the packaged desktop executable to launch.", + "settings.developerAutomation.executable.placeholder": "Select an executable", + "settings.developerAutomation.executable.browse": "Browse", + "settings.developerAutomation.executable.dialogTitle": "Select developer executable", + "settings.developerAutomation.actions.start": "Start run", + "settings.developerAutomation.actions.starting": "Starting...", + "settings.developerAutomation.actions.stop": "Stop run", + "settings.developerAutomation.actions.stopping": "Stopping...", + "settings.developerAutomation.details.title": "Run details", + "settings.developerAutomation.details.state": "State", + "settings.developerAutomation.details.build": "Build", + "settings.developerAutomation.details.profile": "Profile", + "settings.developerAutomation.details.cdpTarget": "CDP target", + "settings.developerAutomation.details.unavailable": "Not available", + "settings.developerAutomation.state.loading": "Loading", + "settings.developerAutomation.state.idle": "Idle", + "settings.developerAutomation.state.building": "Building", + "settings.developerAutomation.state.starting": "Starting", + "settings.developerAutomation.state.running": "Running", + "settings.developerAutomation.state.stopping": "Stopping", + "settings.developerAutomation.state.failed": "Failed", + "settings.developerAutomation.logs.title": "Run logs", + "settings.developerAutomation.logs.empty": "No run output yet.", + "settings.developerAutomation.logs.ariaLabel": "Developer run output", + "settings.developerAutomation.errors.load": "Could not load the developer run.", + "settings.developerAutomation.errors.start": "Could not start the developer run.", + "settings.developerAutomation.errors.stop": "Could not stop the developer run.", + "settings.developerAutomation.errors.pick": "Could not open the executable picker.", + // Info Section "settings.section.info.title": "关于", "settings.section.info.subtitle": "查看版本、运行时,并收集诊断信息。", diff --git a/packages/ui/src/lib/native/developer-run.ts b/packages/ui/src/lib/native/developer-run.ts new file mode 100644 index 000000000..73f71bff2 --- /dev/null +++ b/packages/ui/src/lib/native/developer-run.ts @@ -0,0 +1,63 @@ +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" + +export type DeveloperRunTarget = "electron" | "tauri" +export type DeveloperRunState = "stopped" | "starting" | "ready" | "stopping" | "error" + +export interface DeveloperRunStatus { + state: DeveloperRunState + runId?: string + target?: DeveloperRunTarget + executable?: string + pid?: number + profilePath?: string + cdpUrl?: string + targetId?: string + targetTitle?: string + targetUrl?: string + error?: string +} + +export interface DeveloperRunLog { + runId: string + timestamp: number + stream: "system" | "stdout" | "stderr" + message: string +} + +interface DeveloperRunElectronAPI { + getDeveloperRun?: () => Promise<{ status: DeveloperRunStatus; logs: DeveloperRunLog[] }> + startDeveloperRun?: (input: { target: DeveloperRunTarget; executable: string }) => Promise + stopDeveloperRun?: () => Promise + onDeveloperRunStatus?: (callback: (status: DeveloperRunStatus) => void) => () => void + onDeveloperRunLog?: (callback: (log: DeveloperRunLog) => void) => () => void +} + +function electronAPI(): DeveloperRunElectronAPI | undefined { + return (window as Window & { electronAPI?: DeveloperRunElectronAPI }).electronAPI +} + +export async function getDeveloperRun(): Promise<{ status: DeveloperRunStatus; logs: DeveloperRunLog[] }> { + const api = electronAPI() + return api?.getDeveloperRun ? api.getDeveloperRun() : invoke("developer_run_get") +} + +export async function startDeveloperRun(input: { target: DeveloperRunTarget; executable: string }): Promise { + const api = electronAPI() + return api?.startDeveloperRun ? api.startDeveloperRun(input) : invoke("developer_run_start", { input }) +} + +export async function stopDeveloperRun(): Promise { + const api = electronAPI() + return api?.stopDeveloperRun ? api.stopDeveloperRun() : invoke("developer_run_stop") +} + +export async function onDeveloperRunStatus(callback: (status: DeveloperRunStatus) => void): Promise<() => void> { + const unsubscribe = electronAPI()?.onDeveloperRunStatus?.(callback) + return unsubscribe ?? listen("developer-run:status", (event) => callback(event.payload)) +} + +export async function onDeveloperRunLog(callback: (log: DeveloperRunLog) => void): Promise<() => void> { + const unsubscribe = electronAPI()?.onDeveloperRunLog?.(callback) + return unsubscribe ?? listen("developer-run:log", (event) => callback(event.payload)) +} From 3234871d8895a8068c7feb89c787b9a316797af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 29 Aug 2026 09:54:59 +0200 Subject: [PATCH 129/131] fix(automation): harden bridge discovery and CDP state Start an internal loopback HTTP listener for native Developer Automation while preserving the configured user-facing HTTPS URL. Register the inert tool definitions independently of workspace restoration timing and keep every operation fenced by current session location ownership. Reject non-loopback CDP WebSocket targets, invalidate accessibility refs and pending commands on socket errors, and serialize UI polling while fencing delayed start and stop responses. Add focused coverage for listener selection, socket failures, redirect rejection, and session-gated registration. --- dev-docs/DEVELOPER_AUTOMATION.md | 4 ++-- packages/server/src/developer-cdp.test.ts | 21 +++++++++++++++- packages/server/src/developer-cdp.ts | 19 +++++++++++---- packages/server/src/index.ts | 15 +++++++----- .../src/opencode/automation-plugin.test.ts | 24 ++----------------- .../server/src/opencode/automation-plugin.ts | 18 +------------- .../__tests__/listener-base-url.test.ts | 13 +++++++++- .../server/src/server/listener-base-url.ts | 7 ++++++ .../server/routes/automation-plugin.test.ts | 1 - .../src/server/routes/automation-plugin.ts | 17 +------------ .../settings/developer-automation-card.tsx | 17 ++++++++++--- 11 files changed, 83 insertions(+), 73 deletions(-) diff --git a/dev-docs/DEVELOPER_AUTOMATION.md b/dev-docs/DEVELOPER_AUTOMATION.md index 3217db279..3f640e923 100644 --- a/dev-docs/DEVELOPER_AUTOMATION.md +++ b/dev-docs/DEVELOPER_AUTOMATION.md @@ -29,12 +29,12 @@ The adapter is intentionally narrower than the removed V1 plugin runtime. It doe ## Trust Boundaries -- Discovery registrations contain random tokens and accept loopback requests only. +- Discovery registrations contain random tokens and target an internal loopback HTTP listener, independent of the user-facing HTTPS certificate. - The bridge verifies the OpenCode session and its location against the current CodeNomad workspace manager. - One OpenCode session owns a developer run until that run stops or is replaced. - CDP uses the exact target ID reported by the native host. - Accessibility refs are invalidated by navigation. -- Diagnostics, accessibility snapshots, screenshots, lines, and log histories are bounded. +- Diagnostics, accessibility snapshots, screenshots, and log histories are bounded. ## Main Paths diff --git a/packages/server/src/developer-cdp.test.ts b/packages/server/src/developer-cdp.test.ts index 039460905..f9f6df74f 100644 --- a/packages/server/src/developer-cdp.test.ts +++ b/packages/server/src/developer-cdp.test.ts @@ -13,7 +13,7 @@ const page = (id: string) => ({ title: `Page ${id}`, type: "page", url: `http://app.test/${id}`, - webSocketDebuggerUrl: `ws://chrome.test/${id}`, + webSocketDebuggerUrl: `ws://127.0.0.1:9222/${id}`, }) class FakeSocket { @@ -214,6 +214,25 @@ describe("DeveloperCdp", () => { assert.equal(chrome.sockets.length, 2) }) + it("invalidates refs and pending commands when an open socket errors", async () => { + const chrome = new FakeChrome() + const client = chrome.client() + const inspection = await client.inspect(identity) + const oldRef = inspection.nodes[0].ref! + const socket = chrome.sockets[0] + socket.onerror?.({}) + + await assert.rejects(client.act({ runId: identity.runId, kind: "click", ref: oldRef }), /stale accessibility ref/) + assert.equal(socket.closed, true) + }) + + it("rejects non-loopback WebSocket targets", async () => { + const chrome = new FakeChrome() + chrome.target = { ...chrome.target, webSocketDebuggerUrl: "ws://example.com/page" } + await assert.rejects(chrome.client().inspect(identity), /target one is unavailable/) + assert.equal(chrome.sockets.length, 0) + }) + it("aborts actions when navigation occurs between CDP commands", async () => { const chrome = new FakeChrome() const client = chrome.client() diff --git a/packages/server/src/developer-cdp.ts b/packages/server/src/developer-cdp.ts index 039593f1b..afe614c3d 100644 --- a/packages/server/src/developer-cdp.ts +++ b/packages/server/src/developer-cdp.ts @@ -268,10 +268,8 @@ export class DeveloperCdp { const fail = (error: Error) => { clearTimeout(timer) if (state.socket === socket) { - state.socket = undefined - state.open = undefined - } - socket.close() + this.disconnect(state, error) + } else socket.close() reject(error) } const timer = setTimeout(() => fail(new Error("CDP WebSocket connection timed out")), this.dependencies.timeoutMs) @@ -417,6 +415,19 @@ function isTarget(value: unknown): value is CdpTarget { && typeof target.type === "string" && typeof target.url === "string" && typeof target.webSocketDebuggerUrl === "string" + && isLoopbackWebSocketUrl(target.webSocketDebuggerUrl) +} + +function isLoopbackWebSocketUrl(value: string): boolean { + try { + const url = new URL(value) + return (url.protocol === "ws:" || url.protocol === "wss:") + && url.hostname === "127.0.0.1" + && !url.username + && !url.password + } catch { + return false + } } function valueOf(value: AxValue | undefined): string { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 63cf4b736..fc36f43f2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,7 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, D import { resolveHttpsOptions } from "./server/tls" import { RemoteProxySessionManager } from "./server/remote-proxy" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" -import { resolvePluginBaseUrl } from "./server/listener-base-url" +import { resolveAutomationBridgeUrl, resolvePluginBaseUrl } from "./server/listener-base-url" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" import { SideCarManager } from "./sidecars/manager" @@ -470,11 +470,11 @@ async function main() { // - Remote access disabled: both listen on loopback. // - HTTP-only mode: respect --host (used for dev/testing). const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1" - const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" + const httpBindHost = nativeParent.available ? "127.0.0.1" : options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" const servers: Array> = [] - const httpServer = options.http + const httpServer = options.http || nativeParent.available ? createHttpServer({ bindHost: httpBindHost, bindPort: httpBindPort, @@ -537,7 +537,8 @@ async function main() { httpsServer ? httpsServer.start() : Promise.resolve(null), ]) - const localStart = httpStart ?? httpsStart + const visibleHttpStart = options.http ? httpStart : null + const localStart = visibleHttpStart ?? httpsStart if (!localStart) { throw new Error("No listeners started") } @@ -568,7 +569,7 @@ async function main() { // accepts loopback. Concrete LAN bindings do not, so plugins need the reachable // bound/listener URL instead of an unreachable 127.0.0.1 URL. const localUrl = resolvePluginBaseUrl({ - httpStart: httpStart ? { protocol: "http", bindHost: httpBindHost, port: httpStart.port } : null, + httpStart: visibleHttpStart ? { protocol: "http", bindHost: httpBindHost, port: visibleHttpStart.port } : null, httpsStart: httpsStart ? { protocol: "https", bindHost: httpsBindHost, port: httpsStart.port } : null, remoteUrl, }) @@ -584,9 +585,11 @@ async function main() { if (nativeParent.available) { try { await installAutomationPlugin() + if (!httpStart) throw new Error("Developer Automation HTTP listener did not start") + const automationUrl = resolveAutomationBridgeUrl({ protocol: "http", bindHost: httpBindHost, port: httpStart.port }) removeAutomationBridge = await publishAutomationBridge({ ...automationBridge, - url: new URL(AUTOMATION_BRIDGE_PATH, localUrl).href, + url: new URL(AUTOMATION_BRIDGE_PATH, automationUrl).href, }) } catch (error) { logger.warn({ err: error }, "Failed to install the OpenCode automation plugin") diff --git a/packages/server/src/opencode/automation-plugin.test.ts b/packages/server/src/opencode/automation-plugin.test.ts index c059b03bb..ba5622a49 100644 --- a/packages/server/src/opencode/automation-plugin.test.ts +++ b/packages/server/src/opencode/automation-plugin.test.ts @@ -15,40 +15,20 @@ test("validates developer automation actions", () => { assert.throws(() => parseDeveloperAction({ action: "click" }), /click requires ref/) }) -test("registers developer tools only for CodeNomad-owned locations", async () => { +test("registers developer tools while execution remains session-gated", async () => { let skill: Record | undefined const tools: string[] = [] await setupAutomationPlugin({ location: { directory: "D:\\project" }, skill: { transform: async (callback) => callback({ add: (value) => { skill = value } }) }, tool: { transform: async (callback) => callback({ add: (value) => tools.push(value.name) }) }, - }, async () => true) + }) assert.equal(skill?.id, "codenomad-automation") assert.equal(skill?.autoinvoke, true) assert.match(String(skill?.content), /codenomad\.inspect/) assert.deepEqual(tools, ["inspect", "act", "screenshot"]) - let transformed = false - await setupAutomationPlugin({ - location: { directory: "D:\\other" }, - skill: { transform: async () => { transformed = true } }, - tool: { transform: async () => { transformed = true } }, - }, async () => false) - assert.equal(transformed, false) -}) - -test("includes the OpenCode workspace identity in location discovery", async () => { - let location: [string, string | undefined] | undefined - await setupAutomationPlugin({ - location: { directory: "D:\\project", workspaceID: "workspace-1" }, - skill: { transform: async () => undefined }, - tool: { transform: async () => undefined }, - }, async (directory, workspaceID) => { - location = [directory, workspaceID] - return false - }) - assert.deepEqual(location, ["D:\\project", "workspace-1"]) }) test("installs the automation plugin and removes obsolete browser wrappers", async () => { diff --git a/packages/server/src/opencode/automation-plugin.ts b/packages/server/src/opencode/automation-plugin.ts index 5a4413112..732ad7612 100644 --- a/packages/server/src/opencode/automation-plugin.ts +++ b/packages/server/src/opencode/automation-plugin.ts @@ -168,18 +168,6 @@ async function callBridge( return { status: response.status, body: await response.json() as BridgeResponse } } -async function ownsLocation(directory: string, workspaceID?: string): Promise { - const active = await registrations() - const claims = await Promise.all(active.map(async (registration) => { - try { - return (await callBridge(registration, { mode: "location", directory, workspaceID })).status === 200 - } catch { - return false - } - })) - return claims.some(Boolean) -} - function formatBridgeResult(resultValue: unknown) { const result = resultValue as { image?: { data: string; mime: string }; [key: string]: unknown } | undefined if (result?.image) { @@ -211,11 +199,7 @@ async function executeDeveloperTool(sessionID: string, command: DeveloperAction) return formatBridgeResult(response.body.result) } -export async function setupAutomationPlugin( - context: AutomationPluginContext, - locationOwned: (directory: string, workspaceID?: string) => Promise = ownsLocation, -): Promise { - if (!await locationOwned(context.location.directory, context.location.workspaceID)) return +export async function setupAutomationPlugin(context: AutomationPluginContext): Promise { await context.skill.transform((draft) => { draft.add({ id: SKILL_ID, diff --git a/packages/server/src/server/__tests__/listener-base-url.test.ts b/packages/server/src/server/__tests__/listener-base-url.test.ts index f742e68e6..ddd3161a2 100644 --- a/packages/server/src/server/__tests__/listener-base-url.test.ts +++ b/packages/server/src/server/__tests__/listener-base-url.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { resolvePluginBaseUrl } from "../listener-base-url" +import { resolveAutomationBridgeUrl, resolvePluginBaseUrl } from "../listener-base-url" describe("resolvePluginBaseUrl", () => { it("keeps loopback URLs for default local listeners", () => { @@ -45,3 +45,14 @@ describe("resolvePluginBaseUrl", () => { ) }) }) + +describe("resolveAutomationBridgeUrl", () => { + it("uses IPv4 loopback for an internal HTTP listener", () => { + assert.equal(resolveAutomationBridgeUrl({ protocol: "http", bindHost: "0.0.0.0", port: 3210 }), "http://127.0.0.1:3210") + }) + + it("rejects HTTPS and non-loopback listeners", () => { + assert.throws(() => resolveAutomationBridgeUrl({ protocol: "https", bindHost: "127.0.0.1", port: 3210 }), /loopback HTTP/) + assert.throws(() => resolveAutomationBridgeUrl({ protocol: "http", bindHost: "192.168.1.2", port: 3210 }), /loopback HTTP/) + }) +}) diff --git a/packages/server/src/server/listener-base-url.ts b/packages/server/src/server/listener-base-url.ts index 6e68b0a01..6b6bd8528 100644 --- a/packages/server/src/server/listener-base-url.ts +++ b/packages/server/src/server/listener-base-url.ts @@ -28,6 +28,13 @@ export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string { return `${fallbackListener.protocol}://${fallbackListener.bindHost}:${fallbackListener.port}` } +export function resolveAutomationBridgeUrl(listener: StartedListenerBaseUrlInput): string { + if (listener.protocol !== "http" || !acceptsLoopback(listener.bindHost)) { + throw new Error("Developer Automation requires a loopback HTTP listener") + } + return `http://127.0.0.1:${listener.port}` +} + function acceptsLoopback(bindHost: string): boolean { return bindHost === "0.0.0.0" || bindHost === "::" || bindHost === "localhost" || bindHost === "::1" || bindHost.startsWith("127.") } diff --git a/packages/server/src/server/routes/automation-plugin.test.ts b/packages/server/src/server/routes/automation-plugin.test.ts index d641b385a..0d1ce900e 100644 --- a/packages/server/src/server/routes/automation-plugin.test.ts +++ b/packages/server/src/server/routes/automation-plugin.test.ts @@ -47,7 +47,6 @@ test("fences developer automation by owned session and forwards CDP actions", as payload: body, }) - assert.equal((await request({ mode: "location", directory: "D:\\project", workspaceID: "workspace-1" })).statusCode, 200) assert.equal((await request({ mode: "developer-probe", sessionID: "session-1" })).statusCode, 200) const inspect = await request({ mode: "developer-execute", sessionID: "session-1", command: { action: "inspect" } }) assert.equal(inspect.statusCode, 200) diff --git a/packages/server/src/server/routes/automation-plugin.ts b/packages/server/src/server/routes/automation-plugin.ts index 9215c9cbc..1dd01370b 100644 --- a/packages/server/src/server/routes/automation-plugin.ts +++ b/packages/server/src/server/routes/automation-plugin.ts @@ -41,22 +41,7 @@ export function registerAutomationPluginRoute(app: FastifyInstance, deps: Automa let developerOwner: { runId: string; sessionID: string } | undefined app.post(AUTOMATION_BRIDGE_PATH, { bodyLimit: 32 * 1024 }, async (request, reply) => { if (!isAutomationPluginRequest(request, deps)) return reply.code(401).send({ error: "Unauthorized automation bridge" }) - const body = request.body as { mode?: unknown; directory?: unknown; workspaceID?: unknown; sessionID?: unknown; command?: unknown } | undefined - if (body?.mode === "location") { - if (typeof body.directory !== "string" || body.directory.length === 0 || body.directory.length > 32_768) { - return reply.code(400).send({ error: "Invalid automation bridge location" }) - } - if (body.workspaceID !== undefined && (typeof body.workspaceID !== "string" || body.workspaceID.length === 0 || body.workspaceID.length > 256)) { - return reply.code(400).send({ error: "Invalid automation bridge workspace" }) - } - const directory = body.directory - const workspaceID = body.workspaceID as string | undefined - const owned = await Promise.all(deps.workspaceManager.list().map((workspace) => - deps.workspaceManager.ownsLocation(workspace.id, { directory, workspaceID }), - )) - if (owned.some(Boolean)) return reply.send({ result: { available: true } }) - return reply.code(404).send({ error: "Location is not owned by this CodeNomad instance" }) - } + const body = request.body as { mode?: unknown; sessionID?: unknown; command?: unknown } | undefined if (!body || !["developer-probe", "developer-execute"].includes(String(body.mode)) || typeof body.sessionID !== "string" || body.sessionID.length > 256) { return reply.code(400).send({ error: "Invalid automation bridge request" }) diff --git a/packages/ui/src/components/settings/developer-automation-card.tsx b/packages/ui/src/components/settings/developer-automation-card.tsx index 79d6ab35f..69ca85e4d 100644 --- a/packages/ui/src/components/settings/developer-automation-card.tsx +++ b/packages/ui/src/components/settings/developer-automation-card.tsx @@ -26,6 +26,8 @@ export const DeveloperAutomationCard: Component = () => { let disposed = false let statusRevision = 0 let logRevision = 0 + let operationRevision = 0 + let refreshing = false let stopRequested = false let refreshTimer: number | undefined const cleanups: Array<() => void> = [] @@ -56,6 +58,8 @@ export const DeveloperAutomationCard: Component = () => { } async function refresh() { + if (refreshing) return + refreshing = true const currentStatusRevision = statusRevision const currentLogRevision = logRevision try { @@ -65,6 +69,8 @@ export const DeveloperAutomationCard: Component = () => { if (currentLogRevision === logRevision) setLogs(snapshot.logs.slice(-MAX_LOG_ENTRIES)) } catch (cause) { if (!disposed) reportError(cause, "settings.developerAutomation.errors.load") + } finally { + refreshing = false } } @@ -104,12 +110,14 @@ export const DeveloperAutomationCard: Component = () => { const path = executable().trim() if (!path || starting() || stopping() || active()) return stopRequested = false + const currentOperationRevision = ++operationRevision setStarting(true) setError(null) logRevision += 1 setLogs([]) try { - applyStatus(await startDeveloperRun({ target: target(), executable: path })) + const next = await startDeveloperRun({ target: target(), executable: path }) + if (currentOperationRevision === operationRevision && !stopRequested) applyStatus(next) } catch (cause) { if (!stopRequested) reportError(cause, "settings.developerAutomation.errors.start") } finally { @@ -120,14 +128,17 @@ export const DeveloperAutomationCard: Component = () => { async function stop() { if (stopping() || (!active() && !starting())) return stopRequested = true + const currentOperationRevision = ++operationRevision setStopping(true) setError(null) try { await stopDeveloperRun() const currentLogRevision = logRevision const snapshot = await getDeveloperRun() - applyStatus(snapshot.status) - if (currentLogRevision === logRevision) setLogs(snapshot.logs.slice(-MAX_LOG_ENTRIES)) + if (currentOperationRevision === operationRevision) { + applyStatus(snapshot.status) + if (currentLogRevision === logRevision) setLogs(snapshot.logs.slice(-MAX_LOG_ENTRIES)) + } } catch (cause) { reportError(cause, "settings.developerAutomation.errors.stop") } finally { From 993f32453db9feaa3906dfef1a0244034415e87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 29 Aug 2026 09:57:26 +0200 Subject: [PATCH 130/131] docs(automation): record global tool visibility Document the current OpenCode one-shot plugin setup limitation: Developer Automation definitions are visible globally, while every operation remains inert until the authenticated bridge verifies session location ownership. Mark the dynamic location-scoped registration upgrade path explicitly. --- .../references/server-conventions.md | 2 +- dev-docs/DEVELOPER_AUTOMATION.md | 4 +++- packages/server/src/opencode/automation-plugin.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index 9ca9252d1..16b1feabc 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -12,7 +12,7 @@ - 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 general plugin installation/packaging. Developer Automation's reviewed, location-gated adapter is the sole exception. +- Never spawn or stop OpenCode per workspace and never add general plugin installation/packaging. Developer Automation's reviewed, execution-gated adapter is the sole exception; definitions are global until OpenCode supports dynamic location-scoped registration. - 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`. diff --git a/dev-docs/DEVELOPER_AUTOMATION.md b/dev-docs/DEVELOPER_AUTOMATION.md index 3f640e923..7c1e298c1 100644 --- a/dev-docs/DEVELOPER_AUTOMATION.md +++ b/dev-docs/DEVELOPER_AUTOMATION.md @@ -19,12 +19,14 @@ The host waits for the exact CDP page target, keeps bounded stdout/stderr logs, ## Agent Feedback -For CodeNomad-owned OpenCode locations, a small automation adapter exposes: +The small automation adapter registers these definitions with OpenCode: - `codenomad.inspect`: accessibility tree, runtime diagnostics, target metadata, and recent launch logs. - `codenomad.act`: click, type, or restart using refs from the latest inspection. - `codenomad.screenshot`: PNG capture of the connected build. +OpenCode plugin setup is currently one-shot, so definitions are visible in unrelated locations. Calls remain inert unless the bridge verifies that the current session location is owned by the active CodeNomad instance. + The adapter is intentionally narrower than the removed V1 plugin runtime. It does not own OpenCode lifecycle or state, spawn one daemon per workspace, or expose autonomous browser previews. ## Trust Boundaries diff --git a/packages/server/src/opencode/automation-plugin.ts b/packages/server/src/opencode/automation-plugin.ts index 732ad7612..208370071 100644 --- a/packages/server/src/opencode/automation-plugin.ts +++ b/packages/server/src/opencode/automation-plugin.ts @@ -200,6 +200,7 @@ async function executeDeveloperTool(sessionID: string, command: DeveloperAction) } export async function setupAutomationPlugin(context: AutomationPluginContext): Promise { + // ponytail: OpenCode plugin setup is one-shot; keep definitions global until it supports dynamic location-scoped registration. Every call is still ownership-gated. await context.skill.transform((draft) => { draft.add({ id: SKILL_ID, From bce40b7fa6d477675e2fb35b1a74a32a6a9586b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 29 Aug 2026 10:35:19 +0200 Subject: [PATCH 131/131] fix(yolo): preserve session list scope across pages Keep the workspace directory and list limit on every OpenCode session pagination request. Cursor-only follow-up requests fail location scoping and broke the full server suite after the V2 client migration. Validated with the Yolo metadata regression, the server typecheck, and the complete server test suite. --- packages/server/src/permissions/opencode-yolo-metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index 6dba5ce86..eee343f62 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -49,7 +49,7 @@ export function createOpencodeYoloPersistence( const sessions: SessionInfo[] = [] let cursor: string | undefined do { - const page = await client.session.list(cursor ? { cursor } : { directory, limit: SESSION_LIST_LIMIT }) + const page = await client.session.list({ directory, limit: SESSION_LIST_LIMIT, cursor }) sessions.push(...page.data) cursor = page.cursor.next ?? undefined } while (cursor)