diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 8fdc3cb3..721d187d 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -15,7 +15,7 @@ description: | ## Native OpenCode V2 Baseline -- The only OpenCode client dependency is exact version `@opencode-ai/client@0.0.0-next-17288` in server and UI. +- The only OpenCode client dependency is exact version `@opencode-ai/client@0.0.0-next-17353` in server and UI. - Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`. - 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. @@ -56,7 +56,7 @@ description: | | Avoid | Use | |---|---| -| `@opencode-ai/sdk` | `@opencode-ai/client@0.0.0-next-17288` | +| `@opencode-ai/sdk` | `@opencode-ai/client@0.0.0-next-17353` | | 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 | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index d41ed709..de87d124 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -16,7 +16,7 @@ The server calls `Service.ensure` once through `packages/server/src/workspaces/o | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | `@opencode-ai/client@0.0.0-next-17288` | +| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | `@opencode-ai/client@0.0.0-next-17353` | | 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/` | 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 6075adb9..bf28822c 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -2,7 +2,7 @@ ## Package -CodeNomad pins `@opencode-ai/client@0.0.0-next-17288` exactly in both `packages/server/package.json` and `packages/ui/package.json`. +CodeNomad pins `@opencode-ai/client@0.0.0-next-17353` exactly in both `packages/server/package.json` and `packages/ui/package.json`. - Promise client: `import { OpenCode } from "@opencode-ai/client"` - Service lifecycle: `import { Service } from "@opencode-ai/client/service"` 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 4f8c8f04..6c267c93 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -2,7 +2,7 @@ ## Contract -- Version is pinned to `@opencode-ai/client@0.0.0-next-17288`; update server and UI together. +- Version is pinned to `@opencode-ai/client@0.0.0-next-17353`; update server, UI, and the required `opencode2` CLI together. - The package root is the generated zero-Effect Promise client. Use installed declarations, not old SDK examples. - Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1853bc33..beae9213 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,7 @@ Then open a pull request on GitHub targeting the `dev` branch. ### OpenCode V2 Boundaries -- Server and UI pin `@opencode-ai/client@0.0.0-next-17288`; do not add `@opencode-ai/sdk`. +- Server and UI pin `@opencode-ai/client@0.0.0-next-17353`; do not add `@opencode-ai/sdk`. - `packages/server/src/workspaces/opencode-service.ts` owns the single shared `Service.ensure` lifecycle. Workspaces are native OpenCode locations/directories, not separate server processes. - 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. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index cb5f132f..79c2cad7 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -8,12 +8,13 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Main Changes -- Replace `@opencode-ai/sdk` with the pinned native V2 client, `@opencode-ai/client@0.0.0-next-17288`. +- Replace `@opencode-ai/sdk` with the pinned native V2 client, `@opencode-ai/client@0.0.0-next-17353`. - 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. +- Use browser `EventSource` as the single desktop and web event transport; the duplicate Rust-native Tauri transport was removed. - Reconcile session state through `session.active()` after reconnecting so missed events do not leave stale working states. - Route events from owned Git worktrees to their corresponding logical CodeNomad workspace. @@ -53,21 +54,17 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Current Status - Server and UI typechecks pass. -- Focused tests for service ownership, proxy security, worktree event routing, provider authentication, and voice instructions pass. -- UI tests and builds passed earlier in the migration. -- A real service smoke test is blocked because `opencode2` is not installed in the current `PATH`. -- The migration is not merge-ready yet. The final security review found unresolved proxy isolation issues that must be fixed first. +- The complete UI suite passes with 433 tests. +- Focused proxy, service ownership, worktree event routing, provider authentication, voice instruction, Windows, WSL, and Tauri tests pass. +- The pinned client and installed `opencode2` CLI are aligned on `0.0.0-next-17353`. +- The final critical/high security gate has no unresolved proxy, authentication, event-isolation, or process-ownership finding. +- A real `opencode2@0.0.0-next-17353` lifecycle smoke test passed: authenticated discovery, workspace location validation, and confirmed service shutdown. +- The migration remains a Draft until the GitHub build matrix completes. ## Remaining Work -- Fix the encoded-path proxy issue that can redirect an authenticated upstream request to another host. -- Restrict or filter global V2 endpoints that are not scoped by `Location`. -- Enforce session ownership for experimental session log routes. -- Validate embedded locations when importing sessions. -- Harden shared service registration and multi-process shutdown behavior. -- Complete the remaining high-priority event and provider-auth security fixes. - Run the complete test and build matrix after the fixes. -- Run an end-to-end smoke test with the actual `opencode2` binary. +- Run the GitHub build matrix by marking the PR ready for review. ## Validation diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 0c0979f4..cf775585 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -297,7 +297,7 @@ tasks/ Task tracking ## Current OpenCode Baseline -- Native client: `@opencode-ai/client@0.0.0-next-17288` +- Native client and required CLI: `@opencode-ai/client@0.0.0-next-17353` / `opencode2@0.0.0-next-17353` - Service: one shared `Service.ensure` - Workspaces: native locations/directories - Shell and instructions: native session APIs diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 6619f6d5..b4cc0c44 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates directly with native OpenCode V2 through exact dependency `@opencode-ai/client@0.0.0-next-17288`. +CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates directly with native OpenCode V2 through exact dependency `@opencode-ai/client@0.0.0-next-17353`. ```text Desktop host -> CodeNomad server -> one shared OpenCode service diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 856c1178..10a41425 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -2,7 +2,7 @@ ## OpenCode Dependency -Server and UI pin `@opencode-ai/client@0.0.0-next-17288`. Import the generated Promise client from `@opencode-ai/client` and service lifecycle APIs from `@opencode-ai/client/service`. +Server and UI pin `@opencode-ai/client@0.0.0-next-17353`. Import the generated Promise client from `@opencode-ai/client`; CodeNomad owns the safe shared-service launch lifecycle because the beta service helper does not expose sufficient process-identity guarantees. 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/`. diff --git a/package-lock.json b/package-lock.json index 4753d2a6..96e5d853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3303,13 +3303,13 @@ } }, "node_modules/@opencode-ai/client": { - "version": "0.0.0-next-17288", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17288.tgz", - "integrity": "sha512-9qD73yHk4zpIafusE5NQ/fMAQhm9TdEmiocy3niuL6VuTaUzy7/18gjqYbUAjmKriCPf3G/ue+l44JC/P8n8rw==", + "version": "0.0.0-next-17353", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17353.tgz", + "integrity": "sha512-LuIl8eL3ZLpoiPBNHvBVn8bcsQnxEvplf5nC83RN5PUK1Wo2pKGTW+e22VMqRhmTz12mxH8n0W4ys5/SFtbyiA==", "license": "MIT", "dependencies": { - "@opencode-ai/protocol": "0.0.0-next-17288", - "@opencode-ai/schema": "0.0.0-next-17288" + "@opencode-ai/protocol": "0.0.0-next-17353", + "@opencode-ai/schema": "0.0.0-next-17353" }, "peerDependencies": { "effect": "4.0.0-beta.101" @@ -3321,19 +3321,19 @@ } }, "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-next-17288", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17288.tgz", - "integrity": "sha512-UlKX6+3ShWAJF6Ctq5yvJQbBvpb48GFeFaGSyZWTkqNdzG9kCbiXpxo6/bMUps+JyS7yQ7tdNcn9vfSNZ0QFEA==", + "version": "0.0.0-next-17353", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17353.tgz", + "integrity": "sha512-QnCRsd3hznEXsTAQxloFBA97VQC3gHFGjB91ugZjmvKzcWYVvuBjMp86GZzv1ZWsmUCemiIwvyp6v4d7hjg/SQ==", "license": "MIT", "dependencies": { - "@opencode-ai/schema": "0.0.0-next-17288", + "@opencode-ai/schema": "0.0.0-next-17353", "effect": "4.0.0-beta.101" } }, "node_modules/@opencode-ai/schema": { - "version": "0.0.0-next-17288", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17288.tgz", - "integrity": "sha512-c/raCR/Es3UXada+7ZpL/nabjBtSV5QPS11sL3mmfmryMtsrZE4DSnnG5I7ihwwr9ZpR8taAmXbyh0fzq6h3lQ==", + "version": "0.0.0-next-17353", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17353.tgz", + "integrity": "sha512-7ZfJmXOsMI16w0UpqRvtwh3Dice4DG0irufuNqIINUc2aHWL8LOpZkpyhu+n9KKbCYOj/VMeWGLwhx8u5KIoNA==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", @@ -13654,7 +13654,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "0.0.0-next-17288", + "@opencode-ai/client": "0.0.0-next-17353", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -13704,7 +13704,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/client": "0.0.0-next-17288", + "@opencode-ai/client": "0.0.0-next-17353", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/server/package.json b/packages/server/package.json index 45d53b50..a858fae0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -27,7 +27,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "0.0.0-next-17288", + "@opencode-ai/client": "0.0.0-next-17353", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index d803587b..432a70c2 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -71,6 +71,69 @@ describe("AutoAcceptManager session tree", () => { manager.stop() }) + it("updates family boundaries from native revert events", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + publishSession(bus, "inst", "session.updated", { id: "root", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "root" }) + manager.toggle("inst", "root") + + publishInstanceEvent(bus, "inst", { + type: "session.revert.staged", + properties: { sessionID: "child", revert: { messageID: "message" } }, + }) + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "staged", sessionID: "child" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + publishInstanceEvent(bus, "inst", { + type: "session.revert.cleared", + properties: { sessionID: "child" }, + }) + await flushMicrotasks() + assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged"]) + + publishInstanceEvent(bus, "inst", { + type: "session.revert.staged", + properties: { sessionID: "child", revert: { messageID: "message" } }, + }) + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "committed", sessionID: "child" }, + }) + publishInstanceEvent(bus, "inst", { + type: "session.revert.committed", + properties: { sessionID: "child" }, + }) + await flushMicrotasks() + assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged", "committed"]) + manager.stop() + }) + + it("does not apply Yolo policy to a session unknown to that logical workspace", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + manager.toggle("wrong-owner", "session") + publishInstanceEvent(bus, "wrong-owner", { + type: "permission.asked", + properties: { id: "permission", sessionID: "session" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + publishSession(bus, "wrong-owner", "session.updated", { id: "session", parentID: null }) + await flushMicrotasks() + assert.equal(replier.calls.length, 1) + manager.stop() + }) + it("session.deleted removes the tree entry but keeps the toggle", () => { const bus = new EventBus(noopLogger) const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) @@ -129,6 +192,7 @@ describe("AutoAcceptManager persistence", () => { const writes: unknown[][] = [] const persistence: AutoAcceptPersistence = { async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } }, async persist(...args) { writes.push(args); await gate }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) @@ -148,6 +212,7 @@ describe("AutoAcceptManager persistence", () => { const writes: boolean[] = [] const persistence: AutoAcceptPersistence = { async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } }, async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled) }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) @@ -181,6 +246,20 @@ describe("AutoAcceptManager persistence", () => { manager.stop() }) + it("rejects a persisted toggle when the native session belongs to another logical workspace", async () => { + const bus = new EventBus(noopLogger) + let writes = 0 + const persistence: AutoAcceptPersistence = { + async loadSessions() { return [{ id: "foreign", parentId: null, yoloEnabled: false }] }, + async loadSession() { return null }, + async persist() { writes += 1 }, + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) + await assert.rejects(Promise.resolve(manager.toggle("inst", "foreign")), /does not belong to workspace/) + assert.equal(writes, 0) + assert.equal(manager.isEnabled("inst", "foreign"), false) + }) + it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => { const bus = new EventBus(noopLogger) const changes: Record[] = [] @@ -190,6 +269,7 @@ describe("AutoAcceptManager persistence", () => { let writes = 0 const persistence: AutoAcceptPersistence = { async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } }, async persist() { writes += 1; await gate }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) @@ -245,6 +325,7 @@ describe("AutoAcceptManager persistence", () => { { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true }, ] }, + async loadSession() { return { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true } }, async persist(...args) { writes.push(args) if (writes.length === 1) await firstGate @@ -273,6 +354,7 @@ describe("AutoAcceptManager persistence", () => { let attempts = 0 const persistence: AutoAcceptPersistence = { async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, + async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } }, async persist() { if (++attempts === 1) throw new Error("write failed") }, } const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) @@ -301,6 +383,25 @@ describe("AutoAcceptManager persistence", () => { }) describe("AutoAcceptManager permission interception", () => { + it("replies once when duplicate logical workspaces receive the same native permission", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + for (const instanceId of ["first", "second"]) { + publishSession(bus, instanceId, "session.updated", { id: "session", parentID: null }) + manager.toggle(instanceId, "session") + publishInstanceEvent(bus, instanceId, { + type: "permission.asked", + properties: { id: "permission", sessionID: "session" }, + }) + } + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + manager.stop() + }) + it("auto-replies to a v2 permission on an enabled family", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index 3fc7e7e6..1de933b7 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -49,6 +49,7 @@ export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo { export interface AutoAcceptPersistence { loadSessions(instanceId: string): Promise + loadSession?(instanceId: string, sessionId: string): Promise persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise } @@ -56,15 +57,16 @@ const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) +const SESSION_REVERT_TYPES = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"]) export class AutoAcceptManager { private static readonly MAX_REPLY_ATTEMPTS = 3 private readonly store = new AutoAcceptStore() - /** instanceId:permissionId entries currently being replied, to dedupe re-emissions */ + /** Native permission ids currently being replied, including duplicate logical workspace emissions. */ private readonly inFlight = new Set() /** instanceId -> (permissionId -> pending permission) awaiting a reply */ private readonly pending = new Map>() - /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */ + /** Native permission id -> failure count, to stop retrying stuck permissions. */ private readonly replyAttempts = new Map() private readonly hydratedInstances = new Set() private readonly hydration = new Map>() @@ -177,6 +179,17 @@ export class AutoAcceptManager { if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { return this.store.isEnabled(instanceId, sessionId) } + const session = await this.deps.persistence!.loadSession?.(instanceId, sessionId) + if (!session) throw new Error(`Session ${sessionId} does not belong to workspace ${instanceId}`) + if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) { + return this.store.isEnabled(instanceId, sessionId) + } + this.store.upsertSession(instanceId, session) + if (session.workspaceId) { + const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map() + workspaces.set(session.id, session.workspaceId) + this.sessionWorkspaces.set(instanceId, workspaces) + } const rootSessionId = this.store.familyRoot(instanceId, sessionId) const traversedRootSessionIds = new Set([rootSessionId]) const enabled = !this.store.isEnabled(instanceId, rootSessionId) @@ -240,13 +253,6 @@ export class AutoAcceptManager { this.mutations.delete(instanceId) this.store.clearInstance(instanceId) this.pending.delete(instanceId) - const prefix = `${instanceId}:` - for (const key of Array.from(this.inFlight.keys())) { - if (key.startsWith(prefix)) this.inFlight.delete(key) - } - for (const key of Array.from(this.replyAttempts.keys())) { - if (key.startsWith(prefix)) this.replyAttempts.delete(key) - } } handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void { @@ -265,6 +271,10 @@ export class AutoAcceptManager { } return } + if (SESSION_REVERT_TYPES.has(event.type)) { + this.ingestSessionRevert(instanceId, event.type, event.properties) + return + } if (PERMISSION_REPLIED_TYPES.has(event.type)) { this.handlePermissionReplied(instanceId, event.properties) return @@ -300,6 +310,16 @@ export class AutoAcceptManager { this.drainPending(instanceId, session.id) } + private ingestSessionRevert(instanceId: string, eventType: string, properties: unknown): void { + const value = properties as { sessionID?: unknown; sessionId?: unknown; revert?: unknown } | undefined + const sessionId = readString(value?.sessionID) ?? readString(value?.sessionId) + if (!sessionId || !this.store.hasSession(instanceId, sessionId)) return + const enabledBefore = this.store.enabledRoots(instanceId) + this.store.setSessionRevert(instanceId, sessionId, eventType === "session.revert.staged" ? value?.revert : undefined) + this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId)) + this.drainPending(instanceId, sessionId) + } + private persistRootMigration(instanceId: string, before: readonly string[], after: readonly string[]): void { if (!this.deps.persistence) return const removed = before.filter((id) => !after.includes(id)) @@ -354,7 +374,7 @@ export class AutoAcceptManager { this.addPending(instanceId, { permissionId, sessionId, source }) - if (!this.store.isEnabled(instanceId, sessionId)) return + if (!this.store.hasSession(instanceId, sessionId) || !this.store.isEnabled(instanceId, sessionId)) return this.tryAutoAccept(instanceId, permissionId, sessionId, source) } @@ -375,7 +395,7 @@ export class AutoAcceptManager { sessionId: string, source: PermissionSource, ): void { - const key = `${instanceId}:${permissionId}` + const key = permissionId if (this.inFlight.has(key)) return const attempts = this.replyAttempts.get(key) ?? 0 if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return @@ -387,13 +407,13 @@ export class AutoAcceptManager { void this.deps.replier(reply) .then(() => { this.replyAttempts.delete(key) - this.removePending(instanceId, permissionId) + this.removePendingFromAllInstances(permissionId) this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId }) }) .catch((error) => { this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed") if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) { - this.removePending(instanceId, permissionId) + this.removePendingFromAllInstances(permissionId) } }) .finally(() => { @@ -407,7 +427,7 @@ export class AutoAcceptManager { if (!instancePending || instancePending.size === 0) return const root = this.store.familyRoot(instanceId, sessionId) for (const entry of Array.from(instancePending.values())) { - if (this.store.familyRoot(instanceId, entry.sessionId) === root) { + if (this.store.hasSession(instanceId, entry.sessionId) && this.store.familyRoot(instanceId, entry.sessionId) === root) { this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source) } } @@ -425,18 +445,22 @@ export class AutoAcceptManager { private removePending(instanceId: string, permissionId: string): void { const instancePending = this.pending.get(instanceId) if (instancePending?.delete(permissionId)) { - this.replyAttempts.delete(`${instanceId}:${permissionId}`) + this.replyAttempts.delete(permissionId) if (instancePending.size === 0) this.pending.delete(instanceId) } } + private removePendingFromAllInstances(permissionId: string): void { + for (const instanceId of Array.from(this.pending.keys())) this.removePending(instanceId, permissionId) + } + private removePendingForSession(instanceId: string, sessionId: string): void { const instancePending = this.pending.get(instanceId) if (!instancePending) return for (const [permId, entry] of Array.from(instancePending)) { if (entry.sessionId === sessionId) { instancePending.delete(permId) - this.replyAttempts.delete(`${instanceId}:${permId}`) + this.replyAttempts.delete(permId) } } if (instancePending.size === 0) this.pending.delete(instanceId) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts index fe186c01..e2cdafe5 100644 --- a/packages/server/src/permissions/auto-accept-store.ts +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -97,6 +97,17 @@ export class AutoAcceptStore { this.sessions.get(instanceId)?.delete(sessionId) } + hasSession(instanceId: string, sessionId: string): boolean { + return this.sessions.get(instanceId)?.has(sessionId) ?? false + } + + setSessionRevert(instanceId: string, sessionId: string, revert: unknown): void { + const session = this.sessions.get(instanceId)?.get(sessionId) + if (!session) return + session.revert = revert + this.migrateEnabledRoots(instanceId) + } + clearInstance(instanceId: string): void { this.sessions.delete(instanceId) this.enabled.delete(instanceId) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index fdd1f1f0..248c05f2 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -18,7 +18,10 @@ function createHarness() { return owner }, } as unknown as SettingsService - const workspaceManager = { get: () => ({ path: "/repo" }) } as unknown as WorkspaceManager + const workspaceManager = { + get: () => ({ path: "/repo" }), + ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo", + } as unknown as WorkspaceManager const client = { session: { async list(input: Record) { @@ -35,6 +38,14 @@ function createHarness() { cursor: {}, } }, + async get({ sessionID }: { sessionID: string }) { + return { + id: sessionID, + parentID: undefined, + revert: undefined, + location: { directory: sessionID === "foreign" ? "/other" : "/repo", workspaceID: "workspace" }, + } + }, }, } as unknown as OpenCodeClient const persistence = createOpencodeYoloPersistence( @@ -61,4 +72,10 @@ describe("OpenCode Yolo persistence", () => { ]) }) + it("loads an exact session only when its native location belongs to the logical workspace", async () => { + const { persistence } = createHarness() + assert.equal((await persistence.loadSession!("instance", "root"))?.id, "root") + assert.equal(await persistence.loadSession!("instance", "foreign"), null) + }) + }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index a3bce391..e09e298e 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -1,4 +1,4 @@ -import type { OpenCodeClient } from "@opencode-ai/client" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import type { WorkspaceManager } from "../workspaces/manager" import { createInstanceClient } from "../workspaces/instance-client" @@ -43,6 +43,13 @@ export function createOpencodeYoloPersistence( limit: SESSION_LIST_LIMIT, })).data } + const persistedSession = (session: SessionInfo): PersistedAutoAcceptSession => ({ + id: session.id, + parentId: session.parentID ?? null, + revert: session.revert, + workspaceId: session.location.workspaceID, + yoloEnabled: sessionState(settings, session.id).yoloEnabled === true, + }) const updateYolo = ( sessionId: string, enabled: boolean, @@ -60,13 +67,16 @@ export function createOpencodeYoloPersistence( return { async loadSessions(instanceId): Promise { - return (await listSessions(instanceId)).map((session) => ({ - id: session.id, - parentId: session.parentID ?? null, - revert: session.revert, - workspaceId: session.location.workspaceID, - yoloEnabled: sessionState(settings, session.id).yoloEnabled === true, - })) + return (await listSessions(instanceId)).map(persistedSession) + }, + async loadSession(instanceId, sessionId): Promise { + try { + const session = await (await clientFor(instanceId)).session.get({ sessionID: sessionId }) + if (!(await workspaceManager.ownsDirectory(instanceId, session.location.directory))) return null + return persistedSession(session) + } catch { + return null + } }, persist(_instanceId, rootSessionId, enabled): Promise { return updateYolo(rootSessionId, enabled) diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index f2f44a66..73ff19ec 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -14,7 +14,11 @@ function logger(): Logger { return value as unknown as Logger } -async function harness(sessionDirectory = "/repo/worktree") { +async function harness( + sessionDirectory = "/repo/worktree", + activeSessions: Record = {}, + sessionLocations: Record = {}, +) { const upstream = Fastify() apps.push(upstream) let requests = 0 @@ -33,8 +37,11 @@ async function harness(sessionDirectory = "/repo/worktree") { session: { get: async ({ sessionID }: { sessionID: string }) => { sessionGets.push(sessionID) - return { id: sessionID, location: { directory: sessionDirectory } } as SessionInfo + const location = sessionLocations[sessionID] ?? sessionDirectory + if (location instanceof Error) throw location + return { id: sessionID, location: { directory: location } } as SessionInfo }, + active: async () => activeSessions, }, } as OpenCodeClient const manager: InstanceProxyWorkspaceManager = { @@ -43,6 +50,7 @@ async function harness(sessionDirectory = "/repo/worktree") { getInstanceAuthorizationHeader: () => "Basic internal-secret", getSharedServiceClient: async () => client, ownsDirectory: async (_id, directory) => owned.has(directory), + ownsPath: async (_id, candidate) => candidate === "/repo" || candidate.startsWith("/repo/"), } const app = Fastify() apps.push(app) @@ -118,6 +126,9 @@ describe("instance proxy location enforcement", () => { connection: "keep-alive, x-remove-me", cookie: "codenomad_session=browser-secret; other=value", "x-forwarded-for": "203.0.113.1", + "x-opencode-directory": "/other", + "x-opencode-workspace": "foreign-workspace", + "x-opencode-routing-test": "foreign-route", "x-remove-me": "secret", }, }) @@ -126,6 +137,9 @@ describe("instance proxy location enforcement", () => { assert.equal(headers.cookie, undefined) assert.doesNotMatch(headers.connection ?? "", /x-remove-me/i) assert.equal(headers["x-forwarded-for"], undefined) + assert.equal(headers["x-opencode-directory"], undefined) + assert.equal(headers["x-opencode-workspace"], undefined) + assert.equal(headers["x-opencode-routing-test"], undefined) assert.equal(headers["x-remove-me"], undefined) assert.equal(response.headers["set-cookie"], undefined) }) @@ -145,6 +159,168 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) assert.doesNotMatch(response.body, /internal-secret/) }) + + it("uses the same once-decoded percent-bearing session id for ownership and forwarding", async () => { + const { app, sessionGets } = await harness() + const response = await app.inject({ + method: "DELETE", + url: "/workspaces/workspace/instance/api/session/owned%25session", + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(sessionGets, ["owned%session"]) + assert.equal(JSON.parse(response.body).url, "/api/session/owned%25session") + }) + + it("rejects deletion through a double-encoded alias of a foreign session", async () => { + const { app, sessionGets, requestCount } = await harness("/repo/worktree", {}, { + "foreign%25session": "/other", + }) + const response = await app.inject({ + method: "DELETE", + url: "/workspaces/workspace/instance/api/session/foreign%2525session", + }) + assert.equal(response.statusCode, 403) + assert.deepEqual(sessionGets, ["foreign%25session"]) + assert.equal(requestCount(), 0) + }) + + it("filters active sessions to the workspace without failing on stale ids", async () => { + const active = { owned: { type: "running" as const }, foreign: { type: "running" as const }, stale: { type: "running" as const } } + const { app, sessionGets, requestCount } = await harness("/repo/worktree", active, { + foreign: "/other", + stale: new Error("missing"), + }) + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/active" }) + assert.equal(response.statusCode, 200) + assert.deepEqual(JSON.parse(response.body), { data: { owned: { type: "running" } } }) + assert.deepEqual(sessionGets.sort(), ["foreign", "owned", "stale"]) + assert.equal(requestCount(), 0) + }) + + it("blocks global routes through a workspace", async () => { + const { app, requestCount } = await harness() + for (const route of ["global/dispose", "global/config", "global/upgrade"]) { + const response = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/${route}` }) + assert.equal(response.statusCode, 403) + } + for (const route of ["event", "project", "debug/location"]) { + const response = await app.inject({ method: "GET", url: `/workspaces/workspace/instance/api/${route}` }) + assert.equal(response.statusCode, 403) + } + assert.equal((await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/service/stop", + payload: { instanceID: "instance-1" }, + })).statusCode, 403) + assert.equal((await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/permission/saved" })).statusCode, 403) + assert.equal((await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/permission/saved/global-rule" })).statusCode, 403) + assert.equal(requestCount(), 0) + }) + + it("rejects legacy session routes before foreign session lookup", async () => { + const { app, sessionGets, requestCount } = await harness("/other") + const response = await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/session/foreign" }) + assert.equal(response.statusCode, 403) + assert.deepEqual(sessionGets, []) + assert.equal(requestCount(), 0) + }) + + it("rejects foreign prompt file URIs and accepts owned files", async () => { + const { app, requestCount } = await harness() + const malformed = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/session-1/prompt", + payload: { text: "read this", files: [{ uri: "file:///%ZZ" }] }, + }) + assert.equal(malformed.statusCode, 400) + + const foreign = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/session-1/prompt", + payload: { text: "read this", files: [{ uri: "file:///other/secret.txt" }] }, + }) + assert.equal(foreign.statusCode, 403) + assert.equal(requestCount(), 0) + + const owned = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/session-1/prompt", + payload: { text: "read this", files: [{ uri: "file:///repo/worktree/notes.txt" }] }, + }) + assert.equal(owned.statusCode, 200) + assert.equal(JSON.parse(owned.body).body.files[0].uri, "file:///repo/worktree/notes.txt") + assert.equal(requestCount(), 1) + }) + + it("enforces ownership for experimental session logs", async () => { + const owned = await harness() + const accepted = await owned.app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/experimental/session/session-1/log" }) + assert.equal(accepted.statusCode, 200) + assert.deepEqual(owned.sessionGets, ["session-1"]) + + const foreign = await harness("/other") + const rejected = await foreign.app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/experimental/session/session-2/log" }) + assert.equal(rejected.statusCode, 403) + assert.equal(foreign.requestCount(), 0) + }) + + it("defaults and validates only schema-defined imported session locations", async () => { + const { app, requestCount } = await harness() + const accepted = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/import", + payload: { + info: { id: "session-1", metadata: { location: { directory: "/other" } } }, + messages: [{ + type: "location-switched", + location: { directory: "/repo/worktree" }, + previous: { location: null }, + metadata: { location: { directory: "/other" } }, + content: [{ type: "tool", state: { input: { location: "/other" } } }], + }], + }, + }) + assert.equal(accepted.statusCode, 200) + const body = JSON.parse(accepted.body).body + assert.deepEqual(body.location, { directory: "/repo" }) + assert.deepEqual(body.info.location, { directory: "/repo" }) + assert.deepEqual(body.messages[0].previous.location, { directory: "/repo" }) + assert.deepEqual(body.messages[0].metadata.location, { directory: "/other" }) + assert.equal(body.messages[0].content[0].state.input.location, "/other") + + const rejected = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/import", + payload: { + info: { id: "session-2", location: { directory: "/repo" } }, + messages: [{ type: "location-switched", location: { directory: "/repo/worktree" }, previous: { location: { directory: "/other" } } }], + }, + }) + assert.equal(rejected.statusCode, 403) + assert.equal(requestCount(), 1) + }) + + it("never sends workspace credentials to an encoded or backslash foreign origin", async () => { + const foreign = Fastify() + apps.push(foreign) + const credentials: unknown[] = [] + foreign.all("/*", async (request) => credentials.push(request.headers.authorization)) + await foreign.listen({ host: "127.0.0.1", port: 0 }) + const address = foreign.server.address() + assert.ok(address && typeof address === "object") + + const { app, requestCount } = await harness() + for (const prefix of ["%2F%2F", "%5C%5C"]) { + const proxyResponse: Awaited> = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/${prefix}127.0.0.1:${address.port}/steal`, + }) + assert.equal(proxyResponse.statusCode, 400) + assert.doesNotMatch(proxyResponse.body, /internal-secret/) + } + assert.equal(requestCount(), 0) + assert.deepEqual(credentials, []) + }) }) it("redacts secret-bearing fields recursively", () => { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index a983c549..861c6563 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -367,6 +367,7 @@ export interface InstanceProxyWorkspaceManager { getInstanceAuthorizationHeader(id: string): string | undefined getSharedServiceClient(): Promise ownsDirectory(id: string, directory: string): Promise + ownsPath(id: string, candidate: string): Promise } interface InstanceProxyDeps { @@ -563,16 +564,56 @@ async function proxyWorkspaceRequest(args: { return } - const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) - const targetUrl = appendIncomingQuery(new URL(normalizedSuffix, endpoint.url), request.raw.url ?? "") - const requestLocations = readRequestDirectories(targetUrl, request.body) - readNativeCwd(targetUrl, request.body, requestLocations) + const rawInstancePath = (request.raw.url ?? "").split("?", 1)[0]?.match(/\/instance(?:\/(.*))?$/)?.[1] ?? "" + if (/\\|%2f|%5c/i.test(rawInstancePath)) { + reply.code(400).send({ error: "Invalid workspace instance path" }) + return + } + const targetUrl = buildInstanceTargetUrl(endpoint.url, args.pathSuffix) + if (!targetUrl) { + reply.code(400).send({ error: "Invalid workspace instance path" }) + return + } + appendIncomingQuery(targetUrl, request.raw.url ?? "") + const pathname = normalizeInstanceSuffix(args.pathSuffix) + if (!isAllowedInstanceApiRoute(request.method, pathname)) { + reply.code(403).send({ error: "OpenCode route is not available through a workspace" }) + return + } + if (pathname.replace(/\/+$/, "") === "/api/session/active") { + if (request.method !== "GET") { + reply.code(405).send({ error: "Method not allowed" }) + return + } + const client = await workspaceManager.getSharedServiceClient() + const active = await client.session.active() + const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => { + try { + const session = await client.session.get({ sessionID: sessionId }) + return await workspaceManager.ownsDirectory(workspaceId, session.location.directory) ? [sessionId, status] as const : null + } catch { + return null + } + })) + reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable => entry !== null)) }) + return + } + const imported = prepareSessionImport(pathname, request.method, request.body, workspace.path) + const requestLocations = readRequestDirectories(targetUrl, imported.body) + requestLocations.directories.push(...imported.directories) + requestLocations.invalid ||= imported.invalid + readNativeCwd(targetUrl, imported.body, requestLocations) + const promptFiles = readPromptFilePaths(pathname, request.method, imported.body) if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))) { reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" }) return } + if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) { + reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" }) + return + } - const sessionId = getSessionRouteId(targetUrl.pathname) + const sessionId = getSessionRouteId(pathname) if (sessionId) { let session try { @@ -587,7 +628,7 @@ async function proxyWorkspaceRequest(args: { } } - const body = applyDefaultWorkspaceLocation(targetUrl, request.body, request.method, workspace.path, requestLocations.directories.length > 0, Boolean(sessionId)) + const body = applyDefaultWorkspaceLocation(targetUrl, imported.body, request.method, workspace.path, requestLocations.directories.length > 0, Boolean(sessionId)) const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance") @@ -685,7 +726,8 @@ function sanitizeInstanceProxyRequestHeaders( const result: Record = {} for (const [key, value] of Object.entries(headers)) { - if (!blocked.has(key.toLowerCase())) result[key] = value + const normalized = key.toLowerCase() + if (!blocked.has(normalized) && !normalized.startsWith("x-opencode-")) result[key] = value } if (authorization) result.authorization = authorization return result @@ -711,6 +753,10 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean) } +async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) { + return (await Promise.all(paths.map((candidate) => manager.ownsPath(workspaceId, candidate)))).every(Boolean) +} + function applyDefaultWorkspaceLocation( targetUrl: URL, body: unknown, @@ -735,13 +781,141 @@ function applyDefaultWorkspaceLocation( } function getSessionRouteId(pathname: string): string | null { - const match = pathname.match(/^\/api\/session\/([^/]+)(?:\/|$)/) + const match = pathname.match(/^\/api\/(?:experimental\/)?session\/([^/]+)(?:\/|$)/) if (!match || match[1] === "active" || match[1] === "import") return null - try { - return decodeURIComponent(match[1]) - } catch { - return null + return match[1] +} + +function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null { + const suffix = pathSuffix ?? "" + if (/\\|%2f|%5c/i.test(suffix)) return null + const targetUrl = new URL(endpoint) + const origin = targetUrl.origin + targetUrl.pathname = normalizeInstanceSuffix(suffix).split("/").map(encodeURIComponent).join("/") + targetUrl.search = "" + targetUrl.hash = "" + return targetUrl.origin === origin ? targetUrl : null +} + +function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { + const route = pathname.replace(/\/+$/, "") + const allowed: Array<[string, RegExp]> = [ + ["GET", /^\/api\/(?:agent|command|config|integration|mcp|model|provider)$/], + ["GET", /^\/api\/(?:permission|question)\/request$/], + ["GET", /^\/api\/project\/current$/], + ["GET", /^\/api\/vcs\/status$/], + ["GET", /^\/api\/fs\/(?:list|read\/.+)$/], + ["POST", /^\/api\/(?:pty|shell)$/], + ["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/], + ["DELETE", /^\/api\/credential\/[^/]+$/], + ["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/], + ["GET", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/], + ["DELETE", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/], + ["POST", /^\/api\/integration\/[^/]+\/connect\/oauth\/[^/]+\/complete$/], + ["GET", /^\/api\/session(?:\/active)?$/], + ["POST", /^\/api\/session(?:\/import)?$/], + ["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/], + ["DELETE", /^\/api\/session\/[^/]+$/], + ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|fork)$/], + ["POST", /^\/api\/session\/[^/]+\/revert\/stage$/], + ["PUT", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/], + ["DELETE", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/], + ["POST", /^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/], + ["POST", /^\/api\/session\/[^/]+\/question\/[^/]+\/(?:reply|reject)$/], + ["GET", /^\/api\/experimental\/session\/[^/]+\/log$/], + ] + return allowed.some(([allowedMethod, pattern]) => method === allowedMethod && pattern.test(route)) +} + +function readPromptFilePaths(pathname: string, method: string, body: unknown) { + const result = { paths: [] as string[], invalid: false } + if (method !== "POST" || !/^\/api\/session\/[^/]+\/(?:prompt|command)\/?$/.test(pathname)) return result + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return result + const files = (body as Record).files + if (files === undefined) return result + if (!Array.isArray(files)) return { paths: [], invalid: true } + + for (const file of files) { + if (!file || typeof file !== "object" || Array.isArray(file) || Buffer.isBuffer(file)) { + result.invalid = true + continue + } + const uri = (file as Record).uri + if (typeof uri !== "string" || !uri.trim()) { + result.invalid = true + continue + } + const parsed = parsePromptFileUri(uri) + if (parsed.invalid) result.invalid = true + else if (parsed.path) result.paths.push(parsed.path) } + return result +} + +function parsePromptFileUri(value: string): { path?: string; invalid: boolean } { + if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return { path: value, invalid: value.includes("\0") } + let uri: URL + try { + uri = new URL(value) + } catch { + return { invalid: true } + } + if (["data:", "http:", "https:"].includes(uri.protocol)) return { invalid: false } + if (uri.protocol !== "file:" || (uri.hostname && uri.hostname !== "localhost") || uri.search || uri.hash || /%2f|%5c/i.test(uri.pathname)) { + return { invalid: true } + } + try { + const decoded = decodeURIComponent(uri.pathname) + const localPath = /^\/[A-Za-z]:\//.test(decoded) ? decoded.slice(1) : decoded + return { path: localPath, invalid: !localPath || localPath.includes("\0") } + } catch { + return { invalid: true } + } +} + +function prepareSessionImport(pathname: string, method: string, body: unknown, directory: string) { + const result = { body, directories: [] as string[], invalid: false } + if (pathname !== "/api/session/import" || method !== "POST") return result + if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) { + result.invalid = true + return result + } + + const input = body as Record + const addLocation = (owner: Record, key: string) => { + const value = owner[key] + if (value === null || value === undefined) { + owner[key] = { directory } + result.directories.push(directory) + return + } + if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) { + result.invalid = true + return + } + const location = value as Record + if (location.directory === null || location.directory === undefined) location.directory = directory + if (typeof location.directory === "string" && location.directory.trim()) result.directories.push(location.directory) + else result.invalid = true + } + + addLocation(input, "location") + if (input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info)) { + addLocation(input.info as Record, "location") + } + + if (Array.isArray(input.messages)) { + for (const value of input.messages) { + if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) continue + const message = value as Record + if (message.type !== "location-switched") continue + addLocation(message, "location") + if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) { + addLocation(message.previous as Record, "location") + } + } + } + return result } function normalizeInstanceSuffix(pathSuffix: string | undefined) { diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index 9ac7ffa2..7e2a1641 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -56,8 +56,8 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(buildWindowsSpawnSpec("powershell.exe", []).processKind, "windows-wrapper") }) - it("conservatively classifies bare commands as wrappers", () => { - assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper") + it("resolves an installed bare npm command to its packaged executable", () => { + assert.equal(buildWindowsSpawnSpec("opencode2", []).processKind, "windows-direct") }) it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", { skip: process.platform !== "win32" }, () => { @@ -233,22 +233,23 @@ describe("buildServiceLaunchSpec", () => { ) }) - it("uses a Node trampoline for the verbatim cmd.exe batch command", () => { + it("passes through a non-npm cmd wrapper with its required verbatim arguments", () => { const launch = buildServiceLaunchSpec(String.raw`C:\Program Files\OpenCode\opencode.cmd`, ["serve", "--service"], { platform: "win32", env: { ComSpec: "test-cmd.exe" }, }) - assert.equal(launch.command[0], process.execPath) - assert.equal(launch.command[1], "-e") - assert.equal(launch.command[3], "test-cmd.exe") - assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), [ + assert.equal(launch.command[0], "test-cmd.exe") + assert.deepEqual(launch.command.slice(1), [ "/d", "/s", "/c", String.raw`""C:\Program Files\OpenCode\opencode.cmd" serve --service"`, ]) - assert.equal(launch.command[5], "") + assert.equal(launch.windowsVerbatimArguments, true) + assert.equal(launch.nativePid, false) + assert.equal(buildServiceLaunchSpec("custom.bat", ["serve"], { platform: "win32" }).nativePid, false) + assert.equal(buildServiceLaunchSpec("custom.ps1", ["serve"], { platform: "win32" }).nativePid, false) }) - it("records a direct service contender PID", () => { + it("launches a direct service executable without a wrapper", () => { const launch = buildServiceLaunchSpec("opencode.exe", ["serve", "--service"], { platform: "win32", contenderFile: String.raw`C:\Temp\codenomad-contenders.txt`, @@ -256,8 +257,10 @@ describe("buildServiceLaunchSpec", () => { assert.equal(launch.command[0], process.execPath) assert.equal(launch.command[3], "opencode.exe") + assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), ["serve", "--service"]) assert.equal(launch.command[5], String.raw`C:\Temp\codenomad-contenders.txt`) - assert.equal(launch.command[6], "false") + assert.equal(launch.nativePid, true) + assert.equal(launch.launcherRecordsPid, true) }) it("translates shared Windows state and contender files for WSL", () => { @@ -273,9 +276,46 @@ describe("buildServiceLaunchSpec", () => { ) assert.equal(launch.command[0], "wsl.exe") - assert.match(launch.command[6] ?? "", /wslpath -au/) - assert.equal(launch.command[8], contenderFile) + const wslArgs = launch.command.slice(1) + assert.match(wslArgs[5] ?? "", /wslpath -au/) + assert.equal(wslArgs[7], contenderFile) assert.equal(launch.env?.WSLENV, "XDG_STATE_HOME/p") + assert.equal(launch.nativePid, false) + assert.equal(launch.wslDistro, "Ubuntu") + }) + + it("passes configured service variables only to the launched child", () => { + const launch = buildServiceLaunchSpec("opencode", ["serve", "--service"], { + platform: "linux", + env: { ...process.env, XDG_STATE_HOME: "/private/state", SERVICE_ONLY: "yes" }, + propagateEnvKeys: ["XDG_STATE_HOME", "SERVICE_ONLY"], + }) + + assert.deepEqual(launch.command, ["opencode", "serve", "--service"]) + assert.equal(launch.env?.XDG_STATE_HOME, "/private/state") + assert.equal(launch.env?.SERVICE_ONLY, "yes") + }) + + it("resolves the standard Windows npm opencode2 shim to its packaged executable", { skip: process.platform !== "win32" }, () => { + const root = mkdtempSync(path.join(tmpdir(), "codenomad-npm-shim-")) + const executable = path.join(root, "node_modules", "@opencode-ai", "cli", "bin", "opencode2.exe") + mkdirSync(path.dirname(executable), { recursive: true }) + writeFileSync(executable, "") + writeFileSync(path.join(root, "opencode2.cmd"), '@ECHO off\r\n"%~dp0\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n') + try { + const launch = buildServiceLaunchSpec("opencode2", ["serve", "--service"], { + platform: "win32", + cwd: root, + env: { PATH: root, PATHEXT: ".CMD" }, + contenderFile: path.join(root, "contenders.txt"), + }) + assert.equal(launch.command[0], process.execPath) + assert.equal(launch.command[3]?.toLowerCase(), executable.toLowerCase()) + assert.equal(launch.nativePid, true) + assert.equal(launch.launcherRecordsPid, true) + } finally { + rmSync(root, { recursive: true, force: true }) + } }) }) diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index 882ba9d6..61792ad4 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -105,4 +105,27 @@ describe("InstanceEventBridge", () => { bridge.shutdown() } }) + + it("fans an event out to every logical workspace for the same directory", async () => { + const manager = { + list: () => [{ id: "first", path: "/repo" }, { id: "second", path: "/repo" }], + ownsDirectory: async (_workspaceId: string, directory: string) => directory === "/repo", + subscribeToSharedService: async (signal?: AbortSignal) => (async function* () { + yield { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo" }, data: { id: "p1" } } as OpenCodeEvent + await new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true })) + })(), + } as unknown as WorkspaceManager + const bus = new EventBus() + const received: string[] = [] + bus.on("instance.event", (event) => received.push(event.instanceId)) + + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 2) + assert.deepEqual(received, ["first", "second"]) + } finally { + bridge.shutdown() + } + }) }) diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index f9f68046..902c7942 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -17,7 +17,7 @@ export class InstanceEventBridge { private readonly controller = new AbortController() private status: InstanceStreamStatus = "connecting" private task?: Promise - private readonly directoryOwners = new Map }>() + private readonly directoryOwners = new Map }>() private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { this.directoryOwners.clear() if (!this.task) this.task = this.run() @@ -74,33 +74,35 @@ export class InstanceEventBridge { const directory = event.location?.directory if (!directory) return - const instanceId = await this.resolveDirectoryOwner(directory) - if (!instanceId) return + const instanceIds = await this.resolveDirectoryOwners(directory) + if (instanceIds.length === 0) return // 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 }) + for (const instanceId of instanceIds) { + this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent }) + } } - private resolveDirectoryOwner(directory: string): Promise { + private resolveDirectoryOwners(directory: string): Promise { const now = Date.now() const cached = this.directoryOwners.get(directory) - if (cached && cached.expiresAt > now) return cached.owner + if (cached && cached.expiresAt > now) return cached.owners const workspaces = this.options.workspaceManager.list() - const owner = Promise.all(workspaces.map((workspace) => ( + const owners = Promise.all(workspaces.map((workspace) => ( this.options.workspaceManager.ownsDirectory(workspace.id, directory) ))) - .then((ownership) => workspaces.find((_, index) => ownership[index])?.id) + .then((ownership) => workspaces.filter((_, index) => ownership[index]).map((workspace) => workspace.id)) .catch((error) => { this.options.logger.warn({ err: error, directory }, "Failed to resolve instance event directory owner") - return undefined + return [] }) - this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owner }) - return owner + this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owners }) + return owners } private compatibilityProperties(event: OpenCodeEvent): Record { diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 720e90d4..610d8bad 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -29,6 +29,7 @@ class ControlledSharedService { validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = [] evictions: LocationRef[] = [] failEvictions = 0 + shutdownGate?: ReturnType> async endpoint(options?: OpenCodeEnsureOptions) { this.assertCommand(options) @@ -74,19 +75,22 @@ class ControlledSharedService { this.evictions.push(location) } - async shutdown() {} + async shutdown() { + await this.shutdownGate?.promise + } 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$`)) + const stateRoot = path.join(os.homedir(), ".codenomad", "state", "opencode-v2") + assert.equal(options?.file, path.join(stateRoot, "opencode", "service.json")) + assert.match(options?.contenderFile ?? "", new RegExp(`contenders-${process.pid}-.*\\.txt$`)) + assert.match(options?.leaseFile ?? "", new RegExp(`leases[/\\\\]process-${process.pid}-.*\\.json$`)) assert.equal(options?.command?.[0], process.execPath) assert.equal(options?.command?.[1], "-e") + assert.equal(options?.command?.[3], process.execPath) assert.equal(options?.command?.[4], JSON.stringify(["serve", "--service"])) + assert.equal(options?.command?.[5], options?.contenderFile) + assert.equal(options?.launcherRecordsPid, true) + assert.equal(options?.environment?.XDG_STATE_HOME, stateRoot) } } @@ -193,4 +197,17 @@ describe("workspace manager shared service lifecycle", () => { await harness.manager.delete(workspace.id) assert.equal(harness.manager.get(workspace.id), undefined) }) + + it("bounds a stalled shared service shutdown", async () => { + const service = new ControlledSharedService() + service.shutdownGate = deferred() + const { manager } = createHarness(service) + ;(manager as any).options.shutdownTimeoutMs = 10 + + await assert.rejects(manager.shutdown(), (error: unknown) => { + assert.ok(error instanceof WorkspaceShutdownError) + assert.match(String(error.errors[0]), /did not finish within/) + return true + }) + }) }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index f0cd75a5..515658d9 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,8 +1,6 @@ import path from "path" import { spawnSync } from "child_process" import { randomUUID } from "node:crypto" -import { mkdirSync } from "node:fs" -import os from "node:os" import type { Endpoint } from "@opencode-ai/client/service" import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { EventBus } from "../events/bus" @@ -16,14 +14,20 @@ import { Logger } from "../logger" import { resolveWorkspaceIdentity } from "./workspace-identity" import { buildServiceLaunchSpec, parseWslUncPath } from "./spawn" import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" -import { resolveWorktreeSlugForDirectory } from "./worktree-directory" +import { + prepareServiceState, + SERVICE_LEASE_DIRECTORY, + SERVICE_REGISTRATION_FILE, + SERVICE_STATE_ROOT, + SERVICE_STOP_LOCK, +} from "./service-state" +import { isPathOwnedByWorktree, resolveWorktreeSlugForDirectory } from "./worktree-directory" const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 const 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`) +const SERVICE_LEASE_FILE = path.join(SERVICE_LEASE_DIRECTORY, `process-${process.pid}-${randomUUID()}.json`) type ManagerTimeout = ReturnType interface SharedService { @@ -33,7 +37,7 @@ interface SharedService { 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 + shutdown: (options?: { timeoutMs?: number }) => Promise } export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean { @@ -176,6 +180,17 @@ export class WorkspaceManager { })) !== null } + async ownsPath(id: string, candidate: string): Promise { + const workspace = this.get(id) + if (!workspace) return false + return isPathOwnedByWorktree({ + workspaceId: id, + workspacePath: workspace.path, + candidate, + logger: this.options.logger, + }) + } + subscribeToSharedService(signal?: AbortSignal): Promise> { return this.sharedService.subscribe({ signal }) } @@ -377,7 +392,10 @@ export class WorkspaceManager { } }, timeoutMs) try { - return await this.createResolvedWorkspace(record) + const deadline = new Promise((_resolve, reject) => { + state.abortController.signal.addEventListener("abort", () => reject(state.abortController.signal.reason), { once: true }) + }) + return await Promise.race([this.createResolvedWorkspace(record, timeoutMs), deadline]) } finally { if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) } @@ -401,6 +419,7 @@ export class WorkspaceManager { } private async createResolvedWorkspace( record: WorkspaceRecord, + timeoutMs: number, ): Promise { const state = record[WORKSPACE_STATE] const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record @@ -408,8 +427,7 @@ export class WorkspaceManager { 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 }) + prepareServiceState(SERVICE_CONTENDER_FILE) const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], { env: { ...process.env, ...configuredEnvironment }, propagateEnvKeys: Object.keys(configuredEnvironment), @@ -418,10 +436,15 @@ export class WorkspaceManager { const ensureOptions: OpenCodeEnsureOptions = { file: SERVICE_REGISTRATION_FILE, command: launch.command, - environment: { - ...configuredEnvironment, - ...(launch.env?.WSLENV ? { WSLENV: launch.env.WSLENV } : {}), - }, + contenderFile: SERVICE_CONTENDER_FILE, + leaseFile: SERVICE_LEASE_FILE, + lockDirectory: SERVICE_STOP_LOCK, + nativePid: launch.nativePid, + wslDistro: launch.wslDistro, + environment: launch.env, + launcherRecordsPid: launch.launcherRecordsPid, + windowsVerbatimArguments: launch.windowsVerbatimArguments, + timeoutMs, } try { this.throwIfCancelled(record) @@ -557,15 +580,23 @@ export class WorkspaceManager { async shutdown() { this.shuttingDown = true this.options.logger.info("Shutting down all workspaces") + const shutdownTimeoutMs = Math.max(1, this.options.shutdownTimeoutMs ?? 10000) + const deadlineAt = Date.now() + shutdownTimeoutMs const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id)) const results = stopTasks.length - ? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown") + ? await this.withTimeout(Promise.allSettled(stopTasks), shutdownTimeoutMs, "shutdown") : [] const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) if (this.workspaces.size === 0) { this.pendingWorkspaceCreations.clear() this.cancelledCreationRequests.clear() - await this.sharedService.shutdown().catch((error) => stopFailures.push(error)) + const remaining = deadlineAt - Date.now() + if (remaining <= 0) stopFailures.push(new WorkspaceCleanupTimeoutError("shared service shutdown", shutdownTimeoutMs)) + else await this.withTimeout( + this.sharedService.shutdown({ timeoutMs: remaining }), + remaining, + "shared service shutdown", + ).catch((error) => stopFailures.push(error)) } else if (!stopFailures.length) stopFailures.push( new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`), ) diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index ecff8990..498c774d 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict" -import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { describe, it } from "node:test" import type { OpenCodeClient } from "@opencode-ai/client" +import type { Endpoint, Info } from "@opencode-ai/client/service" -import { OpenCodeSharedService } from "./opencode-service" +import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" +import type { ProcessIdentity, ProcessIdentityProbe, ProcessNamespace } from "./process-identity" describe("OpenCodeSharedService", () => { it("lazily ensures one authenticated service for concurrent callers", async () => { @@ -26,7 +28,6 @@ describe("OpenCodeSharedService", () => { 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") @@ -66,7 +67,6 @@ describe("OpenCodeSharedService", () => { discover: async () => undefined, ensure: async () => ({ url: "https://localhost:4321" }), headers: () => undefined, - stop: async () => undefined, makeClient: () => client, }) @@ -89,16 +89,22 @@ describe("OpenCodeSharedService", () => { discover: async () => undefined, ensure: async () => ({ url: "file:///tmp/opencode" }), headers: () => undefined, - stop: async () => undefined, makeClient: () => { throw new Error("client should not be created") }, }) await assert.rejects(invalidEndpoint.endpoint(), /Unsupported OpenCode service protocol/) + const remoteEndpoint = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => ({ url: "http://192.0.2.1:4321" }), + headers: () => undefined, + makeClient: () => { throw new Error("client should not be created") }, + }) + await assert.rejects(remoteEndpoint.endpoint(), /must be loopback/) + const invalidLocation = new OpenCodeSharedService({ discover: async () => undefined, ensure: async () => ({ url: "http://localhost:4321" }), headers: () => undefined, - stop: async () => undefined, makeClient: () => ({ location: { get: async () => ({ directory: "/repo" }) } }) as unknown as OpenCodeClient, }) await assert.rejects(invalidLocation.validateLocation({ directory: "/repo" }), /invalid location/) @@ -114,7 +120,6 @@ describe("OpenCodeSharedService", () => { return { url: "http://localhost:4321" } }, headers: () => undefined, - stop: async () => undefined, makeClient: () => ({} as OpenCodeClient), }) @@ -134,7 +139,6 @@ describe("OpenCodeSharedService", () => { return endpoint }, headers: () => undefined, - stop: async () => undefined, makeClient: () => ({ location: { get: async () => { gets += 1 @@ -149,150 +153,562 @@ describe("OpenCodeSharedService", () => { 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), - }) - + it("persists native PID proof through transfer and shutdown reconstruction", async () => { + const state = await serviceState("codenomad-service-peer-") + let stops = 0 + const owner = createOwnedService(state, async () => { stops += 1; return true }) + const peer = createOwnedService( + state, + async () => { stops += 1; return true }, + false, + (pid) => pid !== state.info.pid, + undefined, + true, + ) try { - 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]) + await owner.endpoint(state.options("owner", true)) + assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, true) + await peer.endpoint(state.options("peer", false)) + await owner.shutdown() + assert.equal(stops, 0) + assert.equal(JSON.parse(await readFile(state.lease("peer"), "utf8")).service.nativePid, true) + assert.equal((await peer.endpoint()).url, state.info.url) + await peer.shutdown() + assert.equal(stops, 1) } finally { - if (previous === undefined) delete process.env.CODENOMAD_SERVICE_TEST - else process.env.CODENOMAD_SERVICE_TEST = previous - await rm(root, { recursive: true, force: true }) + await rm(state.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" } + it("reclaims a bounded stale lifecycle lock after PID reuse", async () => { + const state = await serviceState("codenomad-service-stale-lock-") + const stalePid = 7654321 + await mkdir(state.lockDirectory) + await writeFile(path.join(state.lockDirectory, "owner.json"), JSON.stringify({ + version: 1, + identity: "stale-lock-owner", + pid: stalePid, + processIdentity: processIdentity(stalePid, "previous-process"), + createdAt: 1, + })) + await Promise.all([ + utimes(path.join(state.lockDirectory, "owner.json"), 1, 1), + utimes(state.lockDirectory, 1, 1), + ]) + const service = createOwnedService(state, async () => true, true, () => true) + try { + await service.endpoint({ ...state.options("owner", true), staleLockMs: 1 }) + await assert.rejects(access(state.lockDirectory)) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("ages out ownerless, malformed, and legacy lifecycle locks but preserves fresh locks", async () => { + for (const [name, owner] of [ + ["ownerless", undefined], + ["malformed", "{"], + ["legacy", JSON.stringify({ version: 1, identity: "legacy", pid: 1234, createdAt: 1 })], + ] as const) { + const state = await serviceState(`codenomad-service-${name}-lock-`) + await mkdir(state.lockDirectory) + if (owner) { + const ownerFile = path.join(state.lockDirectory, "owner.json") + await writeFile(ownerFile, owner) + await utimes(ownerFile, 1, 1) + } + await utimes(state.lockDirectory, 1, 1) + const service = createOwnedService(state, async () => true) + try { + await service.endpoint({ ...state.options("owner", true), staleLockMs: 10 }) + await assert.rejects(access(state.lockDirectory)) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + } + + const fresh = await serviceState("codenomad-service-fresh-lock-") + await mkdir(fresh.lockDirectory) + const service = createOwnedService(fresh, async () => true) + try { + await assert.rejects(service.endpoint({ ...fresh.options("owner", true), timeoutMs: 10, staleLockMs: 60_000 }), /lifecycle lock/) + await access(fresh.lockDirectory) + } finally { + await rm(fresh.root, { recursive: true, force: true }) + } + }) + + it("prunes an identity-checked lease after PID reuse", async () => { + const state = await serviceState("codenomad-service-stale-lease-") + const stalePid = 7654321 + const staleLease = path.join(state.leases, "stale.json") + await writeFile(staleLease, JSON.stringify({ + version: 1, + identity: "stale-peer", + pid: stalePid, + processIdentity: processIdentity(stalePid, "previous-process"), + createdAt: 1, + updatedAt: 1, + state: "active", + })) + let stops = 0 + const service = createOwnedService(state, async () => { stops += 1; return true }, true, () => true) + try { + await service.endpoint(state.options("owner", true)) + await service.shutdown() + assert.equal(stops, 1) + await assert.rejects(access(staleLease)) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("quarantines stale registration after deterministic PID reuse without signaling it", async () => { + const state = await serviceState("codenomad-service-stale-registration-") + const reusedPid = state.info.pid + await writeFile(state.lease("dead-owner"), JSON.stringify({ + version: 1, + identity: "dead-owner", + pid: 7654321, + processIdentity: processIdentity(7654321, "dead-codenomad"), + createdAt: 1, + updatedAt: 1, + state: "active", + service: { + info: state.info, + endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }, + registrationFile: state.file, + nativePid: true, + processIdentity: processIdentity(reusedPid, "old-service-process"), + }, + })) + const service = new OpenCodeSharedService({ + discover: async () => undefined, + headers: () => undefined, + isProcessAlive: () => true, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity( + pid, + pid === reusedPid ? "reused-service-process" : `identity-${pid}`, + namespace, + ), + makeClient: () => ({} as OpenCodeClient), + }) + try { + await assert.rejects(service.endpoint({ + ...state.options("successor", false), + command: [process.execPath, "-e", "process.exit(0)"], + timeoutMs: 50, + }), /exited before registration|timed out/) + await assert.rejects(access(state.file)) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("recovers a registration written after a predecessor launch intent", async () => { + const state = await serviceState("codenomad-service-crash-window-") + const deadPid = 7654321 + const launchCreatedAt = Date.now() - 1_000 + await writeFile(state.lease("dead-owner"), JSON.stringify({ + version: 1, + identity: "dead-owner", + pid: deadPid, + processIdentity: processIdentity(deadPid, "dead-codenomad"), + createdAt: launchCreatedAt, + updatedAt: launchCreatedAt, + state: "active", + launch: { + identity: "launch-before-crash", + createdAt: launchCreatedAt, + nativePid: true, + contenderFile: state.contenders, + }, + })) + const service = new OpenCodeSharedService({ + discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), + headers: () => undefined, + isProcessAlive: (pid) => pid !== deadPid, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + makeClient: () => ({} as OpenCodeClient), + }) + try { + await service.endpoint(state.options("successor", false)) + const lease = JSON.parse(await readFile(state.lease("successor"), "utf8")) + assert.deepEqual(lease.service.processIdentity, processIdentity(state.info.pid)) + assert.equal(lease.service.info.pid, state.info.pid) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("prunes old invalid lease artifacts while preserving fresh writes", async () => { + const state = await serviceState("codenomad-service-invalid-leases-") + const oldLease = state.lease("invalid") + const legacyLease = state.lease("legacy") + const oldTemporary = `${state.lease("peer")}.peer-id.tmp` + const freshLease = state.lease("fresh") + await writeFile(oldLease, "{") + await writeFile(legacyLease, JSON.stringify({ + version: 1, + identity: "legacy", + pid: 1234, + createdAt: 1, + updatedAt: 1, + state: "active", + })) + await writeFile(oldTemporary, "partial") + await writeFile(freshLease, "{") + await Promise.all([utimes(oldLease, 1, 1), utimes(legacyLease, 1, 1), utimes(oldTemporary, 1, 1)]) + const service = createOwnedService(state, async () => true) + try { + await service.endpoint({ ...state.options("owner", true), staleLockMs: 60_000 }) + await assert.rejects(service.shutdown({ timeoutMs: 20 }), /invalid identity metadata/) + await Promise.all([ + assert.rejects(access(oldLease)), + assert.rejects(access(legacyLease)), + assert.rejects(access(oldTemporary)), + ]) + await access(freshLease) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("inherits proven ownership from a dead owner lease", async () => { + const state = await serviceState("codenomad-service-dead-owner-") + const deadPid = 7654321 + await writeFile(state.lease("dead-owner"), JSON.stringify({ + version: 1, + identity: "dead-owner", + pid: deadPid, + processIdentity: processIdentity(deadPid), + createdAt: 1, + updatedAt: 1, + state: "active", + service: { + info: state.info, + endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }, + registrationFile: state.file, + nativePid: true, + }, + })) + let stops = 0 + const peer = createOwnedService(state, async () => { stops += 1; return true }, false, (pid) => pid !== deadPid) + try { + await peer.endpoint(state.options("peer", false)) + await peer.shutdown() + assert.equal(stops, 1) + await assert.rejects(access(state.lease("dead-owner"))) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("replaces stale in-memory ownership with a newer transferred service proof", async () => { + const state = await serviceState("codenomad-service-replacement-owner-") + const requested: string[] = [] + const staleOwner = createOwnedService(state, async (info) => { requested.push(info.id!); return true }) + const replacementOwner = createOwnedService(state, async () => { throw new Error("peer must not stop while an owner remains") }) + try { + await staleOwner.endpoint(state.options("stale-owner", true)) + Object.assign(state.info, { id: "instance-2", pid: 5678, url: "http://127.0.0.1:5678", password: "replacement-secret" }) + await writeFile(state.file, JSON.stringify(state.info)) + await writeFile(state.contenders, `${state.info.pid}\n`) + await replacementOwner.endpoint(state.options("replacement-owner", true)) + + await replacementOwner.shutdown() + assert.deepEqual(requested, []) + await staleOwner.shutdown() + assert.deepEqual(requested, ["instance-2"]) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("stops the proven endpoint without following a registration swap", async () => { + const state = await serviceState("codenomad-service-swap-") + const replacement = { ...state.info, id: "replacement", pid: 9999, url: "http://127.0.0.1:9999" } + let requestedId: string | undefined + const service = createOwnedService(state, async (info: Info, endpoint: Endpoint) => { + await writeFile(state.file, JSON.stringify(replacement)) + requestedId = info.id + assert.equal(endpoint.url, state.info.url) + return true + }) + try { + await service.endpoint(state.options("owner", true)) + await service.shutdown() + assert.equal(requestedId, state.info.id) + assert.deepEqual(JSON.parse(await readFile(state.file, "utf8")), replacement) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("never owns a symlinked registration", { skip: process.platform === "win32" }, async () => { + const state = await serviceState("codenomad-service-link-") + const target = path.join(state.root, "target.json") + await writeFile(target, JSON.stringify(state.info)) + await rm(state.file) + await symlink(target, state.file) + let stops = 0 + const service = createOwnedService(state, async () => { stops += 1; return true }) + try { + await service.endpoint(state.options("owner", true)) + await service.shutdown() + assert.equal(stops, 0) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("rejects a wrapper launch without a proven service PID before connecting", async () => { + const state = await serviceState("codenomad-service-wrapper-") + let launches = 0 + let discoveries = 0 let stops = 0 const service = new OpenCodeSharedService({ - discover: async () => ({ 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 } } - }, + discover: async () => { discoveries += 1; return undefined }, + ensure: async () => { launches += 1; return { url: state.info.url } }, headers: () => undefined, - stop: async () => { stops += 1 }, + requestStop: async () => { stops += 1; return true }, makeClient: () => ({} as OpenCodeClient), }) - try { - await service.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: contenders } }) - await rm(file) + await assert.rejects(service.endpoint({ + ...state.options("wrapper", true), + contenderFile: undefined, + nativePid: false, + }), /cannot prove the service PID/) await service.shutdown() + assert.equal(launches, 0) + assert.equal(discoveries, 0) assert.equal(stops, 0) - - 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 }) + await rm(state.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 + it("bounds a stalled service stop", async () => { + const state = await serviceState("codenomad-service-stop-timeout-") let stops = 0 - const createService = (contenderFile: string, pid: number) => new OpenCodeSharedService({ - discover: async () => ready - ? { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } } - : undefined, - ensure: async (options) => { - options?.onStart?.("missing") - await writeFile(contenderFile, `${pid}\n`) - await starts.promise - return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } } - }, - headers: () => undefined, - stop: async () => { stops += 1 }, - makeClient: () => ({} as OpenCodeClient), - }) - const first = createService(firstContenders, info.pid) - const second = createService(secondContenders, 3333) - + const service = createOwnedService( + state, + async () => { stops += 1; return new Promise(() => undefined) }, + true, + undefined, + async () => false, + ) try { - const connections = [ - first.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: firstContenders } }), - second.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: secondContenders } }), - ] - await Promise.all([readWhenPresent(firstContenders), readWhenPresent(secondContenders)]) - await writeFile(file, JSON.stringify(info)) - ready = true - starts.resolve() - await Promise.all(connections) - - await Promise.all([first.shutdown(), second.shutdown()]) + await service.endpoint(state.options("owner", true)) + await assert.rejects(service.shutdown({ timeoutMs: 10 }), /stop timed out/) + const lease = JSON.parse(await readFile(state.lease("owner"), "utf8")) + assert.equal(lease.state, "stopping") + await assert.rejects(service.shutdown({ timeoutMs: 10 }), /uncertain outcome/) assert.equal(stops, 1) } finally { - await rm(root, { recursive: true, force: true }) + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("retains ownership until an accepted stop actually completes", async () => { + const state = await serviceState("codenomad-service-stop-completion-") + let finishStop!: () => void + let markAccepted!: () => void + const accepted = new Promise((resolve) => { markAccepted = resolve }) + const completion = new Promise((resolve) => { finishStop = () => resolve(true) }) + const service = createOwnedService(state, async () => { markAccepted(); return true }, true, undefined, async () => completion) + try { + await service.endpoint(state.options("owner", true)) + const shutdown = service.shutdown({ timeoutMs: 100 }) + await accepted + await access(state.lease("owner")) + assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") + finishStop() + await shutdown + await assert.rejects(access(state.lease("owner"))) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("checks WSL stop completion in the distro despite a coincidental live Windows PID", async () => { + const state = await serviceState("codenomad-service-wsl-stop-") + let healthChecks = 0 + let wslPidExists = true + const server = (await import("node:http")).createServer((_request, response) => { + healthChecks += 1 + if (healthChecks <= 2) { + response.destroy() + return + } + response.setHeader("content-type", "application/json") + response.end(JSON.stringify({ healthy: true, version: "test", pid: state.info.pid })) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + assert.ok(address && typeof address === "object") + state.info.url = `http://127.0.0.1:${address.port}` + await writeFile(state.file, JSON.stringify(state.info)) + const wslNamespace = { kind: "wsl", distro: "Ubuntu" } as const + const service = createOwnedService( + state, + async () => true, + true, + () => true, + undefined, + true, + async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + async (pid, _timeoutMs, namespace) => wslPidExists + ? { status: "found", identity: processIdentity(pid, undefined, namespace) } + : { status: "missing" }, + ) + try { + await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" }) + assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, false) + assert.deepEqual(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.processIdentity.namespace, wslNamespace) + await assert.rejects(service.shutdown({ timeoutMs: 500 }), /did not exit|completion timed out/) + assert.ok(healthChecks > 2) + assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") + wslPidExists = false + await service.shutdown({ timeoutMs: 100 }) + await assert.rejects(access(state.lease("owner"))) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("reports a failed stop and keeps an active retryable lease", async () => { + const state = await serviceState("codenomad-service-stop-failure-") + let stops = 0 + const service = createOwnedService(state, async () => { stops += 1; return stops > 1 }) + try { + await service.endpoint(state.options("owner", true)) + await assert.rejects(service.shutdown(), /failed the stop request/) + const lease = JSON.parse(await readFile(state.lease("owner"), "utf8")) + assert.equal(lease.state, "active") + await service.shutdown() + assert.equal(stops, 2) + await assert.rejects(access(state.lease("owner"))) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + + it("bounds a stalled ensure", async () => { + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: async () => new Promise(() => undefined), + headers: () => undefined, + makeClient: () => ({} as OpenCodeClient), + }) + await assert.rejects(service.endpoint({ timeoutMs: 10 }), /timed out after 10ms/) + }) + + it("reconciles a late uncancellable ensure during shutdown", async () => { + const state = await serviceState("codenomad-service-late-ensure-") + let finishEnsure!: () => void + let finishStop!: () => void + let markStopStarted!: () => void + const stopStarted = new Promise((resolve) => { markStopStarted = resolve }) + const stopCompletion = new Promise((resolve) => { finishStop = () => resolve(true) }) + let stops = 0 + const service = new OpenCodeSharedService({ + discover: async () => undefined, + ensure: (options) => new Promise((resolve) => { + options?.onStart?.("missing") + finishEnsure = () => resolve({ + url: state.info.url, + auth: { type: "basic", username: "opencode", password: state.info.password }, + }) + }), + headers: () => undefined, + requestStop: async () => { stops += 1; markStopStarted(); return true }, + waitForStop: async () => stopCompletion, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + makeClient: () => ({} as OpenCodeClient), + }) + try { + await assert.rejects(service.endpoint({ ...state.options("owner", true), timeoutMs: 10 }), /ensure timed out/) + await access(state.lease("owner")) + await assert.rejects(service.shutdown({ timeoutMs: 10 }), /launch reconciliation timed out/) + finishEnsure() + await stopStarted + const reconciliation = service.shutdown({ timeoutMs: 100 }) + finishStop() + await reconciliation + assert.equal(stops, 1) + await assert.rejects(access(state.lease("owner"))) + } finally { + await rm(state.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 serviceState(prefix: string) { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)) + const file = path.join(root, "service.json") + const contenders = path.join(root, "contenders.txt") + const leases = path.join(root, "leases") + const lockDirectory = path.join(root, "stop.lock") + const info = { id: "instance-1", url: "http://127.0.0.1:4321", pid: 1234, password: "secret" } + await mkdir(leases) + await writeFile(file, JSON.stringify(info)) + await writeFile(contenders, `${info.pid}\n`) + return { + root, file, contenders, leases, lockDirectory, info, + lease(name: string) { return path.join(leases, `${name}.json`) }, + options(name: string, started: boolean): OpenCodeEnsureOptions { + return { + file, + contenderFile: contenders, + leaseFile: path.join(leases, `${name}.json`), + lockDirectory, + onStart: started ? () => undefined : undefined, + } + }, + } } -async function readWhenPresent(file: string): Promise { - 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}`) +function createOwnedService( + state: Awaited>, + requestStop: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise, + announceStart = true, + isProcessAlive?: (pid: number) => boolean, + waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise, + useDefaultWaitForStop = false, + getProcessIdentity: ( + pid: number, + timeoutMs: number, + namespace?: ProcessNamespace, + ) => Promise = async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + probeProcessIdentity?: ( + pid: number, + timeoutMs: number, + namespace?: ProcessNamespace, + ) => Promise, +) { + return new OpenCodeSharedService({ + discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), + ensure: async (options) => { + if (announceStart) options?.onStart?.("missing") + return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } } + }, + headers: () => undefined, + requestStop, + waitForStop: useDefaultWaitForStop ? undefined : waitForStop ?? (async () => true), + isProcessAlive, + getProcessIdentity, + probeProcessIdentity, + makeClient: () => ({} as OpenCodeClient), + }) +} + +function processIdentity( + pid: number, + start = `identity-${pid}`, + namespace: ProcessNamespace = { kind: "host" }, +): ProcessIdentity { + return { namespace, pid, start } } diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 760cc31c..f4e2ff50 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -6,10 +6,78 @@ import { 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" +import { spawn, type ChildProcess } from "node:child_process" +import { randomUUID } from "node:crypto" +import type { Stats } from "node:fs" +import { appendFile, lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises" +import path from "node:path" +import { + getProcessStartIdentity, + probeProcessStartIdentity, + type ProcessIdentity, + type ProcessNamespace, +} from "./process-identity" +import { assertLoopbackServiceUrl, readSecureServiceInfo } from "./service-state" type RequestOptions = { signal?: AbortSignal } -export type OpenCodeEnsureOptions = EnsureOptions & { environment?: NodeJS.ProcessEnv } +type ServiceProof = { + info: Info + endpoint: Endpoint + registrationFile: string + nativePid?: boolean + processIdentity?: ProcessIdentity +} +interface LaunchIntent { + identity: string + createdAt: number + nativePid: boolean + contenderFile?: string + servicePid?: number +} +type LeaseState = "active" | "stopping" +interface LeaseMetadata { + version: 1 + identity: string + pid: number + processIdentity: ProcessIdentity + createdAt: number + updatedAt: number + state: LeaseState + contenderFile?: string + launch?: LaunchIntent + service?: ServiceProof +} +interface LockOwner { + version: 1 + identity: string + pid: number + processIdentity: ProcessIdentity + createdAt: number +} +interface LeaseHandle { + file: string + lockDirectory: string + contenderFile?: string + identity: string + staleLockMs: number + lifecycleTimeoutMs: number +} + +const DEFAULT_STALE_LOCK_MS = 30_000 +const LOCK_OWNER_FILE = "owner.json" + +export type OpenCodeEnsureOptions = EnsureOptions & { + contenderFile?: string + leaseFile?: string + lockDirectory?: string + timeoutMs?: number + staleLockMs?: number + nativePid?: boolean + wslDistro?: string + environment?: NodeJS.ProcessEnv + launcherRecordsPid?: boolean + windowsVerbatimArguments?: boolean +} interface ServiceConnection { endpoint: Endpoint @@ -17,27 +85,42 @@ interface ServiceConnection { stopOptions: StopOptions } +interface OwnedService { + stopOptions: StopOptions + info: Info + endpoint: Endpoint + nativePid: boolean + processIdentity?: ProcessIdentity +} + export interface OpenCodeSharedServiceDependencies { discover: typeof Service.discover - ensure: typeof Service.ensure + ensure?: typeof Service.ensure headers: typeof Service.headers - stop: typeof Service.stop makeClient: typeof OpenCode.make + requestStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise + waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise + isProcessAlive?: (pid: number) => boolean + getProcessIdentity?: (pid: number, timeoutMs: number, namespace?: ProcessNamespace) => Promise + probeProcessIdentity?: typeof probeProcessStartIdentity } +type ProcessOwnerState = "live" | "stale" | "unknown" + export class OpenCodeSharedService { private connection?: Promise private connected?: ServiceConnection private healthCheck?: Promise + private pendingLaunch?: Promise private ensureOptions?: OpenCodeEnsureOptions - private owned?: { stopOptions: StopOptions; info: Info } + private owned?: OwnedService + private lease?: LeaseHandle private shutdownAttempt?: Promise + private shutdownRequested = false constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { discover: Service.discover, - ensure: Service.ensure, headers: Service.headers, - stop: Service.stop, makeClient: OpenCode.make, }) {} @@ -95,34 +178,169 @@ export class OpenCodeSharedService { }, requestOptions)) } - shutdown(): Promise { + shutdown(options: { timeoutMs?: number } = {}): Promise { + this.shutdownRequested = true if (this.shutdownAttempt) return this.shutdownAttempt - const attempt = this.stopOwnedService().finally(() => { + const timeoutMs = Math.max(1, options.timeoutMs ?? 10_000) + const attempt = this.stopOwnedService(timeoutMs).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 + private async stopOwnedService(timeoutMs: number): Promise { + const deadlineAt = Date.now() + timeoutMs + const pendingLaunch = this.pendingLaunch + if (pendingLaunch) { + try { + await this.withDeadline(pendingLaunch, Math.max(1, deadlineAt - Date.now()), "OpenCode service launch reconciliation") + } catch (error) { + if (this.pendingLaunch === pendingLaunch) throw error + } + } + const lease = this.lease + if (!lease) return + await this.withLifecycleLock(lease, deadlineAt, async () => { + const ownMetadata = await this.readLease(lease.file) + if (!ownMetadata || ownMetadata.identity !== lease.identity) { + if (this.lease === lease) this.lease = undefined + throw new Error("OpenCode service lease identity changed") + } + const leaseDirectory = path.dirname(lease.file) + const initialPeers = await this.readPeerLeases(leaseDirectory, lease.file) + const inheritedProof = await this.deadPeerProof(initialPeers, this.ensureOptions?.file, deadlineAt) + await this.pruneLeaseArtifacts(leaseDirectory, lease.file, lease.staleLockMs, deadlineAt) + const peers = await this.readPeerLeases(leaseDirectory, lease.file) + const peerEntries = (await readdir(leaseDirectory)) + .filter((entry) => entry.endsWith(".json") && path.join(leaseDirectory, entry) !== lease.file) + if (peers.length !== peerEntries.length) { + throw new Error("An OpenCode service peer lease has invalid identity metadata; retaining lease") + } + const leasedProof = ownMetadata.service ?? inheritedProof + if (leasedProof && leasedProof.registrationFile !== this.ensureOptions?.file) { + throw new Error("OpenCode service lease proof references an unexpected registration; retaining lease") + } + const owned = leasedProof ? { + stopOptions: { file: leasedProof.registrationFile }, + info: leasedProof.info, + endpoint: leasedProof.endpoint, + nativePid: leasedProof.nativePid === true, + processIdentity: leasedProof.processIdentity, + } : this.owned + if (leasedProof) this.owned = owned + if (!owned) { + await this.releaseLease(lease) + return + } + if (ownMetadata.state === "stopping") { + const stopped = await this.waitForOwnedStop(owned, deadlineAt) + if (!stopped) throw new Error("A previous OpenCode service stop has an uncertain outcome; retaining lease") + await this.finishOwnedStop(lease, owned) + return + } + const peerStates = await Promise.all(peers.map(async (peer) => ({ + peer, + state: await this.processOwnerState(peer.metadata, deadlineAt), + }))) + if (peerStates.some(({ state }) => state === "unknown")) { + throw new Error("An OpenCode service peer identity could not be verified; retaining lease") + } + const livePeers = peerStates.filter(({ state }) => state === "live").map(({ peer }) => peer) + if (livePeers.length) { + if (!livePeers.some((peer) => peer.metadata.service && this.sameInfo(peer.metadata.service.info, owned.info))) { + const elected = livePeers.sort((left, right) => left.metadata.identity.localeCompare(right.metadata.identity))[0] + await this.writeLease(elected.file, { + ...elected.metadata, + service: this.serviceProof(owned), + updatedAt: Date.now(), + }) + } + await this.releaseLease(lease) + if (this.owned && this.sameInfo(this.owned.info, owned.info)) this.owned = undefined + return + } + const current = await readSecureServiceInfo(owned.stopOptions.file) + // ponytail: uncertain registration or an unsupported stop API leaks the service; no PID is ever signaled. + if (!current || !this.sameInfo(current, owned.info)) { + throw new Error("OpenCode service ownership can no longer be proven; retaining lease") + } + if (!owned.processIdentity || !this.sameProcessIdentity( + await this.currentProcessIdentity(owned.info.pid, deadlineAt, owned.processIdentity.namespace), + owned.processIdentity, + )) { + throw new Error("OpenCode service process identity changed; retaining lease") + } + const remaining = deadlineAt - Date.now() + if (remaining <= 0) throw new Error("OpenCode service shutdown deadline elapsed; retaining lease") + const proof = this.serviceProof(owned) + await this.writeLease(lease.file, { ...ownMetadata, service: proof, state: "stopping", updatedAt: Date.now() }) + const stopped = await this.withDeadline( + (this.dependencies.requestStop ?? this.requestStop)(owned.info, owned.endpoint, remaining), + remaining, + "OpenCode service stop", + ) + if (!stopped) { + await this.writeLease(lease.file, { ...ownMetadata, service: proof, state: "active", updatedAt: Date.now() }) + throw new Error("OpenCode service rejected or failed the stop request; retaining lease") + } + if (!await this.waitForOwnedStop(owned, deadlineAt)) { + throw new Error("OpenCode service accepted stop but did not exit before the shutdown deadline; retaining lease") + } + await this.finishOwnedStop(lease, owned) + }) + } + + private async waitForOwnedStop( + owned: OwnedService, + deadlineAt: number, + ): Promise { + const remaining = deadlineAt - Date.now() + if (remaining <= 0) return false + return this.withDeadline( + this.dependencies.waitForStop + ? this.dependencies.waitForStop(owned.info, owned.endpoint, remaining) + : this.waitForStop(owned, remaining, deadlineAt), + remaining, + "OpenCode service stop completion", + ) + } + + private async finishOwnedStop( + lease: LeaseHandle, + owned: OwnedService, + ): Promise { + await this.releaseLease(lease) + if (this.owned && this.sameInfo(this.owned.info, owned.info)) 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.connected) { + if (this.pendingLaunch) { + return this.withDeadline(this.pendingLaunch, selectedOptions.timeoutMs ?? 30_000, "OpenCode service ensure") + } + 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 { + contenderFile: _contenderFile, + leaseFile: _leaseFile, + lockDirectory: _lockDirectory, + timeoutMs: _timeoutMs, + staleLockMs: _staleLockMs, + nativePid: _nativePid, + wslDistro: _wslDistro, + environment: _environment, + launcherRecordsPid: _launcherRecordsPid, + windowsVerbatimArguments: _windowsVerbatimArguments, + 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) @@ -140,34 +358,72 @@ export class OpenCodeSharedService { private startConnection(options: OpenCodeEnsureOptions): Promise { this.ensureOptions = options - const { environment, ...ensureOptions } = options + if (options.nativePid === false && !options.wslDistro) { + return Promise.reject(new Error("OpenCode service wrapper cannot prove the service PID")) + } + const { + contenderFile, + leaseFile, + lockDirectory, + timeoutMs = 30_000, + staleLockMs = DEFAULT_STALE_LOCK_MS, + environment, + launcherRecordsPid, + windowsVerbatimArguments, + ...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) => { + const pending = this.ensureLease( + leaseFile, + lockDirectory, + contenderFile, + ensureOptions.file, + timeoutMs, + staleLockMs, + ).then(() => { + const launch = (this.dependencies.ensure + ? this.dependencies.ensure({ + ...ensureOptions, + onStart: (reason, previousVersion) => onStart?.(reason, previousVersion), + }).then((endpoint) => ({ endpoint, started: true })) + : this.ensureSafely(ensureOptions, environment, launcherRecordsPid, windowsVerbatimArguments, timeoutMs) + ).then(async ({ endpoint, started }) => { + assertLoopbackServiceUrl(endpoint.url) + const connection = { + endpoint, + stopOptions: { file: ensureOptions.file }, + client: this.dependencies.makeClient({ + baseUrl: endpoint.url, + headers: this.dependencies.headers(endpoint), + }), + } + const info = await this.proveOwnership(connection.stopOptions.file, contenderFile, endpoint, started, timeoutMs) if (info) { - this.owned = { stopOptions: connection.stopOptions, info } + const processIdentity = await this.requireServiceProcessIdentity(info.pid, timeoutMs, this.serviceNamespace()) + this.owned = { + stopOptions: connection.stopOptions, + info, + endpoint, + nativePid: options.nativePid !== false, + processIdentity, + } + await this.updateLeaseService({ + info, + endpoint, + registrationFile: connection.stopOptions.file!, + nativePid: this.owned.nativePid, + processIdentity, + }) } this.connected = connection return connection }) + const tracked = launch.finally(() => { + if (this.pendingLaunch === tracked) this.pendingLaunch = undefined + if (this.shutdownRequested) setImmediate(() => { void this.reconcileLateLaunch() }) + }) + this.pendingLaunch = tracked + return this.withDeadline(tracked, timeoutMs, "OpenCode service ensure") }) const connection = pending.catch((error) => { this.invalidate(connection) @@ -177,6 +433,13 @@ export class OpenCodeSharedService { return connection } + private async reconcileLateLaunch(): Promise { + await this.shutdownAttempt?.catch(() => undefined) + const lease = this.lease + if (!lease) return + await this.shutdown({ timeoutMs: lease.lifecycleTimeoutMs }).catch(() => undefined) + } + private async withClient( options: OpenCodeEnsureOptions | undefined, run: (client: OpenCodeClient) => Promise, @@ -220,24 +483,189 @@ export class OpenCodeSharedService { && left.auth?.password === right.auth?.password } - private async proveOwnership(file: string | undefined, contenderFile: string | undefined): Promise { - if (!file || !contenderFile) return undefined + private async proveOwnership( + file: string | undefined, + contenderFile: string | undefined, + endpoint: Endpoint, + started: boolean, + timeoutMs: number, + ): Promise { + if (!file) return undefined const [info, contenders] = await Promise.all([ - this.readInfo(file).catch(() => undefined), - readFile(contenderFile, "utf8").catch(() => ""), + readSecureServiceInfo(file), + contenderFile ? open(contenderFile, "r").then(async (handle) => { + try { return await handle.readFile("utf8") } finally { await handle.close() } + }).catch(() => "") : "", ]) - if (!info || !contenders.split(/\r?\n/).includes(String(info.pid))) return undefined + if (!info || !started) return undefined + if (info.url !== endpoint.url || info.password !== endpoint.auth?.password) return undefined + const lease = this.lease && await this.readLease(this.lease.file) + if (this.dependencies.ensure) { + if (!contenderFile || !contenders.split(/\r?\n/).includes(String(info.pid))) return undefined + } else if (!lease?.launch || lease.launch.nativePid !== (this.ensureOptions?.nativePid !== false)) { + return undefined + } + if (!this.dependencies.ensure && !await this.registrationMatchesLaunch(file, endpoint)) return undefined + if (!await this.requireServiceProcessIdentity(info.pid, timeoutMs, this.serviceNamespace())) 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 async ensureSafely( + options: EnsureOptions, + environment: NodeJS.ProcessEnv | undefined, + launcherRecordsPid: boolean | undefined, + windowsVerbatimArguments: boolean | undefined, + timeoutMs: number, + ): Promise<{ endpoint: Endpoint; started: boolean }> { + const discovered = await this.dependencies.discover(options) + if (discovered) return { + endpoint: discovered, + started: await this.registrationMatchesLaunch(options.file, discovered), + } + const lease = this.lease + if (!lease) throw new Error("OpenCode service launch requires a lifecycle lease") + const command = options.command + if (!command?.length) throw new Error("Missing OpenCode service command") + await this.quarantineStaleRegistration(options.file, lease) + let launch = await this.prepareLaunchIntent(lease, this.ensureOptions?.nativePid !== false) + options.onStart?.("missing") + const child = this.spawnService(command, environment, windowsVerbatimArguments) + child.stderr?.resume() + if (!child.pid) throw new Error("OpenCode service process did not expose a PID") + if (launch.nativePid && lease.contenderFile && !launcherRecordsPid) await appendFile(lease.contenderFile, `${child.pid}\n`) + launch = { ...launch, servicePid: launch.nativePid && !launcherRecordsPid ? child.pid : undefined } + await this.updateLaunchIntent(lease, launch) + const deadlineAt = Date.now() + timeoutMs + let lastError: Error | undefined + const childFailure = new Promise((_resolve, reject) => { + child.once("error", (error) => reject(new Error(`Failed to start OpenCode service: ${error.message}`))) + child.once("exit", (code, signal) => { + if (launcherRecordsPid && code === 0) return + lastError = new Error(`OpenCode service exited before registration (${signal ?? code ?? "unknown"})`) + }) + }) + child.unref() + while (Date.now() < deadlineAt) { + const endpoint = await Promise.race([ + this.dependencies.discover(options), + childFailure, + ]) + if (endpoint) return { endpoint, started: true } + if (lastError) throw lastError + await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(1, deadlineAt - Date.now())))) + } + throw new Error(`OpenCode service ensure timed out after ${timeoutMs}ms`) + } + + private spawnService( + command: ReadonlyArray, + environment: NodeJS.ProcessEnv | undefined, + windowsVerbatimArguments: boolean | undefined, + ): ChildProcess { + const [executable, ...args] = command + if (!executable) throw new Error("Missing OpenCode service command") + return spawn(executable, args, { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + env: environment, + windowsHide: true, + windowsVerbatimArguments, + }) + } + + private async prepareLaunchIntent(lease: LeaseHandle, nativePid: boolean): Promise { + const launch = { identity: randomUUID(), createdAt: Date.now(), nativePid, contenderFile: lease.contenderFile } + await this.updateLaunchIntent(lease, launch) + return launch + } + + private async updateLaunchIntent(lease: LeaseHandle, launch: LaunchIntent): Promise { + await this.withLifecycleLock(lease, Date.now() + lease.lifecycleTimeoutMs, async () => { + const metadata = await this.readLease(lease.file) + if (!metadata || metadata.identity !== lease.identity) throw new Error("OpenCode service lease identity changed") + await this.writeLease(lease.file, { ...metadata, launch, updatedAt: Date.now() }) + }) + } + + private async requireServiceProcessIdentity( + pid: number, + timeoutMs: number, + namespace: ProcessNamespace, + ): Promise { + const identity = await (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)(pid, Math.max(1, timeoutMs), namespace) + if (!identity) throw new Error("Unable to prove the OpenCode service process identity") + return identity + } + + private async registrationMatchesLaunch(file: string | undefined, endpoint: Endpoint): Promise { + const lease = this.lease && await this.readLease(this.lease.file) + const launch = lease?.launch + const info = await readSecureServiceInfo(file) + if (!launch || !info || info.url !== endpoint.url || info.password !== endpoint.auth?.password) return false + if (launch.servicePid !== undefined) return launch.servicePid === info.pid + if (launch.contenderFile) { + const contenders = await open(launch.contenderFile, "r").then(async (handle) => { + try { return await handle.readFile("utf8") } finally { await handle.close() } + }).catch(() => "") + if (contenders.split(/\r?\n/).includes(String(info.pid))) return true + } + return false + } + + private async quarantineStaleRegistration(file: string | undefined, lease: LeaseHandle): Promise { + if (!file) return + const info = await readSecureServiceInfo(file) + if (!info) { + await this.quarantineRegistrationFile(file, lease.identity) + return + } + const metadata = await this.readLease(lease.file) + const proof = metadata?.service + const namespace = proof?.processIdentity?.namespace ?? this.serviceNamespace() + if (namespace.kind === "host" && !this.processIsAlive(info.pid)) { + await this.quarantineRegistrationFile(file, lease.identity, info) + return + } + if (namespace.kind === "wsl") { + const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( + info.pid, + lease.lifecycleTimeoutMs, + namespace, + ) + if (probe.status === "missing") { + await this.quarantineRegistrationFile(file, lease.identity, info) + return + } + if (probe.status === "unknown") { + throw new Error("OpenCode service registration is unhealthy but its WSL PID identity cannot be verified") + } + } + if (proof && this.sameInfo(proof.info, info) && proof.processIdentity) { + const currentIdentity = await this.currentProcessIdentity( + info.pid, + Date.now() + lease.lifecycleTimeoutMs, + proof.processIdentity.namespace, + ) + if (currentIdentity && !this.sameProcessIdentity(currentIdentity, proof.processIdentity)) { + await this.quarantineRegistrationFile(file, lease.identity, info) + return + } + } + throw new Error("OpenCode service registration is unhealthy but its PID identity cannot be proven stale") + } + + private async quarantineRegistrationFile(file: string, identity: string, expected?: Info): Promise { + if (expected) { + const current = await readSecureServiceInfo(file) + if (!current || !this.sameInfo(current, expected)) return + } + const quarantine = `${file}.stale-${identity}-${randomUUID()}` + try { + await rename(file, quarantine) + await rm(quarantine, { force: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } } private sameInfo(left: Info, right: Info): boolean { @@ -248,24 +676,484 @@ export class OpenCodeSharedService { && left.password === right.password } - private async withEnvironment(environment: NodeJS.ProcessEnv | undefined, run: () => Promise): Promise { - const entries = Object.entries(environment ?? {}) - if (!entries.length) return run() + private async ensureLease( + file: string | undefined, + lockDirectory: string | undefined, + contenderFile: string | undefined, + registrationFile: string | undefined, + timeoutMs: number, + staleLockMs: number, + ): Promise { + if (!file || !lockDirectory || this.lease) return + const identity = randomUUID() + const lease = { + file, + lockDirectory, + contenderFile, + identity, + staleLockMs: Math.max(1, staleLockMs), + lifecycleTimeoutMs: timeoutMs, + } + const deadlineAt = Date.now() + timeoutMs + await this.withLifecycleLock(lease, deadlineAt, async () => { + const inheritedProof = await this.deadPeerProof( + await this.readPeerLeases(path.dirname(file)), + registrationFile, + deadlineAt, + ) + const inheritedLaunch = inheritedProof ? undefined : await this.deadPeerLaunch( + await this.readPeerLeases(path.dirname(file)), + deadlineAt, + ) + await this.pruneLeaseArtifacts(path.dirname(file), undefined, lease.staleLockMs, deadlineAt) + const now = Date.now() + const processIdentity = await this.requireCurrentProcessIdentity(deadlineAt) + await this.writeLease(file, { + version: 1, + identity, + pid: process.pid, + processIdentity, + createdAt: now, + updatedAt: now, + state: "active", + contenderFile, + launch: inheritedLaunch, + service: inheritedProof, + }, true) + this.lease = lease + }) + } - // 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 + private async updateLeaseService(service: ServiceProof): Promise { + const lease = this.lease + if (!lease) return + await this.withLifecycleLock(lease, Date.now() + lease.lifecycleTimeoutMs, async () => { + const metadata = await this.readLease(lease.file) + if (!metadata || metadata.identity !== lease.identity) throw new Error("OpenCode service lease identity changed") + await this.writeLease(lease.file, { ...metadata, service, updatedAt: Date.now() }) + }) + } + + private async releaseLease(lease: LeaseHandle): Promise { + const metadata = await this.readLease(lease.file) + if (metadata?.identity === lease.identity) await rm(lease.file, { force: true }) + if (lease.contenderFile) await rm(lease.contenderFile, { force: true }) + if (this.lease === lease) this.lease = undefined + } + + private async readPeerLeases(directory: string, ownFile?: string): Promise> { + const entries = (await readdir(directory)).filter((entry) => entry.endsWith(".json")) + const peers = await Promise.all(entries.map(async (entry) => { + const file = path.join(directory, entry) + if (file === ownFile) return undefined + const metadata = await this.readLease(file) + return metadata ? { file, metadata } : undefined + })) + return peers.filter((peer): peer is { file: string; metadata: LeaseMetadata } => Boolean(peer)) + } + + private async pruneLeaseArtifacts( + directory: string, + ownFile: string | undefined, + staleMs: number, + deadlineAt: number, + ): Promise { + for (const entry of await readdir(directory)) { + const file = path.join(directory, entry) + if (file === ownFile || (!entry.endsWith(".json") && !entry.endsWith(".tmp"))) continue + const peer = entry.endsWith(".json") ? await this.readLease(file) : undefined + if (peer) { + const state = await this.processOwnerState(peer, deadlineAt) + if (state === "live" || (state === "unknown" && Date.now() - peer.updatedAt < staleMs)) continue + const current = await this.readLease(file) + if (!current || current.identity !== peer.identity) continue + const currentState = await this.processOwnerState(current, deadlineAt) + if (currentState === "live" || (currentState === "unknown" && Date.now() - current.updatedAt < staleMs)) continue + await rm(file, { force: true }) + continue + } + await this.pruneInvalidArtifact(file, staleMs) + } + } + + private async pruneInvalidArtifact(file: string, staleMs: number): Promise { + try { + const before = await lstat(file) + if (!before.isFile() || before.isSymbolicLink() || Date.now() - before.mtimeMs < staleMs) return + const after = await lstat(file) + if (!this.sameFileIdentity(before, after) || Date.now() - after.mtimeMs < staleMs) return + await rm(file, { force: true }) + } catch {} + } + + private async deadPeerProof( + peers: Array<{ file: string; metadata: LeaseMetadata }>, + registrationFile: string | undefined, + deadlineAt: number, + ): Promise { + if (!registrationFile) return undefined + const states = await Promise.all(peers.map((peer) => this.processOwnerState(peer.metadata, deadlineAt))) + const proofs = peers + .filter((_peer, index) => states[index] === "stale") + .map((peer) => peer.metadata.service) + .filter((proof): proof is ServiceProof => proof?.registrationFile === registrationFile) + const first = proofs[0] + if (!first) return undefined + return proofs.every((proof) => this.sameInfo(proof.info, first.info) + && this.sameEndpoint(proof.endpoint, first.endpoint) + && proof.nativePid === first.nativePid + && (proof.processIdentity === undefined + ? first.processIdentity === undefined + : first.processIdentity !== undefined && this.sameProcessIdentity(proof.processIdentity, first.processIdentity))) + ? first + : undefined + } + + private async deadPeerLaunch( + peers: Array<{ file: string; metadata: LeaseMetadata }>, + deadlineAt: number, + ): Promise { + for (const peer of peers) { + if (!peer.metadata.launch || await this.processOwnerState(peer.metadata, deadlineAt) !== "stale") continue + return peer.metadata.launch + } + return undefined + } + + private async processOwnerState( + owner: Pick, + deadlineAt: number, + ): Promise { + const timeoutMs = deadlineAt - Date.now() + if (timeoutMs <= 0) return "unknown" + if (owner.processIdentity.namespace.kind === "wsl") { + const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( + owner.pid, + timeoutMs, + owner.processIdentity.namespace, + ) + if (probe.status === "missing") return "stale" + return probe.status === "found" && this.sameProcessIdentity(probe.identity, owner.processIdentity) ? "live" : "unknown" + } + if (!this.processIsAlive(owner.pid)) return "stale" + const identity = await (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)( + owner.pid, + timeoutMs, + owner.processIdentity.namespace, + ) + return identity ? this.sameProcessIdentity(identity, owner.processIdentity) ? "live" : "stale" : "unknown" + } + + private async requireCurrentProcessIdentity(deadlineAt: number): Promise { + const timeoutMs = deadlineAt - Date.now() + const identity = timeoutMs > 0 + ? await (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)(process.pid, timeoutMs, { kind: "host" }) + : undefined + if (!identity) throw new Error("Unable to prove the OpenCode service lease process identity") + return identity + } + + private async currentProcessIdentity( + pid: number, + deadlineAt: number, + namespace: ProcessNamespace, + ): Promise { + const timeoutMs = deadlineAt - Date.now() + return timeoutMs > 0 + ? (this.dependencies.getProcessIdentity ?? getProcessStartIdentity)(pid, timeoutMs, namespace) + : undefined + } + + private processIsAlive(pid: number): boolean { + if (this.dependencies.isProcessAlive) return this.dependencies.isProcessAlive(pid) + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } + } + + private async readLease(file: string): Promise { + try { + const before = await lstat(file) + if (!before.isFile() || before.isSymbolicLink()) return undefined + const handle = await open(file, "r") + try { + const stat = await handle.stat() + if (!stat.isFile() || (process.platform !== "win32" && (before.dev !== stat.dev || before.ino !== stat.ino))) return undefined + const value: unknown = JSON.parse(await handle.readFile("utf8")) + if (!this.isLeaseMetadata(value)) return undefined + return value + } finally { + await handle.close() + } + } catch { + return undefined + } + } + + private async writeLease(file: string, metadata: LeaseMetadata, exclusive = false): Promise { + if (exclusive) { + const handle = await open(file, "wx", 0o600) + try { await handle.writeFile(JSON.stringify(metadata)) } finally { await handle.close() } + return + } + const temporary = `${file}.${metadata.identity}.tmp` + const handle = await open(temporary, "wx", 0o600) + try { await handle.writeFile(JSON.stringify(metadata)) } finally { await handle.close() } + try { await rename(temporary, file) } catch (error) { + await rm(temporary, { force: true }) + throw error + } + } + + private isLeaseMetadata(value: unknown): value is LeaseMetadata { + if (!value || typeof value !== "object") return false + const lease = value as Partial + return lease.version === 1 + && typeof lease.identity === "string" && lease.identity.length > 0 + && typeof lease.pid === "number" && Number.isInteger(lease.pid) && lease.pid > 0 + && this.isProcessIdentity(lease.processIdentity) + && typeof lease.createdAt === "number" && Number.isFinite(lease.createdAt) + && typeof lease.updatedAt === "number" && Number.isFinite(lease.updatedAt) + && (lease.state === "active" || lease.state === "stopping") + && (lease.contenderFile === undefined || typeof lease.contenderFile === "string") + && (lease.launch === undefined || this.isLaunchIntent(lease.launch)) + && (lease.service === undefined || this.isServiceProof(lease.service)) + } + + private isLaunchIntent(value: unknown): value is LaunchIntent { + if (!value || typeof value !== "object") return false + const launch = value as Partial + return typeof launch.identity === "string" && launch.identity.length > 0 + && typeof launch.createdAt === "number" && Number.isFinite(launch.createdAt) + && typeof launch.nativePid === "boolean" + && (launch.contenderFile === undefined || typeof launch.contenderFile === "string") + && (launch.servicePid === undefined || Number.isInteger(launch.servicePid) && launch.servicePid > 0) + } + + private isServiceProof(value: unknown): value is ServiceProof { + if (!value || typeof value !== "object") return false + const proof = value as Partial + if (typeof proof.registrationFile !== "string") return false + if (!proof.endpoint || !proof.info || typeof proof.endpoint.url !== "string") return false + if (!proof.info.id || !Number.isInteger(proof.info.pid) || proof.info.pid <= 0) return false + if (proof.nativePid !== undefined && typeof proof.nativePid !== "boolean") return false + if (proof.processIdentity !== undefined && !this.isProcessIdentity(proof.processIdentity)) return false + if (proof.info.url !== proof.endpoint.url || proof.info.password !== proof.endpoint.auth?.password) return false + try { assertLoopbackServiceUrl(proof.endpoint.url) } catch { return false } + return true + } + + private serviceProof(owned: OwnedService): ServiceProof { + if (!owned.stopOptions.file) throw new Error("OpenCode service registration file is missing") + return { + info: owned.info, + endpoint: owned.endpoint, + registrationFile: owned.stopOptions.file, + nativePid: owned.nativePid, + processIdentity: owned.processIdentity, + } + } + + private async withLifecycleLock(lease: Pick, deadlineAt: number, run: () => Promise): Promise { + const owner: LockOwner = { + version: 1, + identity: lease.identity, + pid: process.pid, + processIdentity: await this.requireCurrentProcessIdentity(deadlineAt), + createdAt: Date.now(), + } + const ownerFile = path.join(lease.lockDirectory, LOCK_OWNER_FILE) + while (true) { + try { + await mkdir(lease.lockDirectory, { mode: 0o700 }) + await this.writeLockOwner(ownerFile, owner) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error + await this.reclaimStaleLock(lease.lockDirectory, ownerFile, lease.staleLockMs, deadlineAt) + if (Date.now() >= deadlineAt) throw new Error("Timed out waiting for the OpenCode service lifecycle lock") + await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadlineAt - Date.now())))) + } } try { return await run() } finally { - for (const [key, value] of previous) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } + const current = await this.readLockOwner(ownerFile) + if (current?.identity === owner.identity) await rm(lease.lockDirectory, { recursive: true, force: true }) } } + + private async reclaimStaleLock(directory: string, ownerFile: string, staleLockMs: number, deadlineAt: number): Promise { + let before: Stats + try { before = await lstat(directory) } catch { return } + if (!before.isDirectory() || before.isSymbolicLink()) return + const ownerBefore = await this.regularFileStat(ownerFile) + const owner = await this.readLockOwner(ownerFile) + if (owner && await this.processOwnerState(owner, deadlineAt) === "live") return + const now = Date.now() + const ownerCreatedAt = owner && owner.createdAt <= now ? owner.createdAt : 0 + if (now - Math.max(before.mtimeMs, ownerBefore?.mtimeMs ?? 0, ownerCreatedAt) < staleLockMs) return + const current = await this.readLockOwner(ownerFile) + if (owner) { + if (!current || current.identity !== owner.identity || await this.processOwnerState(current, deadlineAt) === "live") return + } else if (current) return + let after: Stats + try { after = await lstat(directory) } catch { return } + const ownerAfter = await this.regularFileStat(ownerFile) + if (!this.sameFileIdentity(before, after)) return + if (ownerBefore && (!ownerAfter || !this.sameFileIdentity(ownerBefore, ownerAfter))) return + if (!ownerBefore && ownerAfter) return + if (Date.now() - Math.max(after.mtimeMs, ownerAfter?.mtimeMs ?? 0) < staleLockMs) return + const staleDirectory = `${directory}.stale-${owner?.identity ?? "unknown"}-${randomUUID()}` + try { await rename(directory, staleDirectory) } catch { return } + await rm(staleDirectory, { recursive: true, force: true }) + } + + private sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs + && left.birthtimeMs === right.birthtimeMs + } + + private async regularFileStat(file: string): Promise { + try { + const stat = await lstat(file) + return stat.isFile() && !stat.isSymbolicLink() ? stat : undefined + } catch { + return undefined + } + } + + private async writeLockOwner(file: string, owner: LockOwner): Promise { + const handle = await open(file, "wx", 0o600) + try { await handle.writeFile(JSON.stringify(owner)) } finally { await handle.close() } + } + + private async readLockOwner(file: string): Promise { + try { + const stat = await lstat(file) + if (!stat.isFile() || stat.isSymbolicLink()) return undefined + const handle = await open(file, "r") + try { + const value: unknown = JSON.parse(await handle.readFile("utf8")) + if (!value || typeof value !== "object") return undefined + const owner = value as Partial + if (owner.version !== 1 || typeof owner.identity !== "string" || !owner.identity) return undefined + if (typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0) return undefined + if (!this.isProcessIdentity(owner.processIdentity)) return undefined + if (typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt)) return undefined + return owner as LockOwner + } finally { + await handle.close() + } + } catch { + return undefined + } + } + + private async withDeadline(operation: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } + + private requestStop = async (info: Info, endpoint: Endpoint, timeoutMs: number): Promise => { + if (!info.id) return false + const response = await fetch(new URL("/api/service/stop", info.url), { + method: "POST", + headers: { ...this.dependencies.headers(endpoint), "content-type": "application/json" }, + body: JSON.stringify({ instanceID: info.id }), + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) return false + const body = await response.json() as { accepted?: unknown } + return body?.accepted === true + } + + private waitForStop = async ( + owned: OwnedService, + timeoutMs: number, + deadlineAt: number, + ): Promise => { + while (true) { + if (owned.processIdentity) { + if (owned.processIdentity.namespace.kind === "host" && !this.processIsAlive(owned.info.pid)) return true + if (owned.processIdentity.namespace.kind === "wsl") { + const remaining = deadlineAt - Date.now() + if (remaining <= 0) return false + const probe = await (this.dependencies.probeProcessIdentity ?? probeProcessStartIdentity)( + owned.info.pid, + remaining, + owned.processIdentity.namespace, + ) + if (probe.status === "missing") return true + if (probe.status === "found" && !this.sameProcessIdentity(probe.identity, owned.processIdentity)) return true + } else { + const identity = await this.currentProcessIdentity(owned.info.pid, deadlineAt, owned.processIdentity.namespace) + if (identity && !this.sameProcessIdentity(identity, owned.processIdentity)) return true + } + } + const remaining = deadlineAt - Date.now() + if (remaining <= 0) return false + const health = await fetch(new URL("/api/health", owned.info.url), { + headers: this.dependencies.headers(owned.endpoint), + signal: AbortSignal.timeout(Math.min(250, remaining)), + }).then(async (response) => { + const body = await response.json() as { healthy?: unknown; pid?: unknown } + return { unavailable: false, pid: response.ok && body?.healthy === true && Number.isInteger(body.pid) ? body.pid as number : undefined } + }).catch(() => ({ unavailable: true, pid: undefined })) + if (health.pid !== undefined && health.pid !== owned.info.pid) return true + if (health.unavailable && await this.registrationDisappeared(owned.stopOptions.file)) return true + await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadlineAt - Date.now())))) + } + } + + private async registrationDisappeared(file: string | undefined): Promise { + if (!file) return false + try { + await lstat(file) + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + } + return false + } + + private serviceNamespace(): ProcessNamespace { + return this.ensureOptions?.wslDistro + ? { kind: "wsl", distro: this.ensureOptions.wslDistro } + : { kind: "host" } + } + + private isProcessIdentity(value: unknown): value is ProcessIdentity { + if (!value || typeof value !== "object") return false + const identity = value as Partial + const namespace = identity.namespace as Partial | undefined + return Number.isInteger(identity.pid) && (identity.pid ?? 0) > 0 + && typeof identity.start === "string" && identity.start.length > 0 + && (namespace?.kind === "host" + || namespace?.kind === "wsl" && typeof namespace.distro === "string" && namespace.distro.length > 0) + } + + private sameProcessIdentity(left: ProcessIdentity | undefined, right: ProcessIdentity): boolean { + if (!left) return false + return left.pid === right.pid + && left.start === right.start + && left.namespace.kind === right.namespace.kind + && (left.namespace.kind !== "wsl" + || right.namespace.kind === "wsl" && left.namespace.distro.toLowerCase() === right.namespace.distro.toLowerCase()) + } } diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts new file mode 100644 index 00000000..380c31a5 --- /dev/null +++ b/packages/server/src/workspaces/process-identity.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { probeProcessStartIdentity } from "./process-identity" + +test("probes a service PID inside its WSL distro instead of the coincidental Windows PID", async () => { + const calls: Array<{ command: string; args: string[] }> = [] + const probe = await probeProcessStartIdentity(4242, 100, { kind: "wsl", distro: "Ubuntu" }, async (command, args) => { + calls.push({ command, args }) + return { + code: 0, + stdout: "4242 (opencode) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20\nlinux-boot-id\n", + } + }) + + assert.equal(calls[0]?.command, "wsl.exe") + assert.deepEqual(calls[0]?.args.slice(0, 2), ["--distribution", "Ubuntu"]) + assert.deepEqual(probe, { + status: "found", + identity: { namespace: { kind: "wsl", distro: "Ubuntu" }, pid: 4242, start: "linux-boot-id:98765" }, + }) +}) + +test("distinguishes a missing WSL PID from an unverified probe", async () => { + const missing = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({ + code: 3, + stdout: "", + })) + const unknown = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({ + code: null, + stdout: "", + })) + + assert.deepEqual(missing, { status: "missing" }) + assert.deepEqual(unknown, { status: "unknown" }) +}) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts new file mode 100644 index 00000000..c8764729 --- /dev/null +++ b/packages/server/src/workspaces/process-identity.ts @@ -0,0 +1,102 @@ +import { execFile } from "node:child_process" +import { readFile } from "node:fs/promises" + +export type ProcessNamespace = { kind: "host" } | { kind: "wsl"; distro: string } + +export interface ProcessIdentity { + namespace: ProcessNamespace + pid: number + start: string +} + +export type ProcessIdentityProbe = + | { status: "found"; identity: ProcessIdentity } + | { status: "missing" } + | { status: "unknown" } + +export type ProcessIdentityRunner = ( + command: string, + args: string[], + timeoutMs: number, +) => Promise<{ code: number | null; stdout: string }> + +const runCommand: ProcessIdentityRunner = (command, args, timeoutMs) => new Promise((resolve) => { + execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => { + resolve({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout }) + }) +}) + +export async function probeProcessStartIdentity( + pid: number, + timeoutMs: number, + namespace: ProcessNamespace = { kind: "host" }, + runner: ProcessIdentityRunner = runCommand, +): Promise { + if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return { status: "unknown" } + if (namespace.kind === "wsl") { + const result = await runner("wsl.exe", [ + "--distribution", namespace.distro, + "--exec", "sh", "-c", + 'test -r "/proc/$1/stat" || exit 3; cat "/proc/$1/stat"; cat /proc/sys/kernel/random/boot_id', + "codenomad-process-probe", String(pid), + ], timeoutMs).catch(() => ({ code: null, stdout: "" })) + if (result.code === 3) return { status: "missing" } + const [stat, bootId] = result.stdout.trim().split(/\r?\n/) + const start = result.code === 0 && stat && bootId ? linuxStart(stat, bootId) : undefined + return start + ? { status: "found", identity: { namespace, pid, start } } + : { status: "unknown" } + } + + try { + if (process.platform === "linux") { + const signal = AbortSignal.timeout(timeoutMs) + const stat = await readFile(`/proc/${pid}/stat`, { encoding: "utf8", signal }) + const bootId = (await readFile("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim() + const start = linuxStart(stat, bootId) + return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" } + } + if (process.platform === "darwin") { + return commandProbe(runner, "ps", ["-p", String(pid), "-o", "lstart="], namespace, pid, timeoutMs) + } + if (process.platform === "win32") { + return commandProbe(runner, "powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop; if (!$p) { exit 3 }; $p.CreationDate.ToUniversalTime().Ticks`, + ], namespace, pid, timeoutMs) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" } + } + return { status: "unknown" } +} + +export async function getProcessStartIdentity( + pid: number, + timeoutMs: number, + namespace: ProcessNamespace = { kind: "host" }, + runner?: ProcessIdentityRunner, +): Promise { + const probe = await probeProcessStartIdentity(pid, timeoutMs, namespace, runner) + return probe.status === "found" ? probe.identity : undefined +} + +function linuxStart(stat: string, bootId: string): string | undefined { + const commandEnd = stat.lastIndexOf(")") + const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19] + return startTicks && bootId ? `${bootId}:${startTicks}` : undefined +} + +async function commandProbe( + runner: ProcessIdentityRunner, + command: string, + args: string[], + namespace: ProcessNamespace, + pid: number, + timeoutMs: number, +): Promise { + const result = await runner(command, args, timeoutMs).catch(() => ({ code: null, stdout: "" })) + if (result.code === 3) return { status: "missing" } + const start = result.code === 0 ? result.stdout.trim() : "" + return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" } +} diff --git a/packages/server/src/workspaces/service-state.ts b/packages/server/src/workspaces/service-state.ts new file mode 100644 index 00000000..3da9c7bc --- /dev/null +++ b/packages/server/src/workspaces/service-state.ts @@ -0,0 +1,106 @@ +import { constants, closeSync, chmodSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs" +import { open } from "node:fs/promises" +import { isIP } from "node:net" +import os from "node:os" +import path from "node:path" +import type { Info } from "@opencode-ai/client/service" + +const CODENOMAD_HOME = path.join(os.homedir(), ".codenomad") +const CODENOMAD_STATE = path.join(CODENOMAD_HOME, "state") +export const SERVICE_STATE_ROOT = path.join(CODENOMAD_STATE, "opencode-v2") +export const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json") +export const SERVICE_LEASE_DIRECTORY = path.join(SERVICE_STATE_ROOT, "leases") +export const SERVICE_STOP_LOCK = path.join(SERVICE_STATE_ROOT, "stop.lock") + +const preparedFiles = new Set() + +export function prepareServiceState(contenderFile: string): void { + ensurePrivateDirectory(CODENOMAD_HOME) + ensurePrivateDirectory(CODENOMAD_STATE) + ensurePrivateDirectory(SERVICE_STATE_ROOT) + ensurePrivateDirectory(path.dirname(SERVICE_REGISTRATION_FILE)) + ensurePrivateDirectory(SERVICE_LEASE_DIRECTORY) + validateRegistrationFile(SERVICE_REGISTRATION_FILE) + if (preparedFiles.has(contenderFile)) return + closeSync(openSync(contenderFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)) + preparedFiles.add(contenderFile) +} + +export async function readSecureServiceInfo(file: string | undefined): Promise { + if (!file) return undefined + let handle + try { + const before = lstatSync(file) + if (!before.isFile() || before.isSymbolicLink()) return undefined + const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW + handle = await open(file, flags) + const after = await handle.stat() + if (process.platform !== "win32" && (before.dev !== after.dev || before.ino !== after.ino)) return undefined + return parseInfo(await handle.readFile("utf8")) + } catch { + return undefined + } finally { + await handle?.close() + } +} + +export function assertLoopbackServiceUrl(value: string): URL { + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Unsupported OpenCode service protocol: ${url.protocol}`) + } + const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase() + const ipVersion = isIP(hostname) + const loopback = hostname === "localhost" + || (ipVersion === 4 && hostname.startsWith("127.")) + || (ipVersion === 6 && (hostname === "::1" || hostname.startsWith("::ffff:127."))) + if (!loopback) throw new Error(`OpenCode service endpoint must be loopback: ${url.hostname}`) + return url +} + +function ensurePrivateDirectory(directory: string): void { + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const stat = lstatSync(directory) + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service state directory: ${directory}`) + if (process.platform === "win32") return + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { + throw new Error(`OpenCode service state directory is owned by another user: ${directory}`) + } + if ((stat.mode & 0o077) !== 0) chmodSync(directory, 0o700) +} + +function validateRegistrationFile(file: string): void { + let stat + try { + stat = lstatSync(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return + throw error + } + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service registration file: ${file}`) + const text = readFileSync(file, "utf8") + let value: unknown + try { value = JSON.parse(text) } catch { return } + if (typeof value === "object" && value !== null && "url" in value && typeof value.url === "string") { + assertLoopbackServiceUrl(value.url) + } +} + +function parseInfo(text: string): Info | undefined { + let value: unknown + try { + value = JSON.parse(text) + } catch { + return undefined + } + if (typeof value !== "object" || value === null) return undefined + if (!("url" in value) || typeof value.url !== "string") return undefined + if (!("id" in value) || typeof value.id !== "string" || !value.id) return undefined + if (!("pid" in value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined + try { + assertLoopbackServiceUrl(value.url) + } catch { + return undefined + } + return value as Info +} diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index f2fb6bbe..fe6d412d 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -1,5 +1,5 @@ import { spawnSync } from "child_process" -import { statSync } from "fs" +import { readFileSync, statSync } from "fs" import path from "path" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) @@ -41,6 +41,10 @@ export interface SpawnSpec { export interface ServiceLaunchSpec { command: string[] env?: NodeJS.ProcessEnv + nativePid: boolean + wslDistro?: string + launcherRecordsPid?: boolean + windowsVerbatimArguments?: boolean } interface BuildSpawnSpecOptions { @@ -93,7 +97,8 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option return buildWslSpawnSpec(wslPath, args, options) } - const resolvedBinaryPath = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath + const resolvedCommand = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath + const resolvedBinaryPath = resolveWindowsNpmExecutable(resolvedCommand) ?? resolvedCommand const extension = path.win32.extname(resolvedBinaryPath).toLowerCase() if (WINDOWS_CMD_EXTENSIONS.has(extension)) { @@ -157,37 +162,37 @@ export function buildServiceLaunchSpec( options: BuildSpawnSpecOptions = {}, ): ServiceLaunchSpec { const spec = buildSpawnSpec(binaryPath, args, options) - if (spec.processKind === "wsl") { - return { command: [spec.command, ...spec.args], env: spec.env } + const direct = spec.processKind === "posix" || spec.processKind === "windows-direct" + if (direct && options.contenderFile) { + const launcher = [ + 'const { spawn } = require("node:child_process")', + 'const { appendFileSync } = require("node:fs")', + 'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: process.argv[4] === "true" })', + 'child.once("error", (error) => { console.error(error); process.exitCode = 1 })', + 'if (child.pid) { appendFileSync(process.argv[3], `${child.pid}\\n`); child.unref() }', + ].join(";") + return { + command: [ + process.execPath, + "-e", + launcher, + spec.command, + JSON.stringify(spec.args), + options.contenderFile, + String(Boolean(spec.options.windowsVerbatimArguments)), + ], + env: spec.env, + nativePid: true, + launcherRecordsPid: true, + } } - const contenderFile = spec.processKind === "posix" || spec.processKind === "windows-direct" - ? options.contenderFile - : undefined - if (!spec.options.windowsVerbatimArguments && !contenderFile) { - return { command: [spec.command, ...spec.args], env: spec.env } - } - - // Service.ensure cannot pass spawn options or expose contender PIDs. A Node - // trampoline supplies both for commands whose child PID is the service PID. - const launcher = [ - 'const { spawn } = require("node:child_process")', - 'const { appendFileSync } = require("node:fs")', - 'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { stdio: "inherit", windowsVerbatimArguments: process.argv[4] === "true" })', - 'if (process.argv[3]) appendFileSync(process.argv[3], `${child.pid}\\n`)', - 'child.once("error", (error) => { console.error(error); process.exit(1) })', - 'child.once("exit", (code) => process.exit(code ?? 1))', - ].join(";") return { - command: [ - process.execPath, - "-e", - launcher, - spec.command, - JSON.stringify(spec.args), - contenderFile ?? "", - String(Boolean(spec.options.windowsVerbatimArguments)), - ], + command: [spec.command, ...spec.args], env: spec.env, + nativePid: direct, + wslDistro: spec.wsl?.distro, + launcherRecordsPid: spec.processKind === "wsl" && Boolean(options.contenderFile), + windowsVerbatimArguments: spec.options.windowsVerbatimArguments, } } @@ -374,6 +379,20 @@ function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, return steps.join(" && ") } +function resolveWindowsNpmExecutable(command: string): string | null { + if (!WINDOWS_CMD_EXTENSIONS.has(path.win32.extname(command).toLowerCase())) return null + try { + const script = readFileSync(command, "utf8") + if (script.length > 64 * 1024) return null + const match = script.match(/["'](?:%~dp0|%dp0%)[\\/]([^"'\r\n]+\.exe)["']\s+%\*/i) + if (!match?.[1]) return null + const executable = path.win32.resolve(path.win32.dirname(command), match[1]) + return statSync(executable).isFile() ? executable : null + } catch { + return null + } +} + function normalizeWindowsPath(input: string): string | null { const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\")) if (!normalized) { diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts index 144aed98..bff0a0f3 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -1,4 +1,5 @@ import { realpath } from "fs/promises" +import path from "node:path" import type { LogLike } from "./git-worktrees" import { listWorktrees, resolveRepoRoot } from "./git-worktrees" @@ -97,3 +98,22 @@ export async function resolveWorktreeSlugForDirectory(params: { }) return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null } + +export async function isPathOwnedByWorktree(params: { + workspaceId: string + workspacePath: string + candidate: string + logger?: LogLike +}): Promise { + let target: string + try { + target = await realpath(params.candidate) + } catch { + return false + } + const cached = await getCachedWorktrees(params) + return cached.worktrees.some((worktree) => { + const relative = path.relative(worktree.normalizedDirectory, target) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + }) +} diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 9e381964..ec3f8a49 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -1014,15 +1014,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.1" @@ -1226,7 +1217,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1649,25 +1639,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1793,7 +1764,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -3403,11 +3373,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", - "futures-channel", "futures-core", - "futures-util", - "h2", "http", "http-body", "http-body-util", @@ -3416,7 +3382,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -3428,14 +3393,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3470,7 +3433,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -5231,19 +5194,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index d99219a8..eada6713 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ serde_json = "1" serde_yaml = "0.9" base64 = "0.22" rustls = { version = "0.23", features = ["ring"] } -reqwest = { version = "0.12", default-features = false, features = ["blocking", "http2", "charset", "json", "stream", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } regex = "1" parking_lot = "0.12" anyhow = "1" diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 78c42dd1..58bd937b 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,4 +1,3 @@ -use crate::desktop_event_transport::DesktopEventStreamConfig; use crate::managed_node::resolve_bundled_node_binary; use dirs::home_dir; use parking_lot::Mutex; @@ -565,15 +564,6 @@ fn generate_auth_cookie_name() -> String { format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } -fn generate_transport_connection_id() -> String { - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let tid = std::thread::current().id(); - format!("tauri-{}-{:?}", ts, tid) -} - const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] @@ -731,8 +721,6 @@ pub struct CliProcessManager { #[cfg(windows)] job: Arc>>, bootstrap_token: Arc>>, - session_cookie: Arc>>, - auth_cookie_name: Arc>>, lifecycle: Arc>, generation: Arc, } @@ -745,8 +733,6 @@ impl CliProcessManager { #[cfg(windows)] job: Arc::new(Mutex::new(None)), bootstrap_token: Arc::new(Mutex::new(None)), - session_cookie: Arc::new(Mutex::new(None)), - auth_cookie_name: Arc::new(Mutex::new(None)), lifecycle: Arc::new(Mutex::new(())), generation: Arc::new(AtomicU64::new(0)), } @@ -758,8 +744,6 @@ impl CliProcessManager { log_line(&format!("start requested (dev={dev})")); self.stop_tracked_child()?; *self.bootstrap_token.lock() = None; - *self.session_cookie.lock() = None; - *self.auth_cookie_name.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; @@ -855,7 +839,6 @@ impl CliProcessManager { status.port = None; status.url = None; status.error = None; - *self.session_cookie.lock() = None; } fn publish_error(&self, app: &AppHandle, generation: u64, message: String) { @@ -874,26 +857,6 @@ impl CliProcessManager { self.status.lock().clone() } - pub fn desktop_event_stream_config(&self) -> Option { - let base_url = self.status.lock().url.clone()?; - let events_url = format!("{}/api/events", base_url.trim_end_matches('/')); - let client_id = format!("tauri-{}", std::process::id()); - let cookie_name = self - .auth_cookie_name - .lock() - .clone() - .unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string()); - - Some(DesktopEventStreamConfig { - base_url, - events_url, - client_id, - connection_id: generate_transport_connection_id(), - cookie_name, - session_cookie: self.session_cookie.lock().clone(), - }) - } - fn spawn_cli( manager: CliProcessManager, app: AppHandle, @@ -1037,7 +1000,6 @@ impl CliProcessManager { let stdout = child.stdout.take().map(BufReader::new); let stderr = child.stderr.take().map(BufReader::new); debug_assert!(manager.child.lock().is_none()); - *manager.auth_cookie_name.lock() = Some(auth_cookie_name.as_str().to_string()); manager.status.lock().pid = Some(pid); *manager.child.lock() = Some(child); #[cfg(windows)] @@ -1317,9 +1279,6 @@ impl CliProcessManager { log_line(&format!("failed to set session cookie: {err}")); navigate_main(manager, generation, app, &format!("{base_url}/login")); } else { - manager.with_current_generation(generation, || { - *manager.session_cookie.lock() = Some(session_id.clone()); - }); navigate_main(manager, generation, app, &base_url); } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs deleted file mode 100644 index 372ca37c..00000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ /dev/null @@ -1,477 +0,0 @@ -use parking_lot::Mutex; -use reqwest::blocking::{Client, Response}; -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::io::{BufRead, BufReader}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Emitter, Manager, Url}; - -mod assembler; -mod stream; -mod transport; - -use stream::*; -use transport::*; - -const EVENT_BATCH_NAME: &str = "desktop:event-batch"; -const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; -const FLUSH_INTERVAL_MS: u64 = 16; -const DELTA_STREAM_WINDOW_MS: u64 = 48; -const MAX_BATCH_EVENTS: usize = 256; -const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; -const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; -const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0; -const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000; -const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000; -const STREAM_STALL_TIMEOUT_MS: u64 = 30_000; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DesktopEventStreamConfig { - pub base_url: String, - pub events_url: String, - pub client_id: String, - pub connection_id: String, - pub cookie_name: String, - pub session_cookie: Option, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct DesktopEventsStartRequest { - pub reconnect: Option, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct DesktopEventReconnectPolicy { - pub initial_delay_ms: Option, - pub max_delay_ms: Option, - pub multiplier: Option, - pub max_attempts: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopEventsStartResult { - pub started: bool, - pub generation: Option, - pub reason: Option, -} - -#[derive(Clone, Debug, PartialEq)] -struct ResolvedDesktopEventReconnectPolicy { - initial_delay_ms: u64, - max_delay_ms: u64, - multiplier: f64, - max_attempts: Option, -} - -impl ResolvedDesktopEventReconnectPolicy { - fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self { - let initial_delay_ms = policy - .and_then(|value| value.initial_delay_ms) - .unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS) - .max(1); - let max_delay_ms = policy - .and_then(|value| value.max_delay_ms) - .unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS) - .max(initial_delay_ms); - let multiplier = policy - .and_then(|value| value.multiplier) - .filter(|value| value.is_finite() && *value >= 1.0) - .unwrap_or(DEFAULT_RECONNECT_MULTIPLIER); - let max_attempts = policy - .and_then(|value| value.max_attempts) - .filter(|value| *value > 0); - - Self { - initial_delay_ms, - max_delay_ms, - multiplier, - max_attempts, - } - } -} - -#[derive(Clone, Debug, PartialEq)] -struct DesktopEventTransportConfig { - stream: DesktopEventStreamConfig, - reconnect: ResolvedDesktopEventReconnectPolicy, -} - -impl DesktopEventTransportConfig { - fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self { - Self { - stream, - reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()), - } - } - - fn is_equivalent_start(&self, other: &Self) -> bool { - self.reconnect == other.reconnect - && self.stream.base_url == other.stream.base_url - && self.stream.events_url == other.stream.events_url - && self.stream.client_id == other.stream.client_id - && self.stream.cookie_name == other.stream.cookie_name - && self.stream.session_cookie == other.stream.session_cookie - } -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct WorkspaceEventBatchPayload { - generation: u64, - sequence: u64, - emitted_at: u128, - events: Vec, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopEventStreamStatusPayload { - generation: u64, - state: &'static str, - reconnect_attempt: u32, - terminal: bool, - reason: Option, - next_delay_ms: Option, - status_code: Option, - stats: DesktopEventTransportStats, -} - -#[derive(Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -struct DesktopEventTransportStats { - raw_events: u64, - emitted_events: u64, - emitted_batches: u64, - delta_coalesces: u64, - snapshot_coalesces: u64, - status_coalesces: u64, - superseded_deltas_dropped: u64, -} - -struct DesktopEventTransportState { - stop: Option>, - config: Option, -} - -pub struct DesktopEventTransportManager { - state: Arc>, - generation: Arc, -} - -enum ReaderMessage { - Activity, - Event(Value), - Ping(Value), - End(Option), -} - -enum PendingEntry { - Delta { - key: String, - scope: String, - event: Value, - started_at: Instant, - }, - Status { - key: String, - event: Value, - }, - Snapshot { - key: String, - event: Value, - }, - Event(Value), -} - -enum EventDeliveryPolicy { - CoalesceDelta(String), - CoalesceStatus(String), - CoalesceSnapshot(String), - Passthrough, -} - -enum OpenStreamErrorKind { - Unauthorized, - Http, - Transport, -} - -struct OpenStreamError { - kind: OpenStreamErrorKind, - message: String, - status_code: Option, -} - -#[derive(Default)] -struct PendingBatch { - events: Vec, -} - -impl DesktopEventTransportManager { - pub fn new() -> Self { - Self { - state: Arc::new(Mutex::new(DesktopEventTransportState { - stop: None, - config: None, - })), - generation: Arc::new(AtomicU64::new(0)), - } - } - - pub fn start( - &self, - app: AppHandle, - stream_config: Option, - request: Option, - ) -> DesktopEventsStartResult { - let Some(stream_config) = stream_config else { - return DesktopEventsStartResult { - started: false, - generation: None, - reason: Some("desktop event stream unavailable".to_string()), - }; - }; - - let request = request.unwrap_or_default(); - let transport_config = DesktopEventTransportConfig::new(stream_config, &request); - - let mut state = self.state.lock(); - if state - .config - .as_ref() - .is_some_and(|config| config.is_equivalent_start(&transport_config)) - { - if let Some(stop) = &state.stop { - if !stop.load(Ordering::SeqCst) { - return DesktopEventsStartResult { - started: true, - generation: Some(self.generation.load(Ordering::SeqCst)), - reason: None, - }; - } - } - } - - if let Some(stop) = state.stop.take() { - stop.store(true, Ordering::SeqCst); - } - - let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; - let stop = Arc::new(AtomicBool::new(false)); - state.stop = Some(stop.clone()); - state.config = Some(transport_config.clone()); - let shared_generation = self.generation.clone(); - drop(state); - - thread::spawn(move || { - run_transport_loop(app, shared_generation, generation, stop, transport_config) - }); - - DesktopEventsStartResult { - started: true, - generation: Some(generation), - reason: None, - } - } - - pub fn stop(&self) { - let mut state = self.state.lock(); - if let Some(stop) = state.stop.take() { - stop.store(true, Ordering::SeqCst); - } - state.config = None; - self.generation.fetch_add(1, Ordering::SeqCst); - } -} - -fn classify_event(event: &Value) -> EventDeliveryPolicy { - if let Some(key) = delta_key(event) { - return EventDeliveryPolicy::CoalesceDelta(key); - } - - if let Some(key) = status_key(event) { - return EventDeliveryPolicy::CoalesceStatus(key); - } - - if let Some(key) = snapshot_key(event) { - return EventDeliveryPolicy::CoalesceSnapshot(key); - } - - EventDeliveryPolicy::Passthrough -} - -fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value { - if event.get("type").and_then(Value::as_str) == Some("instance.event") { - event.get("event").unwrap_or(event) - } else { - event - } -} - -fn coalesced_instance_id(event: &Value) -> &str { - event - .get("instanceId") - .and_then(Value::as_str) - .unwrap_or_default() -} - -fn snapshot_key(event: &Value) -> Option { - let instance_id = coalesced_instance_id(event); - let inner = coalesced_payload_event(event); - let inner_type = inner.get("type")?.as_str()?; - let props = inner.get("properties")?; - - match inner_type { - "message.part.updated" => { - let session_id = props - .get("part") - .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) - .and_then(Value::as_str)?; - let message_id = props - .get("part") - .and_then(|part| part.get("messageID").or_else(|| part.get("messageId"))) - .and_then(Value::as_str)?; - let part_id = props - .get("part") - .and_then(|part| part.get("id")) - .and_then(Value::as_str)?; - - Some(format!( - "message.part.updated:{}:{}:{}:{}", - instance_id, session_id, message_id, part_id - )) - } - "message.updated" => { - let info = props.get("info")?; - let session_id = info - .get("sessionID") - .or_else(|| info.get("sessionId")) - .and_then(Value::as_str)?; - let message_id = info.get("id").and_then(Value::as_str)?; - - Some(format!( - "message.updated:{}:{}:{}", - instance_id, session_id, message_id - )) - } - "session.updated" | "session.status" => { - let session_id = props - .get("info") - .and_then(|info| info.get("id")) - .and_then(Value::as_str) - .or_else(|| { - props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str) - })?; - - Some(format!("{}:{}:{}", inner_type, instance_id, session_id)) - } - _ => None, - } -} - -fn delta_scope(event: &Value) -> Option { - let instance_id = coalesced_instance_id(event); - let inner = coalesced_payload_event(event); - if inner.get("type")?.as_str()? != "message.part.delta" { - return None; - } - - let props = inner.get("properties")?; - let session_id = props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str) - .unwrap_or_default(); - let message_id = props - .get("messageID") - .or_else(|| props.get("messageId")) - .and_then(Value::as_str)?; - let part_id = props - .get("partID") - .or_else(|| props.get("partId")) - .and_then(Value::as_str)?; - - Some(format!( - "message.part:{}:{}:{}:{}", - instance_id, session_id, message_id, part_id - )) -} - -fn delta_key(event: &Value) -> Option { - let scope = delta_scope(event)?; - let props = coalesced_payload_event(event).get("properties")?; - let field = props.get("field")?.as_str()?; - - Some(format!("{}:{}", scope, field)) -} - -fn snapshot_superseded_delta_scope(event: &Value) -> Option { - let instance_id = coalesced_instance_id(event); - let inner = coalesced_payload_event(event); - if inner.get("type")?.as_str()? != "message.part.updated" { - return None; - } - - let part = inner.get("properties")?.get("part")?; - let session_id = part - .get("sessionID") - .or_else(|| part.get("sessionId")) - .and_then(Value::as_str)?; - let message_id = part - .get("messageID") - .or_else(|| part.get("messageId")) - .and_then(Value::as_str)?; - let part_id = part.get("id")?.as_str()?; - - Some(format!( - "message.part:{}:{}:{}:{}", - instance_id, session_id, message_id, part_id - )) -} - -fn append_delta(target: &mut Value, event: &Value) { - let next_delta = coalesced_payload_event(event) - .get("properties") - .and_then(|value| value.get("delta")) - .and_then(Value::as_str) - .unwrap_or_default(); - - if let Some(existing_delta) = coalesced_payload_event_mut(target) - .and_then(|event| event.get_mut("properties")) - .and_then(Value::as_object_mut) - .and_then(|props| props.get_mut("delta")) - { - let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta; - *existing_delta = Value::String(combined); - } -} - -fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map> { - if event.get("type").and_then(Value::as_str) == Some("instance.event") { - event.get_mut("event").and_then(Value::as_object_mut) - } else { - event.as_object_mut() - } -} - -fn status_key(event: &Value) -> Option { - match event.get("type")?.as_str()? { - "instance.eventStatus" => Some(coalesced_instance_id(event).to_string()), - "session.status" => snapshot_key(event), - _ => None, - } -} - -#[cfg(test)] -mod tests; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs deleted file mode 100644 index f91bcb76..00000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs +++ /dev/null @@ -1,112 +0,0 @@ -use super::*; - -impl PendingBatch { - pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) { - match classify_event(&event) { - EventDeliveryPolicy::CoalesceDelta(key) => { - let Some(scope) = delta_scope(&event) else { - self.events.push(PendingEntry::Event(event)); - return; - }; - - if let Some(PendingEntry::Delta { - key: existing_key, - event: existing_event, - .. - }) = self.events.last_mut() - { - if existing_key == &key { - append_delta(existing_event, &event); - stats.delta_coalesces = stats.delta_coalesces.saturating_add(1); - return; - } - } - - self.events.push(PendingEntry::Delta { - key, - scope, - event, - started_at: Instant::now(), - }); - } - EventDeliveryPolicy::CoalesceStatus(key) => { - if let Some(PendingEntry::Status { - key: existing_key, - event: existing_event, - }) = self.events.last_mut() - { - if existing_key == &key { - *existing_event = event; - stats.status_coalesces = stats.status_coalesces.saturating_add(1); - return; - } - } - - self.events.push(PendingEntry::Status { key, event }); - } - EventDeliveryPolicy::CoalesceSnapshot(key) => { - if let Some(part_scope) = snapshot_superseded_delta_scope(&event) { - let mut dropped = 0_u64; - while matches!( - self.events.last(), - Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope - ) { - self.events.pop(); - dropped = dropped.saturating_add(1); - } - if dropped > 0 { - stats.superseded_deltas_dropped = - stats.superseded_deltas_dropped.saturating_add(dropped); - } - } - - if let Some(PendingEntry::Snapshot { - key: existing_key, - event: existing_event, - }) = self.events.last_mut() - { - if existing_key == &key { - *existing_event = event; - stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1); - return; - } - } - - self.events.push(PendingEntry::Snapshot { key, event }); - } - EventDeliveryPolicy::Passthrough => { - self.events.push(PendingEntry::Event(event)); - } - } - } - - pub(super) fn take_events(&mut self) -> Vec { - let pending = std::mem::take(&mut self.events); - pending - .into_iter() - .map(|entry| match entry { - PendingEntry::Delta { event, .. } => event, - PendingEntry::Status { event, .. } => event, - PendingEntry::Snapshot { event, .. } => event, - PendingEntry::Event(event) => event, - }) - .collect() - } - - pub(super) fn is_empty(&self) -> bool { - self.events.is_empty() - } - - pub(super) fn pending_len(&self) -> usize { - self.events.len() - } - - pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool { - matches!( - self.events.as_slice(), - [PendingEntry::Delta { started_at, .. }] - if now.duration_since(*started_at) - < Duration::from_millis(DELTA_STREAM_WINDOW_MS) - ) - } -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs deleted file mode 100644 index 33737f9c..00000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ /dev/null @@ -1,325 +0,0 @@ -use super::*; -use reqwest::blocking::RequestBuilder; - -pub(super) fn build_stream_client() -> Result { - Client::builder() - .connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS)) - .tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS)) - // Note: reqwest's blocking client doesn't expose a per-read timeout. - // The global `.timeout()` would kill the entire SSE stream, so we - // rely on: - // 1. tcp_keepalive to detect dead connections (OS will RST after - // several unacked probes, typically ~2 min). - // 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS). - // 3. Reader thread breaking on channel send error (consumer dropped). - .build() - .map_err(|error: reqwest::Error| OpenStreamError { - kind: OpenStreamErrorKind::Transport, - message: error.to_string(), - status_code: None, - }) -} - -pub(super) fn open_stream( - app: &AppHandle, - client: &Client, - config: &DesktopEventStreamConfig, -) -> Result { - let url = format!( - "{}?clientId={}&connectionId={}", - config.events_url, config.client_id, config.connection_id - ); - - let request = attach_session_cookie( - client.get(&url).header("Accept", "text/event-stream"), - app, - config, - ); - - let response = request.send().map_err(|error| OpenStreamError { - kind: OpenStreamErrorKind::Transport, - message: error.to_string(), - status_code: None, - })?; - - if response.status().is_success() { - return Ok(response); - } - - let status = response.status(); - let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { - OpenStreamErrorKind::Unauthorized - } else { - OpenStreamErrorKind::Http - }; - - Err(OpenStreamError { - kind, - message: format!("desktop event stream unavailable ({status})"), - status_code: Some(status.as_u16()), - }) -} - -fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option { - read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name) - .or_else(|| config.session_cookie.clone()) - .filter(|value| !value.is_empty()) -} - -pub(super) fn attach_session_cookie( - request: RequestBuilder, - app: &AppHandle, - config: &DesktopEventStreamConfig, -) -> RequestBuilder { - attach_session_cookie_value( - request, - &config.cookie_name, - resolve_session_cookie(app, config).as_deref(), - ) -} - -fn attach_session_cookie_value( - request: RequestBuilder, - cookie_name: &str, - session_cookie: Option<&str>, -) -> RequestBuilder { - let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else { - return request; - }; - - request.header( - "Cookie", - format!( - "{}={}", - cookie_name, - encode_cookie_header_value(session_cookie) - ), - ) -} - -fn encode_cookie_header_value(value: &str) -> String { - let mut encoded = String::new(); - - for byte in value.bytes() { - if is_cookie_header_value_byte(byte) { - encoded.push(byte as char); - } else { - encoded.push_str(&format!("%{byte:02X}")); - } - } - - encoded -} - -fn is_cookie_header_value_byte(byte: u8) -> bool { - matches!( - byte, - b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~' - ) -} - -fn read_session_cookie_from_webview( - app: &AppHandle, - base_url: &str, - cookie_name: &str, -) -> Option { - let url = Url::parse(base_url).ok()?; - let host = url.host_str()?.to_ascii_lowercase(); - let path = url.path(); - let windows = app.webview_windows(); - let window = windows.get("main")?; - let cookies = window.cookies().ok()?; - cookies - .into_iter() - .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name) - .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { - let Some(domain) = cookie.domain() else { - return true; - }; - - let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase(); - host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain)) - }) - .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { - let Some(cookie_path) = cookie.path() else { - return true; - }; - - path.starts_with(cookie_path) - }) - .map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string()) - .next() -} - -pub(super) fn read_sse( - response: Response, - tx: SyncSender, - stop: Arc, - generation_atomic: Arc, - generation: u64, -) { - let mut reader = BufReader::new(response); - let mut line = String::new(); - let mut event_name: Option = None; - let mut data_lines: Vec = Vec::new(); - - loop { - if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { - let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); - return; - } - - line.clear(); - match reader.read_line(&mut line) { - Ok(0) => { - let _ = flush_sse_frame(&tx, &event_name, &data_lines); - let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); - return; - } - Ok(_) => { - if tx.send(ReaderMessage::Activity).is_err() { - return; // consumer dropped — stop reading - } - let trimmed = line.trim_end_matches(['\r', '\n']); - if handle_sse_line(trimmed, &mut event_name, &mut data_lines) { - if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { - return; - } - event_name = None; - data_lines.clear(); - continue; - } - } - Err(error) => { - let _ = flush_sse_frame(&tx, &event_name, &data_lines); - let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); - return; - } - } - } -} - -fn handle_sse_line( - trimmed: &str, - event_name: &mut Option, - data_lines: &mut Vec, -) -> bool { - if trimmed.is_empty() { - return true; - } - - if trimmed.starts_with(':') { - return false; - } - - if let Some(name) = trimmed.strip_prefix("event:") { - *event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); - return false; - } - - if let Some(data) = trimmed.strip_prefix("data:") { - data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); - } - - false -} - -fn flush_sse_frame( - tx: &SyncSender, - event_name: &Option, - lines: &[String], -) -> Result<(), ()> { - let Some(payload) = parse_sse_payload(lines) else { - return Ok(()); - }; - - if event_name.as_deref() == Some("codenomad.client.ping") { - tx.send(ReaderMessage::Ping(payload)).map_err(|_| ()) - } else { - tx.send(ReaderMessage::Event(payload)).map_err(|_| ()) - } -} - -fn parse_sse_payload(lines: &[String]) -> Option { - if lines.is_empty() { - return None; - } - - let payload = lines.join("\n").trim().to_string(); - if payload.is_empty() { - return None; - } - - serde_json::from_str::(&payload).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn named_ping_event_is_routed_to_ping_channel() { - let (tx, rx) = mpsc::sync_channel(1); - let mut event_name = None; - let mut data_lines = Vec::new(); - - assert!(!handle_sse_line( - "event: codenomad.client.ping", - &mut event_name, - &mut data_lines - )); - assert!(!handle_sse_line( - r#"data: {"ts":123}"#, - &mut event_name, - &mut data_lines - )); - assert!(handle_sse_line("", &mut event_name, &mut data_lines)); - - flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush"); - - match rx.recv().expect("ping frame should be emitted") { - ReaderMessage::Ping(payload) => { - assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123)); - } - _ => panic!("expected ping frame"), - } - } - - #[test] - fn session_cookie_is_attached_to_requests() { - let request = attach_session_cookie_value( - Client::new().post("http://localhost/api/client-connections/pong"), - "codenomad_session", - Some("cookie-value"), - ) - .build() - .expect("request should build"); - - assert_eq!( - request - .headers() - .get("Cookie") - .and_then(|value| value.to_str().ok()), - Some("codenomad_session=cookie-value") - ); - } - - #[test] - fn session_cookie_value_is_encoded_before_header_attachment() { - let request = attach_session_cookie_value( - Client::new().post("http://localhost/api/client-connections/pong"), - "codenomad_session", - Some("safe;\r\nInjected=bad value"), - ) - .build() - .expect("request should build"); - - assert_eq!( - request - .headers() - .get("Cookie") - .and_then(|value| value.to_str().ok()), - Some("codenomad_session=safe%3B%0D%0AInjected=bad%20value") - ); - } -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs deleted file mode 100644 index f2440a20..00000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ /dev/null @@ -1,374 +0,0 @@ -use super::*; -use serde_json::json; - -fn fresh_stats() -> DesktopEventTransportStats { - DesktopEventTransportStats::default() -} - -fn stream_config(connection_id: &str) -> DesktopEventStreamConfig { - DesktopEventStreamConfig { - base_url: "http://127.0.0.1:4096".to_string(), - events_url: "http://127.0.0.1:4096/api/events".to_string(), - client_id: "tauri-test".to_string(), - connection_id: connection_id.to_string(), - cookie_name: "codenomad_session".to_string(), - session_cookie: Some("cookie-value".to_string()), - } -} - -fn delta_event(delta: &str) -> Value { - json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.part.delta", - "properties": { - "sessionID": "sess-1", - "messageID": "msg-1", - "partID": "part-1", - "field": "text", - "delta": delta, - } - } - }) -} - -fn delta_event_for(part_id: &str, delta: &str) -> Value { - json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.part.delta", - "properties": { - "sessionID": "sess-1", - "messageID": "msg-1", - "partID": part_id, - "field": "text", - "delta": delta, - } - } - }) -} - -fn direct_delta_event(delta: &str) -> Value { - json!({ - "type": "message.part.delta", - "properties": { - "sessionID": "sess-1", - "messageID": "msg-1", - "partID": "part-1", - "field": "text", - "delta": delta, - } - }) -} - -fn direct_message_part_updated_event(text: &str) -> Value { - json!({ - "type": "message.part.updated", - "properties": { - "part": { - "id": "part-1", - "type": "text", - "text": text, - "sessionID": "sess-1", - "messageID": "msg-1" - } - } - }) -} - -fn message_part_updated_event(text: &str) -> Value { - json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.part.updated", - "properties": { - "part": { - "id": "part-1", - "type": "text", - "text": text, - "sessionID": "sess-1", - "messageID": "msg-1" - } - } - } - }) -} - -#[test] -fn coalesces_message_part_delta_events() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(delta_event("Hello"), &mut stats); - pending.push(delta_event(" world"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!( - events[0]["event"]["properties"]["delta"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn last_write_wins_for_status_events() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push( - json!({ - "type": "instance.eventStatus", - "instanceId": "inst-1", - "status": "connecting" - }), - &mut stats, - ); - pending.push( - json!({ - "type": "instance.eventStatus", - "instanceId": "inst-1", - "status": "connected" - }), - &mut stats, - ); - - let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0]["status"].as_str(), Some("connected")); -} - -#[test] -fn last_write_wins_for_consecutive_snapshot_events() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(message_part_updated_event("Hello"), &mut stats); - pending.push(message_part_updated_event("Hello world"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!( - events[0]["event"]["properties"]["part"]["text"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn interleaved_snapshot_keys_keep_order() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(message_part_updated_event("A1"), &mut stats); - pending.push( - json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.part.updated", - "properties": { - "part": { - "id": "part-2", - "type": "text", - "text": "B1", - "sessionID": "sess-1", - "messageID": "msg-1" - } - } - } - }), - &mut stats, - ); - pending.push(message_part_updated_event("A2"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 3); - assert_eq!( - events[0]["event"]["properties"]["part"]["id"].as_str(), - Some("part-1") - ); - assert_eq!( - events[1]["event"]["properties"]["part"]["id"].as_str(), - Some("part-2") - ); - assert_eq!( - events[2]["event"]["properties"]["part"]["text"].as_str(), - Some("A2") - ); -} - -#[test] -fn snapshot_replaces_trailing_deltas_for_same_part() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(delta_event("Hello"), &mut stats); - pending.push(message_part_updated_event("Hello world"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!( - events[0]["event"]["type"].as_str(), - Some("message.part.updated") - ); - assert_eq!( - events[0]["event"]["properties"]["part"]["text"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn structural_events_force_coalesced_flush_before_append() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(delta_event("Hello"), &mut stats); - pending.push( - json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.updated", - "properties": { - "id": "msg-1" - } - } - }), - &mut stats, - ); - - let events = pending.take_events(); - assert_eq!(events.len(), 2); - assert_eq!( - events[0]["event"]["type"].as_str(), - Some("message.part.delta") - ); - assert_eq!(events[1]["event"]["type"].as_str(), Some("message.updated")); -} - -#[test] -fn interleaved_delta_keys_keep_order() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(delta_event_for("part-1", "A1"), &mut stats); - pending.push(delta_event_for("part-2", "B1"), &mut stats); - pending.push(delta_event_for("part-1", "A2"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 3); - assert_eq!( - events[0]["event"]["properties"]["partID"].as_str(), - Some("part-1") - ); - assert_eq!( - events[0]["event"]["properties"]["delta"].as_str(), - Some("A1") - ); - assert_eq!( - events[1]["event"]["properties"]["partID"].as_str(), - Some("part-2") - ); - assert_eq!( - events[1]["event"]["properties"]["delta"].as_str(), - Some("B1") - ); - assert_eq!( - events[2]["event"]["properties"]["partID"].as_str(), - Some("part-1") - ); - assert_eq!( - events[2]["event"]["properties"]["delta"].as_str(), - Some("A2") - ); -} - -#[test] -fn reconnect_delay_grows_and_caps() { - let policy = ResolvedDesktopEventReconnectPolicy { - initial_delay_ms: 100, - max_delay_ms: 500, - multiplier: 2.0, - max_attempts: None, - }; - - assert_eq!(compute_reconnect_delay_ms(1, &policy), 100); - assert_eq!(compute_reconnect_delay_ms(2, &policy), 200); - assert_eq!(compute_reconnect_delay_ms(3, &policy), 400); - assert_eq!(compute_reconnect_delay_ms(4, &policy), 500); -} - -#[test] -fn holds_single_delta_within_stream_window() { - let pending = PendingBatch { - events: vec![PendingEntry::Delta { - key: "delta-key".to_string(), - scope: "delta-scope".to_string(), - event: delta_event("Hello"), - started_at: Instant::now(), - }], - }; - - assert!(pending.should_hold_single_delta(Instant::now())); -} - -#[test] -fn flushes_single_delta_after_stream_window() { - let started_at = Instant::now() - Duration::from_millis(DELTA_STREAM_WINDOW_MS + 1); - let pending = PendingBatch { - events: vec![PendingEntry::Delta { - key: "delta-key".to_string(), - scope: "delta-scope".to_string(), - event: delta_event("Hello"), - started_at, - }], - }; - - assert!(!pending.should_hold_single_delta(Instant::now())); -} - -#[test] -fn coalesces_direct_message_part_delta_events() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(direct_delta_event("Hello"), &mut stats); - pending.push(direct_delta_event(" world"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!( - events[0]["properties"]["delta"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() { - let mut pending = PendingBatch::default(); - let mut stats = fresh_stats(); - pending.push(direct_delta_event("Hello"), &mut stats); - pending.push(direct_message_part_updated_event("Hello world"), &mut stats); - - let events = pending.take_events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0]["type"].as_str(), Some("message.part.updated")); - assert_eq!( - events[0]["properties"]["part"]["text"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn equivalent_transport_start_ignores_fresh_connection_id() { - let request = DesktopEventsStartRequest::default(); - let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request); - let second = DesktopEventTransportConfig::new(stream_config("conn-2"), &request); - - assert!(first.is_equivalent_start(&second)); -} - -#[test] -fn equivalent_transport_start_detects_material_stream_changes() { - let request = DesktopEventsStartRequest::default(); - let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request); - let mut changed_stream = stream_config("conn-2"); - changed_stream.session_cookie = Some("other-cookie".to_string()); - let second = DesktopEventTransportConfig::new(changed_stream, &request); - - assert!(!first.is_equivalent_start(&second)); -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs deleted file mode 100644 index 5f0ed231..00000000 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ /dev/null @@ -1,428 +0,0 @@ -use super::*; - -fn send_connection_pong( - app: &AppHandle, - client: &Client, - config: &DesktopEventStreamConfig, - payload: &Value, -) { - let body = serde_json::json!({ - "clientId": config.client_id, - "connectionId": config.connection_id, - "pingTs": payload.get("ts").and_then(Value::as_u64), - }); - - let request = client - .post(format!( - "{}/api/client-connections/pong", - config.base_url.trim_end_matches('/') - )) - .json(&body); - - let _ = attach_session_cookie(request, app, config).send(); -} - -pub(super) fn run_transport_loop( - app: AppHandle, - generation_atomic: Arc, - generation: u64, - stop: Arc, - config: DesktopEventTransportConfig, -) { - let mut reconnect_attempt = 0_u32; - let mut stats = DesktopEventTransportStats::default(); - - let client = match build_stream_client() { - Ok(client) => client, - Err(error) => { - emit_status( - &app, - generation, - "error", - 0, - true, - Some(error.message), - None, - None, - &stats, - ); - return; - } - }; - - loop { - if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { - break; - } - - emit_status( - &app, - generation, - "connecting", - reconnect_attempt, - false, - None, - None, - None, - &stats, - ); - - match open_stream(&app, &client, &config.stream) { - Ok(response) => { - reconnect_attempt = 0; - emit_status( - &app, - generation, - "connected", - reconnect_attempt, - false, - None, - None, - None, - &stats, - ); - - let disconnect_reason = consume_stream( - &app, - &client, - &config.stream, - response, - &generation_atomic, - generation, - stop.clone(), - &mut stats, - ); - if stop.load(Ordering::SeqCst) - || !generation_matches(&generation_atomic, generation) - { - break; - } - - if !schedule_retry( - &app, - &generation_atomic, - generation, - stop.clone(), - &config.reconnect, - &mut reconnect_attempt, - "disconnected", - disconnect_reason, - None, - &stats, - ) { - break; - } - } - Err(error) => { - let state_name = match error.kind { - OpenStreamErrorKind::Unauthorized => "unauthorized", - OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error", - }; - - if !schedule_retry( - &app, - &generation_atomic, - generation, - stop.clone(), - &config.reconnect, - &mut reconnect_attempt, - state_name, - Some(error.message), - error.status_code, - &stats, - ) { - break; - } - } - } - } - - emit_status( - &app, - generation, - "stopped", - reconnect_attempt, - true, - None, - None, - None, - &stats, - ); -} - -fn schedule_retry( - app: &AppHandle, - generation_atomic: &Arc, - generation: u64, - stop: Arc, - policy: &ResolvedDesktopEventReconnectPolicy, - reconnect_attempt: &mut u32, - state_name: &'static str, - reason: Option, - status_code: Option, - stats: &DesktopEventTransportStats, -) -> bool { - *reconnect_attempt = reconnect_attempt.saturating_add(1); - let terminal = policy - .max_attempts - .map(|max_attempts| *reconnect_attempt >= max_attempts) - .unwrap_or(false); - let next_delay_ms = if terminal { - None - } else { - Some(compute_reconnect_delay_ms(*reconnect_attempt, policy)) - }; - - emit_status( - app, - generation, - state_name, - *reconnect_attempt, - terminal, - reason, - next_delay_ms, - status_code, - stats, - ); - - if terminal { - return false; - } - - if let Some(delay_ms) = next_delay_ms { - wait_with_cancellation(generation_atomic, generation, stop, delay_ms); - } - - true -} - -fn wait_with_cancellation( - generation_atomic: &Arc, - generation: u64, - stop: Arc, - delay_ms: u64, -) { - let mut remaining_ms = delay_ms; - while remaining_ms > 0 { - if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { - return; - } - - let chunk_ms = remaining_ms.min(100); - thread::sleep(Duration::from_millis(chunk_ms)); - remaining_ms -= chunk_ms; - } -} - -fn consume_stream( - app: &AppHandle, - client: &Client, - stream_config: &DesktopEventStreamConfig, - response: Response, - generation_atomic: &Arc, - generation: u64, - stop: Arc, - stats: &mut DesktopEventTransportStats, -) -> Option { - let (tx, rx) = mpsc::sync_channel::(4096); - let reader_stop = stop.clone(); - let reader_generation_atomic = generation_atomic.clone(); - thread::spawn(move || { - read_sse( - response, - tx, - reader_stop, - reader_generation_atomic, - generation, - ) - }); - - let mut pending = PendingBatch::default(); - let mut sequence = 0_u64; - let mut last_reader_activity = Instant::now(); - - loop { - if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { - return Some("stopped".to_string()); - } - - match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) { - Ok(ReaderMessage::Activity) => { - last_reader_activity = Instant::now(); - } - Ok(ReaderMessage::Ping(payload)) => { - last_reader_activity = Instant::now(); - send_connection_pong(app, client, stream_config, &payload); - } - Ok(ReaderMessage::Event(event)) => { - last_reader_activity = Instant::now(); - stats.raw_events = stats.raw_events.saturating_add(1); - - pending.push(event, stats); - if pending.pending_len() >= MAX_BATCH_EVENTS { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - } - Ok(ReaderMessage::End(reason)) => { - if !pending.is_empty() { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - return reason; - } - Err(RecvTimeoutError::Timeout) => { - if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) - { - if !pending.is_empty() { - sequence += 1; - emit_batch( - app, - generation, - &mut pending, - sequence, - generation_atomic, - stats, - ); - } - return Some("stream stalled".to_string()); - } - - if !pending.is_empty() { - if pending.should_hold_single_delta(Instant::now()) { - continue; - } - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - } - Err(RecvTimeoutError::Disconnected) => { - if !pending.is_empty() { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - return Some("reader disconnected".to_string()); - } - } - } -} - -fn emit_pending_batch( - app: &AppHandle, - generation: u64, - pending: &mut PendingBatch, - sequence: &mut u64, - generation_atomic: &Arc, - stats: &mut DesktopEventTransportStats, -) { - if pending.is_empty() { - return; - } - - *sequence += 1; - emit_batch( - app, - generation, - pending, - *sequence, - generation_atomic, - stats, - ); -} - -fn emit_batch( - app: &AppHandle, - generation: u64, - pending: &mut PendingBatch, - sequence: u64, - generation_atomic: &Arc, - stats: &mut DesktopEventTransportStats, -) { - if !generation_matches(generation_atomic, generation) { - return; - } - - let events = pending.take_events(); - if events.is_empty() { - return; - } - - stats.emitted_batches = stats.emitted_batches.saturating_add(1); - stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64); - - let _ = app.emit( - EVENT_BATCH_NAME, - WorkspaceEventBatchPayload { - generation, - sequence, - emitted_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - events, - }, - ); -} - -fn emit_status( - app: &AppHandle, - generation: u64, - state_name: &'static str, - reconnect_attempt: u32, - terminal: bool, - reason: Option, - next_delay_ms: Option, - status_code: Option, - stats: &DesktopEventTransportStats, -) { - let _ = app.emit( - EVENT_STATUS_NAME, - DesktopEventStreamStatusPayload { - generation, - state: state_name, - reconnect_attempt, - terminal, - reason, - next_delay_ms, - status_code, - stats: stats.clone(), - }, - ); -} - -pub(super) fn generation_matches(generation_atomic: &Arc, generation: u64) -> bool { - generation_atomic.load(Ordering::SeqCst) == generation -} - -pub(super) fn compute_reconnect_delay_ms( - attempt: u32, - policy: &ResolvedDesktopEventReconnectPolicy, -) -> u64 { - let exponent = attempt.saturating_sub(1) as i32; - let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent); - (scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms) -} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4dc14261..1ccd3842 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -4,7 +4,6 @@ mod cert_manager; mod cli_manager; mod client_state; -mod desktop_event_transport; #[cfg(target_os = "linux")] mod linux_tls; mod managed_node; @@ -12,9 +11,6 @@ mod shutdown; mod windows_update; use cli_manager::{CliProcessManager, CliStatus}; -use desktop_event_transport::{ - DesktopEventTransportManager, DesktopEventsStartRequest, DesktopEventsStartResult, -}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; @@ -54,7 +50,6 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; pub struct AppState { pub manager: CliProcessManager, - pub desktop_events: DesktopEventTransportManager, pub wake_lock: Mutex>, pub remote_origins: Mutex>, pub remote_proxy_sessions: Mutex>, @@ -138,7 +133,6 @@ fn cli_get_status(state: tauri::State) -> CliStatus { #[tauri::command] fn cli_restart(app: AppHandle, state: tauri::State) -> Result { let dev_mode = is_dev_mode(); - state.desktop_events.stop(); state.manager.stop().map_err(|e| e.to_string())?; state .manager @@ -147,21 +141,6 @@ fn cli_restart(app: AppHandle, state: tauri::State) -> Result, - request: Option, -) -> DesktopEventsStartResult { - let config = state.manager.desktop_event_stream_config(); - state.desktop_events.start(app, config, request) -} - -#[tauri::command] -fn desktop_events_stop(state: tauri::State) { - state.desktop_events.stop(); -} - #[tauri::command] fn wake_lock_start( state: tauri::State, @@ -598,7 +577,6 @@ fn main() { .plugin(navigation_guard) .manage(AppState { manager: CliProcessManager::new(), - desktop_events: DesktopEventTransportManager::new(), wake_lock: Mutex::new(None), remote_origins: Mutex::new(HashMap::new()), remote_proxy_sessions: Mutex::new(HashMap::new()), @@ -660,8 +638,6 @@ fn main() { .invoke_handler(tauri::generate_handler![ cli_get_status, cli_restart, - desktop_events_start, - desktop_events_stop, wake_lock_start, wake_lock_stop, needs_local_certificate_install, diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 8fab8fae..037df8ae 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -242,7 +242,6 @@ fn cleanup(app: &AppHandle, capture_window: bool) -> Result<(), String> { client_state::flush_and_release_without_window_capture(app); } if let Some(state) = app.try_state::() { - state.desktop_events.stop(); retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { state.manager.stop().map_err(|err| err.to_string()) })?; diff --git a/packages/ui/package.json b/packages/ui/package.json index b9081773..03a5a37e 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/client": "0.0.0-next-17288", + "@opencode-ai/client": "0.0.0-next-17353", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2fa50eec..6557e6fb 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -108,8 +108,6 @@ const App: Component = () => { const { preferences, recentFolders, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, @@ -571,8 +569,6 @@ const App: Component = () => { const { commands: paletteCommands, executeCommand } = useCommands({ preferences, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/components/provider-auth/provider-auth-form.tsx b/packages/ui/src/components/provider-auth/provider-auth-form.tsx index b9bdb6a3..3c5f3617 100644 --- a/packages/ui/src/components/provider-auth/provider-auth-form.tsx +++ b/packages/ui/src/components/provider-auth/provider-auth-form.tsx @@ -3,7 +3,7 @@ import type { FormAnswer, FormField, FormFields, FormValue } from "@opencode-ai/ import { ChevronDown, ExternalLink } from "lucide-solid" import { createMemo, For, Show, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" -import { shouldShowProviderAuthField } from "../../lib/provider-auth" +import { isProviderAuthHttpUrl, shouldShowProviderAuthField } from "../../lib/provider-auth" type Option = { label: string; value: string; description?: string } @@ -46,13 +46,15 @@ export const ProviderAuthForm: Component<{ - - - {title(field)} - - {field.description} - + +
+ + + {title(field)} + + {field.description} +
+
)} >
diff --git a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx index b3c1e4a6..b1002086 100644 --- a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx +++ b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx @@ -11,6 +11,7 @@ import { genericApiMethod, getProviderAuthAnswer, getProviderAuthInitialAnswer, + isProviderAuthHttpUrl, isProviderAuthFieldComplete, isAbortError, shouldShowProviderAuthField, @@ -188,19 +189,27 @@ export const ProviderManagerModal: Component = (props return null } + let popup: Window | null = null try { - const popup = window.open("", "_blank") + popup = window.open("", "_blank") if (popup && popup.document) { + popup.opener = null popup.document.title = t("settings.providers.oauth.popup.loadingTitle") popup.document.body.innerHTML = `
${t("settings.providers.oauth.popup.loadingBody")}
` } return popup } catch { + popup?.close() return null } } async function launchAuthorizationUrl(url: string, options?: { popup?: Window | null; sameTab?: boolean }): Promise { + if (!isProviderAuthHttpUrl(url)) { + if (options?.popup && !options.popup.closed) options.popup.close() + return false + } + if (options?.sameTab && typeof window !== "undefined") { window.location.assign(url) return true @@ -708,7 +717,7 @@ export const ProviderManagerModal: Component = (props
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
{selectedMethod().type === "command" ? t("settings.providers.command.waitingTitle") : t("settings.providers.oauth.waitingTitle")}
{selectedMethod().type === "command" ? commandStatusMessage() ?? t("settings.providers.command.waitingMessage") : authorization()?.instructions}
- +
diff --git a/packages/ui/src/components/settings/advanced-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx index 6de2da02..eff5ca1a 100644 --- a/packages/ui/src/components/settings/advanced-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -12,8 +12,7 @@ export const AdvancedSettingsSection: Component = () => { getBehaviorSettings(config).filter( (setting) => setting.id === "behavior.autoCleanupBlankSessions" || - setting.id === "behavior.keepUnseenSubagentIdleStatus" || - setting.id === "behavior.tauriNativeEventTransport", + setting.id === "behavior.keepUnseenSubagentIdleStatus", ), ) diff --git a/packages/ui/src/lib/desktop-event-transport-preference.ts b/packages/ui/src/lib/desktop-event-transport-preference.ts deleted file mode 100644 index 1ee0ebcb..00000000 --- a/packages/ui/src/lib/desktop-event-transport-preference.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY = "codenomad-use-tauri-native-event-transport" - -export function readUseTauriNativeEventTransportPreference(): boolean { - if (typeof window === "undefined") { - return true - } - - try { - return window.localStorage?.getItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY) !== "0" - } catch { - return true - } -} - -export function writeUseTauriNativeEventTransportPreference(enabled: boolean): void { - if (typeof window === "undefined") { - return - } - - try { - window.localStorage?.setItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY, enabled ? "1" : "0") - } catch { - // Ignore localStorage failures and keep the in-memory preference only. - } -} diff --git a/packages/ui/src/lib/event-transport-contract.ts b/packages/ui/src/lib/event-transport-contract.ts deleted file mode 100644 index e4d91629..00000000 --- a/packages/ui/src/lib/event-transport-contract.ts +++ /dev/null @@ -1,62 +0,0 @@ -export interface DesktopEventTransportReconnectPolicy { - initialDelayMs: number - maxDelayMs: number - multiplier: number - maxAttempts?: number -} - -export interface DesktopEventTransportStartOptions { - reconnect?: Partial -} - -export type DesktopEventTransportState = - | "connecting" - | "connected" - | "disconnected" - | "unauthorized" - | "error" - | "stopped" - -export interface DesktopEventTransportStats { - rawEvents: number - emittedEvents: number - emittedBatches: number - deltaCoalesces: number - snapshotCoalesces: number - statusCoalesces: number - supersededDeltasDropped: number -} - -export interface DesktopEventTransportStatusPayload { - generation: number - state: DesktopEventTransportState - reconnectAttempt: number - terminal: boolean - reason?: string - nextDelayMs?: number - statusCode?: number - stats?: DesktopEventTransportStats -} - -export interface DesktopEventsStartResult { - started: boolean - generation?: number - reason?: string -} - -export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = { - initialDelayMs: 1000, - maxDelayMs: 10000, - multiplier: 2, -} - -export function resolveDesktopEventTransportStartOptions( - options?: DesktopEventTransportStartOptions, -): Required { - return { - reconnect: { - ...DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY, - ...options?.reconnect, - }, - } -} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index 5de633c3..20b954c4 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -1,15 +1,5 @@ import type { WorkspaceEventPayload } from "../../../server/src/api-types" import { serverApi } from "./api-client" -import { - resolveDesktopEventTransportStartOptions, - type DesktopEventTransportStartOptions, -} from "./event-transport-contract" -import { readUseTauriNativeEventTransportPreference } from "./desktop-event-transport-preference" -import { getLogger } from "./logger" -import { runtimeEnv } from "./runtime-env" -import { connectTauriWorkspaceEvents } from "./native/desktop-events" - -const log = getLogger("sse") export interface WorkspaceEventTransportCallbacks { onBatch: (events: WorkspaceEventPayload[]) => void @@ -25,7 +15,7 @@ export interface WorkspaceEventConnection { disconnect: () => void } -async function connectBrowserWorkspaceEvents( +export async function connectWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, ): Promise { const notifyDisconnected = () => { @@ -45,42 +35,3 @@ async function connectBrowserWorkspaceEvents( }, } } - -export async function connectWorkspaceEvents( - callbacks: WorkspaceEventTransportCallbacks, - options?: DesktopEventTransportStartOptions, -): Promise { - const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference() - - if ( - runtimeEnv.host === "tauri" && - runtimeEnv.windowContext === "local" && - nativeDesktopTransportEnabled - ) { - try { - const conn = await connectTauriWorkspaceEvents( - callbacks, - resolveDesktopEventTransportStartOptions(options), - ) - log.info("Event transport: rust-native (desktop_event_transport)") - return conn - } catch (error) { - log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) - } - } else if (runtimeEnv.host === "tauri" && runtimeEnv.windowContext === "remote") { - log.info("Event transport: browser-eventsource forced for remote Tauri window") - } else if (runtimeEnv.host === "tauri") { - log.info("Event transport: browser-eventsource forced by settings") - } - - log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`) - return connectBrowserWorkspaceEvents(callbacks) -} - -export type { - DesktopEventsStartResult, - DesktopEventTransportReconnectPolicy, - DesktopEventTransportStartOptions, - DesktopEventTransportState, - DesktopEventTransportStatusPayload, -} from "./event-transport-contract" diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 64a472e2..995038c7 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -33,8 +33,6 @@ function splitKeywords(key: string): string[] { export interface UseCommandsOptions { preferences: Accessor - useTauriNativeEventTransport: Accessor - setUseTauriNativeEventTransport: (next: boolean) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void toggleShowMessageTimeline: () => void @@ -412,8 +410,6 @@ export function useCommands(options: UseCommandsOptions) { registerBehaviorCommands((command) => commandRegistry.register(command), { preferences: options.preferences, - useTauriNativeEventTransport: options.useTauriNativeEventTransport, - setUseTauriNativeEventTransport: options.setUseTauriNativeEventTransport, toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, toggleShowMessageTimeline: options.toggleShowMessageTimeline, diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index beea177e..cd8b53d0 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -318,8 +318,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.", "settings.behavior.keepUnseenSubagentIdle.title": "Statusanzeigen von Unteragenten beibehalten", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Statusanzeigen von Unteragenten sichtbar lassen, bis sie angesehen wurden.", - "settings.behavior.tauriNativeEventTransport.title": "Nativer Tauri-Ereignistransport", - "settings.behavior.tauriNativeEventTransport.subtitle": "Den Rust-nativen Desktop-Ereignistransport in Tauri verwenden. Deaktivieren, um auf den Browser-EventSource-Pfad zurückzufallen.", "settings.behavior.promptVoiceInput.title": "Prompt-Spracheingabe", "settings.behavior.promptVoiceInput.subtitle": "Mikrofon-Steuerung für Sprache-zu-Text anzeigen, wenn Sprache konfiguriert ist.", "settings.behavior.promptSubmit.title": "Enter zum Absenden", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index bbd331f7..6fbf5957 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -318,8 +318,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", "settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Keep subagent idle markers visible until viewed instead of hiding them after 5 seconds.", - "settings.behavior.tauriNativeEventTransport.title": "Native Tauri event transport", - "settings.behavior.tauriNativeEventTransport.subtitle": "Use the Rust-native desktop event transport in Tauri. Disable this to fall back to the browser EventSource path.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter to submit", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index e8821008..7ccee0c2 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -317,8 +317,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.", "settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos después de 5 segundos.", - "settings.behavior.tauriNativeEventTransport.title": "Transporte de eventos nativo de Tauri", - "settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactívalo para volver a la ruta browser EventSource.", "settings.behavior.promptVoiceInput.title": "Entrada de voz del prompt", "settings.behavior.promptVoiceInput.subtitle": "Muestra el control del micrófono para la entrada de voz a texto del prompt cuando la voz esté configurada.", "settings.behavior.promptSubmit.title": "Enter para enviar", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 9d6383e7..d32cab13 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -317,8 +317,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", "settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Garde les marqueurs inactifs des sous-agents visibles jusqu'a consultation au lieu de les masquer apres 5 secondes.", - "settings.behavior.tauriNativeEventTransport.title": "Transport d'evenements natif Tauri", - "settings.behavior.tauriNativeEventTransport.subtitle": "Utiliser le transport d'evenements desktop natif Rust dans Tauri. Desactivez-le pour revenir au chemin browser EventSource.", "settings.behavior.promptVoiceInput.title": "Saisie vocale du prompt", "settings.behavior.promptVoiceInput.subtitle": "Afficher le contrôle du microphone pour la saisie vocale du prompt lorsque la voix est configurée.", "settings.behavior.promptSubmit.title": "Entrer pour envoyer", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 7d6a0ca2..87413dd4 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -317,8 +317,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.", "settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים", "settings.behavior.keepUnseenSubagentIdle.subtitle": "השאר סמני idle של תתי-סוכנים גלויים עד צפייה במקום להסתיר אותם אחרי 5 שניות.", - "settings.behavior.tauriNativeEventTransport.title": "תעבורת אירועים מקורית של Tauri", - "settings.behavior.tauriNativeEventTransport.subtitle": "השתמש בתעבורת האירועים השולחנית המקורית ב-Rust בתוך Tauri. כבה זאת כדי לחזור למסלול browser EventSource.", "settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט", "settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.", "settings.behavior.promptSubmit.title": "Enter לשליחה", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 204a490a..5c8786e1 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -317,8 +317,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", "settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持", "settings.behavior.keepUnseenSubagentIdle.subtitle": "サブエージェントの idle マーカーを 5 秒後に隠さず、表示するまで残します。", - "settings.behavior.tauriNativeEventTransport.title": "Tauri ネイティブイベント転送", - "settings.behavior.tauriNativeEventTransport.subtitle": "Tauri で Rust ネイティブのデスクトップイベント転送を使います。無効にすると browser EventSource 経路に戻ります。", "settings.behavior.promptVoiceInput.title": "プロンプト音声入力", "settings.behavior.promptVoiceInput.subtitle": "音声が設定されている場合、音声文字起こし用のマイク操作を表示します。", "settings.behavior.promptSubmit.title": "Enterで送信", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 95adab59..5bf271da 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -318,8 +318,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "नयाँ सत्रहरू सिर्जना गर्दा खाली सत्रहरू स्वतः सफा गर्नुहोस्।", "settings.behavior.keepUnseenSubagentIdle.title": "उप-एजेन्ट निष्क्रिय (idle) मार्करहरू राख्नुहोस्", "settings.behavior.keepUnseenSubagentIdle.subtitle": "नहेरेसम्म उप-एजेन्ट निष्क्रिय मार्करहरू देखिने राख्नुहोस्।", - "settings.behavior.tauriNativeEventTransport.title": "नेटिभ Tauri इभेन्ट ट्रान्सपोर्ट", - "settings.behavior.tauriNativeEventTransport.subtitle": "Tauri मा Rust-नेटिभ डेस्कटप इभेन्ट ट्रान्सपोर्ट प्रयोग गर्नुहोस्। ब्राउजर EventSource मार्गमा फर्कन यसलाई अक्षम गर्नुहोस्।", "settings.behavior.promptVoiceInput.title": "प्रम्प्ट ध्वनि इनपुट", "settings.behavior.promptVoiceInput.subtitle": "वाचन कन्फिगर हुँदा स्पीच-टु-टेक्स्ट प्रम्प्ट इनपुटको लागि माइक्रोफोन नियन्त्रण देखाउनुहोस्।", "settings.behavior.promptSubmit.title": "Enter थिचेर बुझाउनुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index dd1b543b..cbbb4e4c 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -317,8 +317,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.", "settings.behavior.keepUnseenSubagentIdle.title": "Оставлять idle-маркеры субагентов", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Оставлять idle-маркеры субагентов видимыми до просмотра вместо скрытия через 5 секунд.", - "settings.behavior.tauriNativeEventTransport.title": "Нативный транспорт событий Tauri", - "settings.behavior.tauriNativeEventTransport.subtitle": "Использовать нативный транспорт событий Rust в Tauri. Отключите, чтобы вернуться к пути browser EventSource.", "settings.behavior.promptVoiceInput.title": "Голосовой ввод промпта", "settings.behavior.promptVoiceInput.subtitle": "Показывать микрофон для ввода промпта через распознавание речи, когда речь настроена.", "settings.behavior.promptSubmit.title": "Enter для отправки", 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 7d8509cc..f06470ab 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -317,8 +317,6 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", "settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记", "settings.behavior.keepUnseenSubagentIdle.subtitle": "让子智能体 idle 标记保持可见直到查看,而不是 5 秒后隐藏。", - "settings.behavior.tauriNativeEventTransport.title": "Tauri 原生事件传输", - "settings.behavior.tauriNativeEventTransport.subtitle": "在 Tauri 中使用 Rust 原生桌面事件传输。禁用后将回退到浏览器 EventSource 路径。", "settings.behavior.promptVoiceInput.title": "提示词语音输入", "settings.behavior.promptVoiceInput.subtitle": "语音已配置时,显示用于语音转文字提示词输入的麦克风控件。", "settings.behavior.promptSubmit.title": "回车发送", diff --git a/packages/ui/src/lib/native/desktop-events.test.ts b/packages/ui/src/lib/native/desktop-events.test.ts deleted file mode 100644 index c6d0e388..00000000 --- a/packages/ui/src/lib/native/desktop-events.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { - connectTauriWorkspaceEvents, - createTerminalErrorNotifier, - mapDesktopEventTransportStatus, -} from "./desktop-events.ts" - -describe("createTerminalErrorNotifier", () => { - it("calls onError once for repeated terminal notifications", () => { - let errors = 0 - const notifyTerminalError = createTerminalErrorNotifier({ - onError: () => { - errors += 1 - }, - }) - - notifyTerminalError() - notifyTerminalError() - - assert.equal(errors, 1) - }) -}) - -describe("mapDesktopEventTransportStatus", () => { - it("maps native connected state to shared connected state", () => { - assert.equal(mapDesktopEventTransportStatus("connected"), "connected") - }) - - it("maps native connecting state to shared connecting state", () => { - assert.equal(mapDesktopEventTransportStatus("connecting"), "connecting") - }) - - it("maps native transient failures to shared disconnected state", () => { - assert.equal(mapDesktopEventTransportStatus("disconnected"), "disconnected") - assert.equal(mapDesktopEventTransportStatus("error"), "disconnected") - assert.equal(mapDesktopEventTransportStatus("unauthorized"), "disconnected") - }) -}) - -describe("connectTauriWorkspaceEvents", () => { - it("marks the transport connected when a batch opens the native stream", async () => { - let batchHandler: ((event: { payload: any }) => void) | undefined - const unlistened: string[] = [] - const bridge = { - invoke: async (command: string) => { - if (command === "desktop_events_start") { - return { started: true, generation: 1 } - } - if (command === "desktop_events_stop") { - return undefined - } - throw new Error(`Unexpected command: ${command}`) - }, - listen: async (eventName: string, handler: (event: { payload: any }) => void) => { - if (eventName === "desktop:event-batch") { - batchHandler = handler - } - return () => { - unlistened.push(eventName) - } - }, - } as any - - const statuses: string[] = [] - const batches: unknown[] = [] - let opens = 0 - - const connection = await connectTauriWorkspaceEvents( - { - onBatch: (events) => batches.push(events), - onOpen: () => { - opens += 1 - }, - onStatus: (status) => statuses.push(status), - }, - { reconnect: {} }, - bridge, - ) - - assert.ok(batchHandler) - batchHandler({ - payload: { - generation: 1, - sequence: 1, - emittedAt: Date.now(), - events: [{ type: "server.heartbeat" }], - }, - }) - - assert.deepEqual(statuses, ["connected"]) - assert.equal(opens, 1) - assert.equal(batches.length, 1) - - connection.disconnect() - assert.deepEqual(unlistened, ["desktop:event-batch", "desktop:event-stream-status"]) - }) -}) diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts deleted file mode 100644 index 2ee51d4c..00000000 --- a/packages/ui/src/lib/native/desktop-events.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { invoke } from "@tauri-apps/api/core" -import { listen } from "@tauri-apps/api/event" -import type { WorkspaceEventPayload } from "../../../../server/src/api-types" -import type { - DesktopEventsStartResult, - DesktopEventTransportStartOptions, - DesktopEventTransportState, - DesktopEventTransportStatusPayload, -} from "../event-transport-contract" -import type { - WorkspaceEventConnection, - WorkspaceEventTransportCallbacks, - WorkspaceEventTransportStatus, -} from "../event-transport" -import { getLogger } from "../logger" - -const log = getLogger("sse") - -interface WorkspaceEventBatchPayload { - generation: number - sequence: number - emittedAt: number - events: WorkspaceEventPayload[] -} - -interface DesktopEventTransportBridge { - invoke: typeof invoke - listen: typeof listen -} - -const defaultDesktopEventTransportBridge: DesktopEventTransportBridge = { - invoke, - listen, -} - -export function createTerminalErrorNotifier(callbacks: Pick) { - let raised = false - return () => { - if (raised) return - raised = true - callbacks.onError?.() - } -} - -export function mapDesktopEventTransportStatus( - state: DesktopEventTransportState, -): WorkspaceEventTransportStatus { - if (state === "connected") return "connected" - if (state === "connecting") return "connecting" - return "disconnected" -} - -export async function connectTauriWorkspaceEvents( - callbacks: WorkspaceEventTransportCallbacks, - options: DesktopEventTransportStartOptions, - bridge: DesktopEventTransportBridge = defaultDesktopEventTransportBridge, -): Promise { - let closed = false - let opened = false - let expectedGeneration: number | null = null - const notifyTerminalError = createTerminalErrorNotifier(callbacks) - const pendingBatches: WorkspaceEventBatchPayload[] = [] - const pendingStatuses: DesktopEventTransportStatusPayload[] = [] - - const matchesGeneration = (generation: number) => expectedGeneration === generation - - const handleBatchPayload = (payload: WorkspaceEventBatchPayload) => { - if (!payload || !matchesGeneration(payload.generation)) return - - if (!opened) { - opened = true - callbacks.onStatus?.("connected") - callbacks.onOpen?.() - } - - const events = payload.events ?? [] - if (events.length === 0) { - return - } - - callbacks.onBatch(events) - } - - const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => { - if (!payload || !matchesGeneration(payload.generation)) return - - callbacks.onStatus?.(mapDesktopEventTransportStatus(payload.state)) - - if (payload.state === "connected" && !opened) { - opened = true - callbacks.onOpen?.() - } - - if (payload.state === "unauthorized") { - log.warn("Native desktop event transport is waiting for authentication", { - reason: payload.reason, - reconnectAttempt: payload.reconnectAttempt, - nextDelayMs: payload.nextDelayMs, - stats: payload.stats, - }) - } else if (payload.state === "error") { - log.warn("Native desktop event transport reported an error", { - reason: payload.reason, - reconnectAttempt: payload.reconnectAttempt, - nextDelayMs: payload.nextDelayMs, - statusCode: payload.statusCode, - stats: payload.stats, - }) - } else if ((payload.state === "disconnected" || payload.state === "stopped") && payload.stats) { - log.info("Native desktop event transport stats", { - state: payload.state, - reconnectAttempt: payload.reconnectAttempt, - stats: payload.stats, - }) - } - - if (payload.state === "stopped") { - notifyTerminalError() - return - } - - if (payload.terminal) { - notifyTerminalError() - } - } - - const flushPending = () => { - if (expectedGeneration === null) return - for (const payload of pendingStatuses.splice(0, pendingStatuses.length)) { - handleStatusPayload(payload) - } - for (const payload of pendingBatches.splice(0, pendingBatches.length)) { - handleBatchPayload(payload) - } - } - - const unlistenBatch = await bridge.listen("desktop:event-batch", (event) => { - if (closed) return - const payload = event.payload - if (!payload) return - if (expectedGeneration === null) { - pendingBatches.push(payload) - return - } - handleBatchPayload(payload) - }) - - const unlistenStatus = await bridge.listen("desktop:event-stream-status", (event) => { - if (closed) return - const payload = event.payload - if (!payload) return - if (expectedGeneration === null) { - pendingStatuses.push(payload) - return - } - handleStatusPayload(payload) - }) - - try { - const result = await bridge.invoke("desktop_events_start", { request: options }) - if (!result?.started) { - throw new Error(result?.reason ?? "desktop event transport unavailable") - } - expectedGeneration = result.generation ?? null - flushPending() - } catch (error) { - unlistenBatch() - unlistenStatus() - throw error - } - - return { - disconnect() { - if (closed) { - return - } - - closed = true - unlistenBatch() - unlistenStatus() - void bridge.invoke("desktop_events_stop").catch((error) => { - log.warn("Failed to stop native desktop event transport", error) - }) - }, - } -} diff --git a/packages/ui/src/lib/provider-auth.test.ts b/packages/ui/src/lib/provider-auth.test.ts index 1bb14802..a31a11a1 100644 --- a/packages/ui/src/lib/provider-auth.test.ts +++ b/packages/ui/src/lib/provider-auth.test.ts @@ -4,6 +4,7 @@ import type { FormFields } from "@opencode-ai/client" import { getProviderAuthAnswer, getProviderAuthInitialAnswer, + isProviderAuthHttpUrl, isProviderAuthFieldComplete, shouldShowProviderAuthField, } from "./provider-auth.ts" @@ -29,4 +30,37 @@ describe("native provider auth answers", () => { assert.equal(isProviderAuthFieldComplete(fields[1], answer), false) assert.equal(isProviderAuthFieldComplete(fields[1], { ...answer, account: "123" }), true) }) + + it("accepts only explicit HTTP authorization URLs", () => { + assert.equal(isProviderAuthHttpUrl("https://example.com/oauth"), true) + assert.equal(isProviderAuthHttpUrl("http://localhost:3000/oauth"), true) + assert.equal(isProviderAuthHttpUrl("javascript:alert(1)"), false) + assert.equal(isProviderAuthHttpUrl("data:text/html,hello"), false) + assert.equal(isProviderAuthHttpUrl("file:///tmp/token"), false) + assert.equal(isProviderAuthHttpUrl("//example.com/oauth"), false) + }) + + it("rejects answers outside supported field constraints", () => { + const constrained = [ + { key: "code", type: "string", required: true, minLength: 3, maxLength: 5, pattern: "[A-Z]+" }, + { key: "email", type: "string", format: "email" }, + { key: "count", type: "integer", minimum: 2, maximum: 4 }, + { key: "scopes", type: "multiselect", options: [{ label: "Read", value: "read" }, { label: "Write", value: "write" }], minItems: 1, maxItems: 2 }, + ] satisfies FormFields + + assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "AB" }), false) + assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "ABCDEF" }), false) + assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "AbC" }), false) + assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "ABC" }), true) + assert.equal(isProviderAuthFieldComplete(constrained[1], { email: "invalid" }), false) + assert.equal(isProviderAuthFieldComplete(constrained[1], { email: "user@example.com" }), true) + assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 1 }), false) + assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 2.5 }), false) + assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 5 }), false) + assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 3 }), true) + assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: [] }), false) + assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: ["read", "write", "read"] }), false) + assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: ["other"] }), false) + assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: ["read"] }), true) + }) }) diff --git a/packages/ui/src/lib/provider-auth.ts b/packages/ui/src/lib/provider-auth.ts index de101613..4170d78e 100644 --- a/packages/ui/src/lib/provider-auth.ts +++ b/packages/ui/src/lib/provider-auth.ts @@ -8,6 +8,30 @@ export type ProviderAuthAuthorization = { export const genericApiMethod: IntegrationKeyMethod = { type: "key", label: "" } +export function isProviderAuthHttpUrl(value: string): boolean { + try { + const protocol = new URL(value).protocol + return protocol === "http:" || protocol === "https:" + } catch { + return false + } +} + +function matchesStringFormat(value: string, format: Extract["format"]): boolean { + if (!format) return true + if (format === "uri") { + try { + new URL(value) + return true + } catch { + return false + } + } + if (format === "email") return /^[^\s@]+@[^\s@]+$/.test(value) + if (format === "date") return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(value) && !Number.isNaN(Date.parse(value)) +} + export function extractProviderAuthErrorMessage(error: unknown, fallback: string): string { const candidate = error as { data?: { message?: unknown } @@ -47,11 +71,44 @@ export function getProviderAuthAnswer(fields: FormFields | undefined, values: Fo } export function isProviderAuthFieldComplete(field: FormField, answer: FormAnswer): boolean { - if (field.type === "external" || !field.required) return true + if (field.type === "external") return true const value = answer[field.key] - if (typeof value === "string") return value.trim().length > 0 - if (Array.isArray(value)) return value.length > 0 - return value !== undefined + if (value === undefined) return !field.required + + if (field.type === "string") { + if (typeof value !== "string" || (field.required && value.trim().length === 0)) return false + if (field.minLength !== undefined && value.length < field.minLength) return false + if (field.maxLength !== undefined && value.length > field.maxLength) return false + if (!matchesStringFormat(value, field.format)) return false + if (field.options && !field.custom && !field.options.some((option) => option.value === value)) return false + if (field.pattern) { + try { + if (!new RegExp(`^(?:${field.pattern})$`).test(value)) return false + } catch { + return false + } + } + return true + } + + if (field.type === "number" || field.type === "integer") { + if (typeof value !== "number" || !Number.isFinite(value)) return false + if (field.type === "integer" && !Number.isInteger(value)) return false + if (typeof field.minimum === "number" && value < field.minimum) return false + if (typeof field.maximum === "number" && value > field.maximum) return false + return true + } + + if (field.type === "multiselect") { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return false + if (field.required && value.length === 0) return false + if (field.minItems !== undefined && value.length < field.minItems) return false + if (field.maxItems !== undefined && value.length > field.maxItems) return false + if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) return false + return true + } + + return typeof value === "boolean" } export function isAbortError(error: unknown): boolean { diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 0cca660f..9af6da2c 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -7,7 +7,7 @@ import type { } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" -import { isTauriHost, isWebHost } from "../runtime-env" +import { isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -36,8 +36,6 @@ export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting export type BehaviorRegistryActions = { preferences: Accessor - useTauriNativeEventTransport: Accessor - setUseTauriNativeEventTransport: (next: boolean) => void updatePreferences?: (updates: Partial) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void @@ -302,20 +300,6 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS } }, }, - ...(isTauriHost() - ? [ - { - kind: "toggle" as const, - id: "behavior.tauriNativeEventTransport", - titleKey: "settings.behavior.tauriNativeEventTransport.title", - subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle", - get: () => actions.useTauriNativeEventTransport(), - set: (next: boolean) => { - actions.setUseTauriNativeEventTransport(next) - }, - }, - ] - : []), { kind: "toggle", id: "behavior.promptVoiceInput", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 18f6eec0..08802692 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,9 +1,5 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" -import { - readUseTauriNativeEventTransportPreference, - writeUseTauriNativeEventTransportPreference, -} from "../lib/desktop-event-transport-preference" import { storage, type OwnerBucket } from "../lib/storage" import type { RemoteServerProfile } from "../../../server/src/api-types" import { @@ -539,9 +535,6 @@ const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) const [isLoaded, setIsLoaded] = createSignal(false) -const [useTauriNativeEventTransport, setUseTauriNativeEventTransportSignal] = createSignal( - readUseTauriNativeEventTransportPreference(), -) const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") @@ -590,23 +583,6 @@ async function patchConfigOwner(owner: string, patch: unknown) { if (owner === "server") setServerConfigBucket(updated as any) } -function setUseTauriNativeEventTransport(enabled: boolean): void { - if (useTauriNativeEventTransport() === enabled) { - return - } - - setUseTauriNativeEventTransportSignal(enabled) - writeUseTauriNativeEventTransportPreference(enabled) - - void import("../lib/server-events") - .then(({ serverEvents }) => { - serverEvents.restart("desktop transport preference changed") - }) - .catch((error) => { - log.error("Failed to restart backend events stream after desktop transport preference change", error) - }) -} - async function patchStateOwner(owner: string, patch: unknown) { await ensureLoaded() const updated = await storage.patchStateOwner(owner, patch) @@ -937,8 +913,6 @@ void ensureLoaded().catch((error: unknown) => { interface ConfigContextValue { isLoaded: Accessor preferences: typeof preferences - useTauriNativeEventTransport: typeof useTauriNativeEventTransport - setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference @@ -1000,8 +974,6 @@ const ConfigContext = createContext() const configContextValue: ConfigContextValue = { isLoaded, preferences, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, updatePreferences, themePreference, setThemePreference, @@ -1091,8 +1063,6 @@ export function useConfig(): ConfigContextValue { export { preferences, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, uiState, serverSettings, recentFolders, diff --git a/packages/ui/src/stores/session-actions.test.ts b/packages/ui/src/stores/session-actions.test.ts index 7551db56..67306f91 100644 --- a/packages/ui/src/stores/session-actions.test.ts +++ b/packages/ui/src/stores/session-actions.test.ts @@ -72,6 +72,29 @@ describe("voice instruction sync", () => { assert.deepEqual(calls, ["remove", "shell"]) }) + + it("serializes concurrent syncs so the latest mode wins remotely", async () => { + const calls: string[] = [] + let releasePut!: () => void + const putGate = new Promise((resolve) => { releasePut = resolve }) + seed({ session: { + instructions: { entry: { + put: async () => { calls.push("put:start"); await putGate; calls.push("put:end") }, + remove: async () => { calls.push("remove") }, + } }, + command: async () => { calls.push("command") }, + shell: async () => { calls.push("shell") }, + } }) + setConversationModeEnabled(instanceId, true) + const first = executeCustomCommand(instanceId, sessionId, "review", "") + await new Promise((resolve) => setImmediate(resolve)) + setConversationModeEnabled(instanceId, false) + releasePut() + await first + + assert.deepEqual(calls, ["put:start", "put:end", "remove", "command"]) + assert.equal(calls.filter((call) => call === "remove").length, 1) + }) }) describe("native session selection persistence", () => { diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index b7f03c45..7b4636de 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -20,14 +20,37 @@ const VOICE_MODE_INSTRUCTION = [ "Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.", "After the `spoken` block, continue with your normal detailed response.", ].join("\n\n") +const voiceInstructionSyncs = new Map }>() async function syncVoiceModeInstruction(client: ReturnType, instanceId: string, sessionId: string): Promise { - const instruction = client.session.instructions.entry - if (isConversationModeEnabled(instanceId)) { - await instruction.put({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY, value: VOICE_MODE_INSTRUCTION }) - } else { - await instruction.remove({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY }) + const key = `${instanceId}:${sessionId}` + const existing = voiceInstructionSyncs.get(key) + if (existing) { + existing.desired = isConversationModeEnabled(instanceId) + return existing.running } + + const state = { desired: isConversationModeEnabled(instanceId), running: Promise.resolve() } + state.running = (async () => { + try { + let applied: boolean | undefined + while (applied !== state.desired) { + const desired = state.desired + const instruction = client.session.instructions.entry + if (desired) { + await instruction.put({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY, value: VOICE_MODE_INSTRUCTION }) + } else { + await instruction.remove({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY }) + } + applied = desired + state.desired = isConversationModeEnabled(instanceId) + } + } finally { + if (voiceInstructionSyncs.get(key) === state) voiceInstructionSyncs.delete(key) + } + })() + voiceInstructionSyncs.set(key, state) + return state.running } function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index fc8206c5..5a670a6a 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -247,7 +247,10 @@ async function fetchSessions(instanceId: string, options?: { log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" }) const [response, activeSessions] = await Promise.all([ fetchV2Sessions(instanceId, sessionListOptions), - getRootClient(instanceId).session.active(), + getRootClient(instanceId).session.active().catch((error) => { + log.warn("Failed to refresh active sessions", { instanceId, error }) + return null + }), ]) if (!isLatestSessionListRequest(instanceId, requestId)) { if (options?.strictStatus) throw new Error("Foreground session refresh was superseded") @@ -259,16 +262,20 @@ async function fetchSessions(instanceId: string, options?: { for (const apiSession of getV2SessionItems(response)) { const existingSession = existingSessions?.get(apiSession.id) const existingStatus = existingSession?.status - const active = Object.prototype.hasOwnProperty.call(activeSessions, apiSession.id) - const status = active && existingStatus === "compacting" ? "compacting" : active ? "working" : "idle" - const runtimeStatusKnown = true + const active = activeSessions && Object.prototype.hasOwnProperty.call(activeSessions, apiSession.id) + const status = activeSessions === null + ? existingStatus ?? "idle" + : active && existingStatus === "compacting" ? "compacting" : active ? "working" : "idle" + const runtimeStatusKnown = activeSessions === null ? existingSession?.runtimeStatusKnown ?? false : true sessionMap.set(apiSession.id, { ...toClientSessionV2(instanceId, apiSession, existingSession), status, - retry: null, + retry: activeSessions === null ? existingSession?.retry ?? null : null, idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince), runtimeStatusKnown, - generationRecovery: runtimeStatusKnown + generationRecovery: activeSessions === null + ? existingSession?.generationRecovery ?? null + : runtimeStatusKnown ? resolveAuthoritativeGenerationRecovery(existingSession?.generationRecovery, status) : existingSession?.generationRecovery ?? null, }) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index aa9174c8..d071b71e 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -91,7 +91,10 @@ import { messageStoreBus } from "./message-v2/bus" import { handleConversationAssistantPartUpdated } from "./conversation-speech" const log = getLogger("sse") -const pendingSessionFetches = new Map>() +const pendingSessionFetches = new Map() const NATIVE_REFRESH_DELAY_MS = 75 const nativeRefreshes = new Map { const fetched = await fetchSessionInfo(instanceId, sessionId, directory) if (!fetched) return - setSessionStatus(instanceId, sessionId, status, { retry }) + setSessionStatus(instanceId, sessionId, pendingState.status, { retry: pendingState.retry }) })() - pendingSessionFetches.set(key, pending) - void pending.finally(() => pendingSessionFetches.delete(key)) + pendingSessionFetches.set(key, pendingState) + void pending.finally(() => { + if (pendingSessionFetches.get(key) === pendingState) pendingSessionFetches.delete(key) + }) } function resolveMessageRole(info?: MessageInfo | null): "user" | "assistant" { diff --git a/packages/ui/src/stores/session-native-events.test.ts b/packages/ui/src/stores/session-native-events.test.ts index 7f230665..3e6bf9ba 100644 --- a/packages/ui/src/stores/session-native-events.test.ts +++ b/packages/ui/src/stores/session-native-events.test.ts @@ -5,7 +5,7 @@ import { sdkManager } from "../lib/sdk-manager.ts" import type { Session } from "../types/session.ts" import { addInstance, removeInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" -import { handleNativeSessionEvent, handleSessionIdle } from "./session-events.ts" +import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from "./session-events.ts" import { clearInstanceDeletedSessionAuthority, sessions, setSessions } from "./session-state.ts" const delay = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) @@ -79,4 +79,43 @@ describe("native session event reducer", () => { sdkManager.destroyClientsForInstance(instanceId) } }) + + it("keeps the newest status while an unknown session is hydrating", async () => { + const instanceId = "native-status-race" + const sessionId = "unknown" + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const client = { + session: { + get: async () => { + await gate + return { + id: sessionId, title: sessionId, parentID: null, version: "1", projectID: "project", + location: { directory: "/work" }, time: { created: 1, updated: 1 }, + } + }, + }, + } as any + ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) + addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + + try { + handleNativeSessionEvent(instanceId, { + type: "session.execution.started", data: { sessionID: sessionId }, location: { directory: "/work" }, + }) + handleSessionStatus(instanceId, { + type: "session.status", data: { sessionID: sessionId, status: { type: "idle" } }, + } as any) + release() + await delay(20) + + assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "idle") + assert.equal(sessions().get(instanceId)?.get(sessionId)?.runtimeStatusKnown, true) + } finally { + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + clearInstanceDeletedSessionAuthority(instanceId) + removeInstance(instanceId, { authoritative: false }) + sdkManager.destroyClientsForInstance(instanceId) + } + }) }) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index d5306ac7..63167312 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -269,6 +269,25 @@ describe("session request authority", () => { } }) + it("refreshes sessions when active status is unavailable", async () => { + const instanceId = "active-status-unavailable" + const { client, cleanup } = setup(instanceId) + const existing = { ...session(instanceId, "existing"), status: "working" as const } + setSessions((prev) => new Map(prev).set(instanceId, new Map([[existing.id, existing]]))) + ;(client.session as any).list = async () => ({ data: [apiSession(existing.id), apiSession("new")] }) + ;(client.session as any).active = async () => { throw new Error("forbidden") } + + try { + await fetchSessions(instanceId) + assert.equal(sessions().get(instanceId)?.get(existing.id)?.status, "working") + assert.equal(sessions().get(instanceId)?.get(existing.id)?.runtimeStatusKnown, true) + assert.equal(sessions().get(instanceId)?.get("new")?.status, "idle") + assert.equal(sessions().get(instanceId)?.get("new")?.runtimeStatusKnown, false) + } finally { + cleanup() + } + }) + it("accepts native absolute session locations without workspace probing", async () => { const instanceId = "unresolved-worktree-status" const { client, cleanup } = setup(instanceId)