fix(opencode): harden the shared V2 runtime

Remove the duplicate Tauri native event transport and use the server EventSource stream consistently across web, Electron, and Tauri clients. Align the generated client and required opencode2 CLI on next-17353.

Restrict workspace proxying to the required V2 API surface, enforce session and directory ownership, validate local file URIs, strip routing headers, and tighten provider, Yolo, worktree, and session event state handling.

Protect the shared service lifecycle with private registration state, process-identity leases, safe ownership transfer, bounded shutdown, Windows executable resolution, and WSL namespace-aware PID verification. Unknown wrappers now fail closed rather than leaving an unowned service.

Validated with server and UI typechecks, 433 UI tests, focused server lifecycle and proxy suites, 67 Rust tests, server and UI builds, and a real next-17353 service lifecycle smoke test.
This commit is contained in:
Pascal André 2026-08-13 03:28:11 +02:00
parent 07f29d08d5
commit c66aac7967
No known key found for this signature in database
70 changed files with 2817 additions and 2708 deletions

View file

@ -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 |

View file

@ -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/` |

View file

@ -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"`

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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/`.

28
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<string, unknown>[] = []
@ -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()

View file

@ -49,6 +49,7 @@ export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo {
export interface AutoAcceptPersistence {
loadSessions(instanceId: string): Promise<PersistedAutoAcceptSession[]>
loadSession?(instanceId: string, sessionId: string): Promise<PersistedAutoAcceptSession | null>
persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise<void>
}
@ -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<string>()
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
private readonly pending = new Map<string, Map<string, PendingPermission>>()
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
/** Native permission id -> failure count, to stop retrying stuck permissions. */
private readonly replyAttempts = new Map<string, number>()
private readonly hydratedInstances = new Set<string>()
private readonly hydration = new Map<string, Promise<void>>()
@ -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<string, string>()
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)

View file

@ -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)

View file

@ -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<string, unknown>) {
@ -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)
})
})

View file

@ -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<PersistedAutoAcceptSession[]> {
return (await listSessions(instanceId)).map((session) => ({
id: session.id,
parentId: session.parentID ?? null,
revert: session.revert,
workspaceId: session.location.workspaceID,
yoloEnabled: sessionState(settings, session.id).yoloEnabled === true,
}))
return (await listSessions(instanceId)).map(persistedSession)
},
async loadSession(instanceId, sessionId): Promise<PersistedAutoAcceptSession | null> {
try {
const session = await (await clientFor(instanceId)).session.get({ sessionID: sessionId })
if (!(await workspaceManager.ownsDirectory(instanceId, session.location.directory))) return null
return persistedSession(session)
} catch {
return null
}
},
persist(_instanceId, rootSessionId, enabled): Promise<void> {
return updateYolo(rootSessionId, enabled)

View file

@ -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<string, { type: "running" }> = {},
sessionLocations: Record<string, string | Error> = {},
) {
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<ReturnType<typeof app.inject>> = await app.inject({
method: "GET",
url: `/workspaces/workspace/instance/${prefix}127.0.0.1:${address.port}/steal`,
})
assert.equal(proxyResponse.statusCode, 400)
assert.doesNotMatch(proxyResponse.body, /internal-secret/)
}
assert.equal(requestCount(), 0)
assert.deepEqual(credentials, [])
})
})
it("redacts secret-bearing fields recursively", () => {

View file

@ -367,6 +367,7 @@ export interface InstanceProxyWorkspaceManager {
getInstanceAuthorizationHeader(id: string): string | undefined
getSharedServiceClient(): Promise<OpenCodeClient>
ownsDirectory(id: string, directory: string): Promise<boolean>
ownsPath(id: string, candidate: string): Promise<boolean>
}
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<typeof entry> => 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<string, string | string[] | undefined> = {}
for (const [key, value] of Object.entries(headers)) {
if (!blocked.has(key.toLowerCase())) result[key] = value
const normalized = key.toLowerCase()
if (!blocked.has(normalized) && !normalized.startsWith("x-opencode-")) result[key] = value
}
if (authorization) result.authorization = authorization
return result
@ -711,6 +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<string, unknown>).files
if (files === undefined) return result
if (!Array.isArray(files)) return { paths: [], invalid: true }
for (const file of files) {
if (!file || typeof file !== "object" || Array.isArray(file) || Buffer.isBuffer(file)) {
result.invalid = true
continue
}
const uri = (file as Record<string, unknown>).uri
if (typeof uri !== "string" || !uri.trim()) {
result.invalid = true
continue
}
const parsed = parsePromptFileUri(uri)
if (parsed.invalid) result.invalid = true
else if (parsed.path) result.paths.push(parsed.path)
}
return result
}
function parsePromptFileUri(value: string): { path?: string; invalid: boolean } {
if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return { path: value, invalid: value.includes("\0") }
let uri: URL
try {
uri = new URL(value)
} catch {
return { invalid: true }
}
if (["data:", "http:", "https:"].includes(uri.protocol)) return { invalid: false }
if (uri.protocol !== "file:" || (uri.hostname && uri.hostname !== "localhost") || uri.search || uri.hash || /%2f|%5c/i.test(uri.pathname)) {
return { invalid: true }
}
try {
const decoded = decodeURIComponent(uri.pathname)
const localPath = /^\/[A-Za-z]:\//.test(decoded) ? decoded.slice(1) : decoded
return { path: localPath, invalid: !localPath || localPath.includes("\0") }
} catch {
return { invalid: true }
}
}
function prepareSessionImport(pathname: string, method: string, body: unknown, directory: string) {
const result = { body, directories: [] as string[], invalid: false }
if (pathname !== "/api/session/import" || method !== "POST") return result
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) {
result.invalid = true
return result
}
const input = body as Record<string, unknown>
const addLocation = (owner: Record<string, unknown>, key: string) => {
const value = owner[key]
if (value === null || value === undefined) {
owner[key] = { directory }
result.directories.push(directory)
return
}
if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) {
result.invalid = true
return
}
const location = value as Record<string, unknown>
if (location.directory === null || location.directory === undefined) location.directory = directory
if (typeof location.directory === "string" && location.directory.trim()) result.directories.push(location.directory)
else result.invalid = true
}
addLocation(input, "location")
if (input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info)) {
addLocation(input.info as Record<string, unknown>, "location")
}
if (Array.isArray(input.messages)) {
for (const value of input.messages) {
if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) continue
const message = value as Record<string, unknown>
if (message.type !== "location-switched") continue
addLocation(message, "location")
if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) {
addLocation(message.previous as Record<string, unknown>, "location")
}
}
}
return result
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {

View file

@ -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 })
}
})
})

View file

@ -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<void>((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true }))
})(),
} as unknown as WorkspaceManager
const bus = new EventBus()
const received: string[] = []
bus.on("instance.event", (event) => received.push(event.instanceId))
const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger })
try {
bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any })
await waitFor(() => received.length === 2)
assert.deepEqual(received, ["first", "second"])
} finally {
bridge.shutdown()
}
})
})

View file

@ -17,7 +17,7 @@ export class InstanceEventBridge {
private readonly controller = new AbortController()
private status: InstanceStreamStatus = "connecting"
private task?: Promise<void>
private readonly directoryOwners = new Map<string, { expiresAt: number; owner: Promise<string | undefined> }>()
private readonly directoryOwners = new Map<string, { expiresAt: number; owners: Promise<string[]> }>()
private readonly 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<string | undefined> {
private resolveDirectoryOwners(directory: string): Promise<string[]> {
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<string, unknown> {

View file

@ -29,6 +29,7 @@ class ControlledSharedService {
validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = []
evictions: LocationRef[] = []
failEvictions = 0
shutdownGate?: ReturnType<typeof deferred<void>>
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<void>()
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
})
})
})

View file

@ -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<typeof setTimeout>
interface SharedService {
@ -33,7 +37,7 @@ interface SharedService {
validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<LocationGetOutput>
subscribe: (requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<AsyncIterable<OpenCodeEvent>>
evict: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<void>
shutdown: () => Promise<void>
shutdown: (options?: { timeoutMs?: number }) => Promise<void>
}
export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean {
@ -176,6 +180,17 @@ export class WorkspaceManager {
})) !== null
}
async ownsPath(id: string, candidate: string): Promise<boolean> {
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<AsyncIterable<OpenCodeEvent>> {
return this.sharedService.subscribe({ signal })
}
@ -377,7 +392,10 @@ export class WorkspaceManager {
}
}, timeoutMs)
try {
return await this.createResolvedWorkspace(record)
const deadline = new Promise<never>((_resolve, reject) => {
state.abortController.signal.addEventListener("abort", () => reject(state.abortController.signal.reason), { once: true })
})
return await Promise.race([this.createResolvedWorkspace(record, timeoutMs), deadline])
} finally {
if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout)
}
@ -401,6 +419,7 @@ export class WorkspaceManager {
}
private async createResolvedWorkspace(
record: WorkspaceRecord,
timeoutMs: number,
): Promise<WorkspaceCreateResult> {
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(", ")}`),
)

View file

@ -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<string | undefined> = []
const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" }
const createService = (contenderPid: number) => new OpenCodeSharedService({
discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }),
ensure: async (options) => {
assert.equal(process.env.CODENOMAD_SERVICE_TEST, "configured")
options?.onStart?.("missing")
await writeFile(contenders, `${contenderPid}\n`)
await writeFile(file, JSON.stringify(info))
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
},
headers: () => undefined,
stop: async (options) => { stopFiles.push(options?.file) },
makeClient: () => ({} as OpenCodeClient),
})
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<void>()
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<boolean>(() => undefined) },
true,
undefined,
async () => false,
)
try {
const connections = [
first.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: firstContenders } }),
second.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: secondContenders } }),
]
await Promise.all([readWhenPresent(firstContenders), readWhenPresent(secondContenders)])
await writeFile(file, JSON.stringify(info))
ready = true
starts.resolve()
await Promise.all(connections)
await Promise.all([first.shutdown(), second.shutdown()])
await 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<void>((resolve) => { markAccepted = resolve })
const completion = new Promise<boolean>((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<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address()
assert.ok(address && typeof address === "object")
state.info.url = `http://127.0.0.1:${address.port}`
await writeFile(state.file, JSON.stringify(state.info))
const wslNamespace = { kind: "wsl", distro: "Ubuntu" } as const
const service = createOwnedService(
state,
async () => true,
true,
() => true,
undefined,
true,
async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
async (pid, _timeoutMs, namespace) => wslPidExists
? { status: "found", identity: processIdentity(pid, undefined, namespace) }
: { status: "missing" },
)
try {
await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" })
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, false)
assert.deepEqual(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.processIdentity.namespace, wslNamespace)
await assert.rejects(service.shutdown({ timeoutMs: 500 }), /did not exit|completion timed out/)
assert.ok(healthChecks > 2)
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping")
wslPidExists = false
await service.shutdown({ timeoutMs: 100 })
await assert.rejects(access(state.lease("owner")))
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await rm(state.root, { recursive: true, force: true })
}
})
it("reports a failed stop and keeps an active retryable lease", async () => {
const state = await serviceState("codenomad-service-stop-failure-")
let stops = 0
const service = createOwnedService(state, async () => { stops += 1; return stops > 1 })
try {
await service.endpoint(state.options("owner", true))
await assert.rejects(service.shutdown(), /failed the stop request/)
const lease = JSON.parse(await readFile(state.lease("owner"), "utf8"))
assert.equal(lease.state, "active")
await service.shutdown()
assert.equal(stops, 2)
await assert.rejects(access(state.lease("owner")))
} finally {
await rm(state.root, { recursive: true, force: true })
}
})
it("bounds a stalled ensure", async () => {
const service = new OpenCodeSharedService({
discover: async () => undefined,
ensure: async () => new Promise<never>(() => 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<void>((resolve) => { markStopStarted = resolve })
const stopCompletion = new Promise<boolean>((resolve) => { finishStop = () => resolve(true) })
let stops = 0
const service = new OpenCodeSharedService({
discover: async () => undefined,
ensure: (options) => new Promise<Endpoint>((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<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
async function serviceState(prefix: string) {
const root = await mkdtemp(path.join(os.tmpdir(), prefix))
const file = path.join(root, "service.json")
const contenders = path.join(root, "contenders.txt")
const leases = path.join(root, "leases")
const lockDirectory = path.join(root, "stop.lock")
const info = { id: "instance-1", url: "http://127.0.0.1:4321", pid: 1234, password: "secret" }
await mkdir(leases)
await writeFile(file, JSON.stringify(info))
await writeFile(contenders, `${info.pid}\n`)
return {
root, file, contenders, leases, lockDirectory, info,
lease(name: string) { return path.join(leases, `${name}.json`) },
options(name: string, started: boolean): OpenCodeEnsureOptions {
return {
file,
contenderFile: contenders,
leaseFile: path.join(leases, `${name}.json`),
lockDirectory,
onStart: started ? () => undefined : undefined,
}
},
}
}
async function readWhenPresent(file: string): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
await access(file)
return
} catch {
await new Promise<void>((resolve) => setImmediate(resolve))
}
}
throw new Error(`Timed out waiting for ${file}`)
function createOwnedService(
state: Awaited<ReturnType<typeof serviceState>>,
requestStop: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise<boolean>,
announceStart = true,
isProcessAlive?: (pid: number) => boolean,
waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise<boolean>,
useDefaultWaitForStop = false,
getProcessIdentity: (
pid: number,
timeoutMs: number,
namespace?: ProcessNamespace,
) => Promise<ProcessIdentity | undefined> = async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
probeProcessIdentity?: (
pid: number,
timeoutMs: number,
namespace?: ProcessNamespace,
) => Promise<ProcessIdentityProbe>,
) {
return new OpenCodeSharedService({
discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }),
ensure: async (options) => {
if (announceStart) options?.onStart?.("missing")
return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }
},
headers: () => undefined,
requestStop,
waitForStop: useDefaultWaitForStop ? undefined : waitForStop ?? (async () => true),
isProcessAlive,
getProcessIdentity,
probeProcessIdentity,
makeClient: () => ({} as OpenCodeClient),
})
}
function processIdentity(
pid: number,
start = `identity-${pid}`,
namespace: ProcessNamespace = { kind: "host" },
): ProcessIdentity {
return { namespace, pid, start }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { probeProcessStartIdentity } from "./process-identity"
test("probes a service PID inside its WSL distro instead of the coincidental Windows PID", async () => {
const calls: Array<{ command: string; args: string[] }> = []
const probe = await probeProcessStartIdentity(4242, 100, { kind: "wsl", distro: "Ubuntu" }, async (command, args) => {
calls.push({ command, args })
return {
code: 0,
stdout: "4242 (opencode) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20\nlinux-boot-id\n",
}
})
assert.equal(calls[0]?.command, "wsl.exe")
assert.deepEqual(calls[0]?.args.slice(0, 2), ["--distribution", "Ubuntu"])
assert.deepEqual(probe, {
status: "found",
identity: { namespace: { kind: "wsl", distro: "Ubuntu" }, pid: 4242, start: "linux-boot-id:98765" },
})
})
test("distinguishes a missing WSL PID from an unverified probe", async () => {
const missing = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({
code: 3,
stdout: "",
}))
const unknown = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({
code: null,
stdout: "",
}))
assert.deepEqual(missing, { status: "missing" })
assert.deepEqual(unknown, { status: "unknown" })
})

View file

@ -0,0 +1,102 @@
import { execFile } from "node:child_process"
import { readFile } from "node:fs/promises"
export type ProcessNamespace = { kind: "host" } | { kind: "wsl"; distro: string }
export interface ProcessIdentity {
namespace: ProcessNamespace
pid: number
start: string
}
export type ProcessIdentityProbe =
| { status: "found"; identity: ProcessIdentity }
| { status: "missing" }
| { status: "unknown" }
export type ProcessIdentityRunner = (
command: string,
args: string[],
timeoutMs: number,
) => Promise<{ code: number | null; stdout: string }>
const runCommand: ProcessIdentityRunner = (command, args, timeoutMs) => new Promise((resolve) => {
execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => {
resolve({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout })
})
})
export async function probeProcessStartIdentity(
pid: number,
timeoutMs: number,
namespace: ProcessNamespace = { kind: "host" },
runner: ProcessIdentityRunner = runCommand,
): Promise<ProcessIdentityProbe> {
if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return { status: "unknown" }
if (namespace.kind === "wsl") {
const result = await runner("wsl.exe", [
"--distribution", namespace.distro,
"--exec", "sh", "-c",
'test -r "/proc/$1/stat" || exit 3; cat "/proc/$1/stat"; cat /proc/sys/kernel/random/boot_id',
"codenomad-process-probe", String(pid),
], timeoutMs).catch(() => ({ code: null, stdout: "" }))
if (result.code === 3) return { status: "missing" }
const [stat, bootId] = result.stdout.trim().split(/\r?\n/)
const start = result.code === 0 && stat && bootId ? linuxStart(stat, bootId) : undefined
return start
? { status: "found", identity: { namespace, pid, start } }
: { status: "unknown" }
}
try {
if (process.platform === "linux") {
const signal = AbortSignal.timeout(timeoutMs)
const stat = await readFile(`/proc/${pid}/stat`, { encoding: "utf8", signal })
const bootId = (await readFile("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim()
const start = linuxStart(stat, bootId)
return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" }
}
if (process.platform === "darwin") {
return commandProbe(runner, "ps", ["-p", String(pid), "-o", "lstart="], namespace, pid, timeoutMs)
}
if (process.platform === "win32") {
return commandProbe(runner, "powershell.exe", [
"-NoProfile", "-NonInteractive", "-Command",
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop; if (!$p) { exit 3 }; $p.CreationDate.ToUniversalTime().Ticks`,
], namespace, pid, timeoutMs)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }
}
return { status: "unknown" }
}
export async function getProcessStartIdentity(
pid: number,
timeoutMs: number,
namespace: ProcessNamespace = { kind: "host" },
runner?: ProcessIdentityRunner,
): Promise<ProcessIdentity | undefined> {
const probe = await probeProcessStartIdentity(pid, timeoutMs, namespace, runner)
return probe.status === "found" ? probe.identity : undefined
}
function linuxStart(stat: string, bootId: string): string | undefined {
const commandEnd = stat.lastIndexOf(")")
const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19]
return startTicks && bootId ? `${bootId}:${startTicks}` : undefined
}
async function commandProbe(
runner: ProcessIdentityRunner,
command: string,
args: string[],
namespace: ProcessNamespace,
pid: number,
timeoutMs: number,
): Promise<ProcessIdentityProbe> {
const result = await runner(command, args, timeoutMs).catch(() => ({ code: null, stdout: "" }))
if (result.code === 3) return { status: "missing" }
const start = result.code === 0 ? result.stdout.trim() : ""
return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" }
}

View file

@ -0,0 +1,106 @@
import { constants, closeSync, chmodSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs"
import { open } from "node:fs/promises"
import { isIP } from "node:net"
import os from "node:os"
import path from "node:path"
import type { Info } from "@opencode-ai/client/service"
const CODENOMAD_HOME = path.join(os.homedir(), ".codenomad")
const CODENOMAD_STATE = path.join(CODENOMAD_HOME, "state")
export const SERVICE_STATE_ROOT = path.join(CODENOMAD_STATE, "opencode-v2")
export const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json")
export const SERVICE_LEASE_DIRECTORY = path.join(SERVICE_STATE_ROOT, "leases")
export const SERVICE_STOP_LOCK = path.join(SERVICE_STATE_ROOT, "stop.lock")
const preparedFiles = new Set<string>()
export function prepareServiceState(contenderFile: string): void {
ensurePrivateDirectory(CODENOMAD_HOME)
ensurePrivateDirectory(CODENOMAD_STATE)
ensurePrivateDirectory(SERVICE_STATE_ROOT)
ensurePrivateDirectory(path.dirname(SERVICE_REGISTRATION_FILE))
ensurePrivateDirectory(SERVICE_LEASE_DIRECTORY)
validateRegistrationFile(SERVICE_REGISTRATION_FILE)
if (preparedFiles.has(contenderFile)) return
closeSync(openSync(contenderFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600))
preparedFiles.add(contenderFile)
}
export async function readSecureServiceInfo(file: string | undefined): Promise<Info | undefined> {
if (!file) return undefined
let handle
try {
const before = lstatSync(file)
if (!before.isFile() || before.isSymbolicLink()) return undefined
const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW
handle = await open(file, flags)
const after = await handle.stat()
if (process.platform !== "win32" && (before.dev !== after.dev || before.ino !== after.ino)) return undefined
return parseInfo(await handle.readFile("utf8"))
} catch {
return undefined
} finally {
await handle?.close()
}
}
export function assertLoopbackServiceUrl(value: string): URL {
const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Unsupported OpenCode service protocol: ${url.protocol}`)
}
const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
const ipVersion = isIP(hostname)
const loopback = hostname === "localhost"
|| (ipVersion === 4 && hostname.startsWith("127."))
|| (ipVersion === 6 && (hostname === "::1" || hostname.startsWith("::ffff:127.")))
if (!loopback) throw new Error(`OpenCode service endpoint must be loopback: ${url.hostname}`)
return url
}
function ensurePrivateDirectory(directory: string): void {
mkdirSync(directory, { recursive: true, mode: 0o700 })
const stat = lstatSync(directory)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service state directory: ${directory}`)
if (process.platform === "win32") return
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
throw new Error(`OpenCode service state directory is owned by another user: ${directory}`)
}
if ((stat.mode & 0o077) !== 0) chmodSync(directory, 0o700)
}
function validateRegistrationFile(file: string): void {
let stat
try {
stat = lstatSync(file)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
throw error
}
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service registration file: ${file}`)
const text = readFileSync(file, "utf8")
let value: unknown
try { value = JSON.parse(text) } catch { return }
if (typeof value === "object" && value !== null && "url" in value && typeof value.url === "string") {
assertLoopbackServiceUrl(value.url)
}
}
function parseInfo(text: string): Info | undefined {
let value: unknown
try {
value = JSON.parse(text)
} catch {
return undefined
}
if (typeof value !== "object" || value === null) return undefined
if (!("url" in value) || typeof value.url !== "string") return undefined
if (!("id" in value) || typeof value.id !== "string" || !value.id) return undefined
if (!("pid" in value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined
try {
assertLoopbackServiceUrl(value.url)
} catch {
return undefined
}
return value as Info
}

View file

@ -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) {

View file

@ -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<boolean> {
let target: string
try {
target = await realpath(params.candidate)
} catch {
return false
}
const cached = await getCachedWorktrees(params)
return cached.worktrees.some((worktree) => {
const relative = path.relative(worktree.normalizedDirectory, target)
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
})
}

View file

@ -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"

View file

@ -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"

View file

@ -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<Mutex<Option<WindowsJobObject>>>,
bootstrap_token: Arc<Mutex<Option<String>>>,
session_cookie: Arc<Mutex<Option<String>>>,
auth_cookie_name: Arc<Mutex<Option<String>>>,
lifecycle: Arc<Mutex<()>>,
generation: Arc<AtomicU64>,
}
@ -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<DesktopEventStreamConfig> {
let base_url = self.status.lock().url.clone()?;
let events_url = format!("{}/api/events", base_url.trim_end_matches('/'));
let client_id = format!("tauri-{}", std::process::id());
let cookie_name = self
.auth_cookie_name
.lock()
.clone()
.unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string());
Some(DesktopEventStreamConfig {
base_url,
events_url,
client_id,
connection_id: generate_transport_connection_id(),
cookie_name,
session_cookie: self.session_cookie.lock().clone(),
})
}
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);
}
}

View file

@ -1,477 +0,0 @@
use parking_lot::Mutex;
use reqwest::blocking::{Client, Response};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{BufRead, BufReader};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager, Url};
mod assembler;
mod stream;
mod transport;
use stream::*;
use transport::*;
const EVENT_BATCH_NAME: &str = "desktop:event-batch";
const EVENT_STATUS_NAME: &str = "desktop:event-stream-status";
const FLUSH_INTERVAL_MS: u64 = 16;
const DELTA_STREAM_WINDOW_MS: u64 = 48;
const MAX_BATCH_EVENTS: usize = 256;
const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000;
const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000;
const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0;
const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000;
const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000;
const STREAM_STALL_TIMEOUT_MS: u64 = 30_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DesktopEventStreamConfig {
pub base_url: String,
pub events_url: String,
pub client_id: String,
pub connection_id: String,
pub cookie_name: String,
pub session_cookie: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DesktopEventsStartRequest {
pub reconnect: Option<DesktopEventReconnectPolicy>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DesktopEventReconnectPolicy {
pub initial_delay_ms: Option<u64>,
pub max_delay_ms: Option<u64>,
pub multiplier: Option<f64>,
pub max_attempts: Option<u32>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopEventsStartResult {
pub started: bool,
pub generation: Option<u64>,
pub reason: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
struct ResolvedDesktopEventReconnectPolicy {
initial_delay_ms: u64,
max_delay_ms: u64,
multiplier: f64,
max_attempts: Option<u32>,
}
impl ResolvedDesktopEventReconnectPolicy {
fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self {
let initial_delay_ms = policy
.and_then(|value| value.initial_delay_ms)
.unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS)
.max(1);
let max_delay_ms = policy
.and_then(|value| value.max_delay_ms)
.unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS)
.max(initial_delay_ms);
let multiplier = policy
.and_then(|value| value.multiplier)
.filter(|value| value.is_finite() && *value >= 1.0)
.unwrap_or(DEFAULT_RECONNECT_MULTIPLIER);
let max_attempts = policy
.and_then(|value| value.max_attempts)
.filter(|value| *value > 0);
Self {
initial_delay_ms,
max_delay_ms,
multiplier,
max_attempts,
}
}
}
#[derive(Clone, Debug, PartialEq)]
struct DesktopEventTransportConfig {
stream: DesktopEventStreamConfig,
reconnect: ResolvedDesktopEventReconnectPolicy,
}
impl DesktopEventTransportConfig {
fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self {
Self {
stream,
reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()),
}
}
fn is_equivalent_start(&self, other: &Self) -> bool {
self.reconnect == other.reconnect
&& self.stream.base_url == other.stream.base_url
&& self.stream.events_url == other.stream.events_url
&& self.stream.client_id == other.stream.client_id
&& self.stream.cookie_name == other.stream.cookie_name
&& self.stream.session_cookie == other.stream.session_cookie
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceEventBatchPayload {
generation: u64,
sequence: u64,
emitted_at: u128,
events: Vec<Value>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DesktopEventStreamStatusPayload {
generation: u64,
state: &'static str,
reconnect_attempt: u32,
terminal: bool,
reason: Option<String>,
next_delay_ms: Option<u64>,
status_code: Option<u16>,
stats: DesktopEventTransportStats,
}
#[derive(Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct DesktopEventTransportStats {
raw_events: u64,
emitted_events: u64,
emitted_batches: u64,
delta_coalesces: u64,
snapshot_coalesces: u64,
status_coalesces: u64,
superseded_deltas_dropped: u64,
}
struct DesktopEventTransportState {
stop: Option<Arc<AtomicBool>>,
config: Option<DesktopEventTransportConfig>,
}
pub struct DesktopEventTransportManager {
state: Arc<Mutex<DesktopEventTransportState>>,
generation: Arc<AtomicU64>,
}
enum ReaderMessage {
Activity,
Event(Value),
Ping(Value),
End(Option<String>),
}
enum PendingEntry {
Delta {
key: String,
scope: String,
event: Value,
started_at: Instant,
},
Status {
key: String,
event: Value,
},
Snapshot {
key: String,
event: Value,
},
Event(Value),
}
enum EventDeliveryPolicy {
CoalesceDelta(String),
CoalesceStatus(String),
CoalesceSnapshot(String),
Passthrough,
}
enum OpenStreamErrorKind {
Unauthorized,
Http,
Transport,
}
struct OpenStreamError {
kind: OpenStreamErrorKind,
message: String,
status_code: Option<u16>,
}
#[derive(Default)]
struct PendingBatch {
events: Vec<PendingEntry>,
}
impl DesktopEventTransportManager {
pub fn new() -> Self {
Self {
state: Arc::new(Mutex::new(DesktopEventTransportState {
stop: None,
config: None,
})),
generation: Arc::new(AtomicU64::new(0)),
}
}
pub fn start(
&self,
app: AppHandle,
stream_config: Option<DesktopEventStreamConfig>,
request: Option<DesktopEventsStartRequest>,
) -> DesktopEventsStartResult {
let Some(stream_config) = stream_config else {
return DesktopEventsStartResult {
started: false,
generation: None,
reason: Some("desktop event stream unavailable".to_string()),
};
};
let request = request.unwrap_or_default();
let transport_config = DesktopEventTransportConfig::new(stream_config, &request);
let mut state = self.state.lock();
if state
.config
.as_ref()
.is_some_and(|config| config.is_equivalent_start(&transport_config))
{
if let Some(stop) = &state.stop {
if !stop.load(Ordering::SeqCst) {
return DesktopEventsStartResult {
started: true,
generation: Some(self.generation.load(Ordering::SeqCst)),
reason: None,
};
}
}
}
if let Some(stop) = state.stop.take() {
stop.store(true, Ordering::SeqCst);
}
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
let stop = Arc::new(AtomicBool::new(false));
state.stop = Some(stop.clone());
state.config = Some(transport_config.clone());
let shared_generation = self.generation.clone();
drop(state);
thread::spawn(move || {
run_transport_loop(app, shared_generation, generation, stop, transport_config)
});
DesktopEventsStartResult {
started: true,
generation: Some(generation),
reason: None,
}
}
pub fn stop(&self) {
let mut state = self.state.lock();
if let Some(stop) = state.stop.take() {
stop.store(true, Ordering::SeqCst);
}
state.config = None;
self.generation.fetch_add(1, Ordering::SeqCst);
}
}
fn classify_event(event: &Value) -> EventDeliveryPolicy {
if let Some(key) = delta_key(event) {
return EventDeliveryPolicy::CoalesceDelta(key);
}
if let Some(key) = status_key(event) {
return EventDeliveryPolicy::CoalesceStatus(key);
}
if let Some(key) = snapshot_key(event) {
return EventDeliveryPolicy::CoalesceSnapshot(key);
}
EventDeliveryPolicy::Passthrough
}
fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value {
if event.get("type").and_then(Value::as_str) == Some("instance.event") {
event.get("event").unwrap_or(event)
} else {
event
}
}
fn coalesced_instance_id(event: &Value) -> &str {
event
.get("instanceId")
.and_then(Value::as_str)
.unwrap_or_default()
}
fn snapshot_key(event: &Value) -> Option<String> {
let instance_id = coalesced_instance_id(event);
let inner = coalesced_payload_event(event);
let inner_type = inner.get("type")?.as_str()?;
let props = inner.get("properties")?;
match inner_type {
"message.part.updated" => {
let session_id = props
.get("part")
.and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId")))
.and_then(Value::as_str)?;
let message_id = props
.get("part")
.and_then(|part| part.get("messageID").or_else(|| part.get("messageId")))
.and_then(Value::as_str)?;
let part_id = props
.get("part")
.and_then(|part| part.get("id"))
.and_then(Value::as_str)?;
Some(format!(
"message.part.updated:{}:{}:{}:{}",
instance_id, session_id, message_id, part_id
))
}
"message.updated" => {
let info = props.get("info")?;
let session_id = info
.get("sessionID")
.or_else(|| info.get("sessionId"))
.and_then(Value::as_str)?;
let message_id = info.get("id").and_then(Value::as_str)?;
Some(format!(
"message.updated:{}:{}:{}",
instance_id, session_id, message_id
))
}
"session.updated" | "session.status" => {
let session_id = props
.get("info")
.and_then(|info| info.get("id"))
.and_then(Value::as_str)
.or_else(|| {
props
.get("sessionID")
.or_else(|| props.get("sessionId"))
.and_then(Value::as_str)
})?;
Some(format!("{}:{}:{}", inner_type, instance_id, session_id))
}
_ => None,
}
}
fn delta_scope(event: &Value) -> Option<String> {
let instance_id = coalesced_instance_id(event);
let inner = coalesced_payload_event(event);
if inner.get("type")?.as_str()? != "message.part.delta" {
return None;
}
let props = inner.get("properties")?;
let session_id = props
.get("sessionID")
.or_else(|| props.get("sessionId"))
.and_then(Value::as_str)
.unwrap_or_default();
let message_id = props
.get("messageID")
.or_else(|| props.get("messageId"))
.and_then(Value::as_str)?;
let part_id = props
.get("partID")
.or_else(|| props.get("partId"))
.and_then(Value::as_str)?;
Some(format!(
"message.part:{}:{}:{}:{}",
instance_id, session_id, message_id, part_id
))
}
fn delta_key(event: &Value) -> Option<String> {
let scope = delta_scope(event)?;
let props = coalesced_payload_event(event).get("properties")?;
let field = props.get("field")?.as_str()?;
Some(format!("{}:{}", scope, field))
}
fn snapshot_superseded_delta_scope(event: &Value) -> Option<String> {
let instance_id = coalesced_instance_id(event);
let inner = coalesced_payload_event(event);
if inner.get("type")?.as_str()? != "message.part.updated" {
return None;
}
let part = inner.get("properties")?.get("part")?;
let session_id = part
.get("sessionID")
.or_else(|| part.get("sessionId"))
.and_then(Value::as_str)?;
let message_id = part
.get("messageID")
.or_else(|| part.get("messageId"))
.and_then(Value::as_str)?;
let part_id = part.get("id")?.as_str()?;
Some(format!(
"message.part:{}:{}:{}:{}",
instance_id, session_id, message_id, part_id
))
}
fn append_delta(target: &mut Value, event: &Value) {
let next_delta = coalesced_payload_event(event)
.get("properties")
.and_then(|value| value.get("delta"))
.and_then(Value::as_str)
.unwrap_or_default();
if let Some(existing_delta) = coalesced_payload_event_mut(target)
.and_then(|event| event.get_mut("properties"))
.and_then(Value::as_object_mut)
.and_then(|props| props.get_mut("delta"))
{
let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta;
*existing_delta = Value::String(combined);
}
}
fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
if event.get("type").and_then(Value::as_str) == Some("instance.event") {
event.get_mut("event").and_then(Value::as_object_mut)
} else {
event.as_object_mut()
}
}
fn status_key(event: &Value) -> Option<String> {
match event.get("type")?.as_str()? {
"instance.eventStatus" => Some(coalesced_instance_id(event).to_string()),
"session.status" => snapshot_key(event),
_ => None,
}
}
#[cfg(test)]
mod tests;

View file

@ -1,112 +0,0 @@
use super::*;
impl PendingBatch {
pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) {
match classify_event(&event) {
EventDeliveryPolicy::CoalesceDelta(key) => {
let Some(scope) = delta_scope(&event) else {
self.events.push(PendingEntry::Event(event));
return;
};
if let Some(PendingEntry::Delta {
key: existing_key,
event: existing_event,
..
}) = self.events.last_mut()
{
if existing_key == &key {
append_delta(existing_event, &event);
stats.delta_coalesces = stats.delta_coalesces.saturating_add(1);
return;
}
}
self.events.push(PendingEntry::Delta {
key,
scope,
event,
started_at: Instant::now(),
});
}
EventDeliveryPolicy::CoalesceStatus(key) => {
if let Some(PendingEntry::Status {
key: existing_key,
event: existing_event,
}) = self.events.last_mut()
{
if existing_key == &key {
*existing_event = event;
stats.status_coalesces = stats.status_coalesces.saturating_add(1);
return;
}
}
self.events.push(PendingEntry::Status { key, event });
}
EventDeliveryPolicy::CoalesceSnapshot(key) => {
if let Some(part_scope) = snapshot_superseded_delta_scope(&event) {
let mut dropped = 0_u64;
while matches!(
self.events.last(),
Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope
) {
self.events.pop();
dropped = dropped.saturating_add(1);
}
if dropped > 0 {
stats.superseded_deltas_dropped =
stats.superseded_deltas_dropped.saturating_add(dropped);
}
}
if let Some(PendingEntry::Snapshot {
key: existing_key,
event: existing_event,
}) = self.events.last_mut()
{
if existing_key == &key {
*existing_event = event;
stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1);
return;
}
}
self.events.push(PendingEntry::Snapshot { key, event });
}
EventDeliveryPolicy::Passthrough => {
self.events.push(PendingEntry::Event(event));
}
}
}
pub(super) fn take_events(&mut self) -> Vec<Value> {
let pending = std::mem::take(&mut self.events);
pending
.into_iter()
.map(|entry| match entry {
PendingEntry::Delta { event, .. } => event,
PendingEntry::Status { event, .. } => event,
PendingEntry::Snapshot { event, .. } => event,
PendingEntry::Event(event) => event,
})
.collect()
}
pub(super) fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub(super) fn pending_len(&self) -> usize {
self.events.len()
}
pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool {
matches!(
self.events.as_slice(),
[PendingEntry::Delta { started_at, .. }]
if now.duration_since(*started_at)
< Duration::from_millis(DELTA_STREAM_WINDOW_MS)
)
}
}

View file

@ -1,325 +0,0 @@
use super::*;
use reqwest::blocking::RequestBuilder;
pub(super) fn build_stream_client() -> Result<Client, OpenStreamError> {
Client::builder()
.connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS))
.tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS))
// Note: reqwest's blocking client doesn't expose a per-read timeout.
// The global `.timeout()` would kill the entire SSE stream, so we
// rely on:
// 1. tcp_keepalive to detect dead connections (OS will RST after
// several unacked probes, typically ~2 min).
// 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS).
// 3. Reader thread breaking on channel send error (consumer dropped).
.build()
.map_err(|error: reqwest::Error| OpenStreamError {
kind: OpenStreamErrorKind::Transport,
message: error.to_string(),
status_code: None,
})
}
pub(super) fn open_stream(
app: &AppHandle,
client: &Client,
config: &DesktopEventStreamConfig,
) -> Result<Response, OpenStreamError> {
let url = format!(
"{}?clientId={}&connectionId={}",
config.events_url, config.client_id, config.connection_id
);
let request = attach_session_cookie(
client.get(&url).header("Accept", "text/event-stream"),
app,
config,
);
let response = request.send().map_err(|error| OpenStreamError {
kind: OpenStreamErrorKind::Transport,
message: error.to_string(),
status_code: None,
})?;
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
OpenStreamErrorKind::Unauthorized
} else {
OpenStreamErrorKind::Http
};
Err(OpenStreamError {
kind,
message: format!("desktop event stream unavailable ({status})"),
status_code: Some(status.as_u16()),
})
}
fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option<String> {
read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name)
.or_else(|| config.session_cookie.clone())
.filter(|value| !value.is_empty())
}
pub(super) fn attach_session_cookie(
request: RequestBuilder,
app: &AppHandle,
config: &DesktopEventStreamConfig,
) -> RequestBuilder {
attach_session_cookie_value(
request,
&config.cookie_name,
resolve_session_cookie(app, config).as_deref(),
)
}
fn attach_session_cookie_value(
request: RequestBuilder,
cookie_name: &str,
session_cookie: Option<&str>,
) -> RequestBuilder {
let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else {
return request;
};
request.header(
"Cookie",
format!(
"{}={}",
cookie_name,
encode_cookie_header_value(session_cookie)
),
)
}
fn encode_cookie_header_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if is_cookie_header_value_byte(byte) {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn is_cookie_header_value_byte(byte: u8) -> bool {
matches!(
byte,
b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~'
)
}
fn read_session_cookie_from_webview(
app: &AppHandle,
base_url: &str,
cookie_name: &str,
) -> Option<String> {
let url = Url::parse(base_url).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
let path = url.path();
let windows = app.webview_windows();
let window = windows.get("main")?;
let cookies = window.cookies().ok()?;
cookies
.into_iter()
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name)
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| {
let Some(domain) = cookie.domain() else {
return true;
};
let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase();
host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain))
})
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| {
let Some(cookie_path) = cookie.path() else {
return true;
};
path.starts_with(cookie_path)
})
.map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string())
.next()
}
pub(super) fn read_sse(
response: Response,
tx: SyncSender<ReaderMessage>,
stop: Arc<AtomicBool>,
generation_atomic: Arc<AtomicU64>,
generation: u64,
) {
let mut reader = BufReader::new(response);
let mut line = String::new();
let mut event_name: Option<String> = None;
let mut data_lines: Vec<String> = Vec::new();
loop {
if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) {
let _ = tx.send(ReaderMessage::End(Some("stopped".to_string())));
return;
}
line.clear();
match reader.read_line(&mut line) {
Ok(0) => {
let _ = flush_sse_frame(&tx, &event_name, &data_lines);
let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string())));
return;
}
Ok(_) => {
if tx.send(ReaderMessage::Activity).is_err() {
return; // consumer dropped — stop reading
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if handle_sse_line(trimmed, &mut event_name, &mut data_lines) {
if flush_sse_frame(&tx, &event_name, &data_lines).is_err() {
return;
}
event_name = None;
data_lines.clear();
continue;
}
}
Err(error) => {
let _ = flush_sse_frame(&tx, &event_name, &data_lines);
let _ = tx.send(ReaderMessage::End(Some(error.to_string())));
return;
}
}
}
}
fn handle_sse_line(
trimmed: &str,
event_name: &mut Option<String>,
data_lines: &mut Vec<String>,
) -> bool {
if trimmed.is_empty() {
return true;
}
if trimmed.starts_with(':') {
return false;
}
if let Some(name) = trimmed.strip_prefix("event:") {
*event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string());
return false;
}
if let Some(data) = trimmed.strip_prefix("data:") {
data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string());
}
false
}
fn flush_sse_frame(
tx: &SyncSender<ReaderMessage>,
event_name: &Option<String>,
lines: &[String],
) -> Result<(), ()> {
let Some(payload) = parse_sse_payload(lines) else {
return Ok(());
};
if event_name.as_deref() == Some("codenomad.client.ping") {
tx.send(ReaderMessage::Ping(payload)).map_err(|_| ())
} else {
tx.send(ReaderMessage::Event(payload)).map_err(|_| ())
}
}
fn parse_sse_payload(lines: &[String]) -> Option<Value> {
if lines.is_empty() {
return None;
}
let payload = lines.join("\n").trim().to_string();
if payload.is_empty() {
return None;
}
serde_json::from_str::<Value>(&payload).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn named_ping_event_is_routed_to_ping_channel() {
let (tx, rx) = mpsc::sync_channel(1);
let mut event_name = None;
let mut data_lines = Vec::new();
assert!(!handle_sse_line(
"event: codenomad.client.ping",
&mut event_name,
&mut data_lines
));
assert!(!handle_sse_line(
r#"data: {"ts":123}"#,
&mut event_name,
&mut data_lines
));
assert!(handle_sse_line("", &mut event_name, &mut data_lines));
flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush");
match rx.recv().expect("ping frame should be emitted") {
ReaderMessage::Ping(payload) => {
assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123));
}
_ => panic!("expected ping frame"),
}
}
#[test]
fn session_cookie_is_attached_to_requests() {
let request = attach_session_cookie_value(
Client::new().post("http://localhost/api/client-connections/pong"),
"codenomad_session",
Some("cookie-value"),
)
.build()
.expect("request should build");
assert_eq!(
request
.headers()
.get("Cookie")
.and_then(|value| value.to_str().ok()),
Some("codenomad_session=cookie-value")
);
}
#[test]
fn session_cookie_value_is_encoded_before_header_attachment() {
let request = attach_session_cookie_value(
Client::new().post("http://localhost/api/client-connections/pong"),
"codenomad_session",
Some("safe;\r\nInjected=bad value"),
)
.build()
.expect("request should build");
assert_eq!(
request
.headers()
.get("Cookie")
.and_then(|value| value.to_str().ok()),
Some("codenomad_session=safe%3B%0D%0AInjected=bad%20value")
);
}
}

View file

@ -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));
}

View file

@ -1,428 +0,0 @@
use super::*;
fn send_connection_pong(
app: &AppHandle,
client: &Client,
config: &DesktopEventStreamConfig,
payload: &Value,
) {
let body = serde_json::json!({
"clientId": config.client_id,
"connectionId": config.connection_id,
"pingTs": payload.get("ts").and_then(Value::as_u64),
});
let request = client
.post(format!(
"{}/api/client-connections/pong",
config.base_url.trim_end_matches('/')
))
.json(&body);
let _ = attach_session_cookie(request, app, config).send();
}
pub(super) fn run_transport_loop(
app: AppHandle,
generation_atomic: Arc<AtomicU64>,
generation: u64,
stop: Arc<AtomicBool>,
config: DesktopEventTransportConfig,
) {
let mut reconnect_attempt = 0_u32;
let mut stats = DesktopEventTransportStats::default();
let client = match build_stream_client() {
Ok(client) => client,
Err(error) => {
emit_status(
&app,
generation,
"error",
0,
true,
Some(error.message),
None,
None,
&stats,
);
return;
}
};
loop {
if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) {
break;
}
emit_status(
&app,
generation,
"connecting",
reconnect_attempt,
false,
None,
None,
None,
&stats,
);
match open_stream(&app, &client, &config.stream) {
Ok(response) => {
reconnect_attempt = 0;
emit_status(
&app,
generation,
"connected",
reconnect_attempt,
false,
None,
None,
None,
&stats,
);
let disconnect_reason = consume_stream(
&app,
&client,
&config.stream,
response,
&generation_atomic,
generation,
stop.clone(),
&mut stats,
);
if stop.load(Ordering::SeqCst)
|| !generation_matches(&generation_atomic, generation)
{
break;
}
if !schedule_retry(
&app,
&generation_atomic,
generation,
stop.clone(),
&config.reconnect,
&mut reconnect_attempt,
"disconnected",
disconnect_reason,
None,
&stats,
) {
break;
}
}
Err(error) => {
let state_name = match error.kind {
OpenStreamErrorKind::Unauthorized => "unauthorized",
OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error",
};
if !schedule_retry(
&app,
&generation_atomic,
generation,
stop.clone(),
&config.reconnect,
&mut reconnect_attempt,
state_name,
Some(error.message),
error.status_code,
&stats,
) {
break;
}
}
}
}
emit_status(
&app,
generation,
"stopped",
reconnect_attempt,
true,
None,
None,
None,
&stats,
);
}
fn schedule_retry(
app: &AppHandle,
generation_atomic: &Arc<AtomicU64>,
generation: u64,
stop: Arc<AtomicBool>,
policy: &ResolvedDesktopEventReconnectPolicy,
reconnect_attempt: &mut u32,
state_name: &'static str,
reason: Option<String>,
status_code: Option<u16>,
stats: &DesktopEventTransportStats,
) -> bool {
*reconnect_attempt = reconnect_attempt.saturating_add(1);
let terminal = policy
.max_attempts
.map(|max_attempts| *reconnect_attempt >= max_attempts)
.unwrap_or(false);
let next_delay_ms = if terminal {
None
} else {
Some(compute_reconnect_delay_ms(*reconnect_attempt, policy))
};
emit_status(
app,
generation,
state_name,
*reconnect_attempt,
terminal,
reason,
next_delay_ms,
status_code,
stats,
);
if terminal {
return false;
}
if let Some(delay_ms) = next_delay_ms {
wait_with_cancellation(generation_atomic, generation, stop, delay_ms);
}
true
}
fn wait_with_cancellation(
generation_atomic: &Arc<AtomicU64>,
generation: u64,
stop: Arc<AtomicBool>,
delay_ms: u64,
) {
let mut remaining_ms = delay_ms;
while remaining_ms > 0 {
if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) {
return;
}
let chunk_ms = remaining_ms.min(100);
thread::sleep(Duration::from_millis(chunk_ms));
remaining_ms -= chunk_ms;
}
}
fn consume_stream(
app: &AppHandle,
client: &Client,
stream_config: &DesktopEventStreamConfig,
response: Response,
generation_atomic: &Arc<AtomicU64>,
generation: u64,
stop: Arc<AtomicBool>,
stats: &mut DesktopEventTransportStats,
) -> Option<String> {
let (tx, rx) = mpsc::sync_channel::<ReaderMessage>(4096);
let reader_stop = stop.clone();
let reader_generation_atomic = generation_atomic.clone();
thread::spawn(move || {
read_sse(
response,
tx,
reader_stop,
reader_generation_atomic,
generation,
)
});
let mut pending = PendingBatch::default();
let mut sequence = 0_u64;
let mut last_reader_activity = Instant::now();
loop {
if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) {
return Some("stopped".to_string());
}
match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) {
Ok(ReaderMessage::Activity) => {
last_reader_activity = Instant::now();
}
Ok(ReaderMessage::Ping(payload)) => {
last_reader_activity = Instant::now();
send_connection_pong(app, client, stream_config, &payload);
}
Ok(ReaderMessage::Event(event)) => {
last_reader_activity = Instant::now();
stats.raw_events = stats.raw_events.saturating_add(1);
pending.push(event, stats);
if pending.pending_len() >= MAX_BATCH_EVENTS {
emit_pending_batch(
app,
generation,
&mut pending,
&mut sequence,
generation_atomic,
stats,
);
}
}
Ok(ReaderMessage::End(reason)) => {
if !pending.is_empty() {
emit_pending_batch(
app,
generation,
&mut pending,
&mut sequence,
generation_atomic,
stats,
);
}
return reason;
}
Err(RecvTimeoutError::Timeout) => {
if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS)
{
if !pending.is_empty() {
sequence += 1;
emit_batch(
app,
generation,
&mut pending,
sequence,
generation_atomic,
stats,
);
}
return Some("stream stalled".to_string());
}
if !pending.is_empty() {
if pending.should_hold_single_delta(Instant::now()) {
continue;
}
emit_pending_batch(
app,
generation,
&mut pending,
&mut sequence,
generation_atomic,
stats,
);
}
}
Err(RecvTimeoutError::Disconnected) => {
if !pending.is_empty() {
emit_pending_batch(
app,
generation,
&mut pending,
&mut sequence,
generation_atomic,
stats,
);
}
return Some("reader disconnected".to_string());
}
}
}
}
fn emit_pending_batch(
app: &AppHandle,
generation: u64,
pending: &mut PendingBatch,
sequence: &mut u64,
generation_atomic: &Arc<AtomicU64>,
stats: &mut DesktopEventTransportStats,
) {
if pending.is_empty() {
return;
}
*sequence += 1;
emit_batch(
app,
generation,
pending,
*sequence,
generation_atomic,
stats,
);
}
fn emit_batch(
app: &AppHandle,
generation: u64,
pending: &mut PendingBatch,
sequence: u64,
generation_atomic: &Arc<AtomicU64>,
stats: &mut DesktopEventTransportStats,
) {
if !generation_matches(generation_atomic, generation) {
return;
}
let events = pending.take_events();
if events.is_empty() {
return;
}
stats.emitted_batches = stats.emitted_batches.saturating_add(1);
stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64);
let _ = app.emit(
EVENT_BATCH_NAME,
WorkspaceEventBatchPayload {
generation,
sequence,
emitted_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
events,
},
);
}
fn emit_status(
app: &AppHandle,
generation: u64,
state_name: &'static str,
reconnect_attempt: u32,
terminal: bool,
reason: Option<String>,
next_delay_ms: Option<u64>,
status_code: Option<u16>,
stats: &DesktopEventTransportStats,
) {
let _ = app.emit(
EVENT_STATUS_NAME,
DesktopEventStreamStatusPayload {
generation,
state: state_name,
reconnect_attempt,
terminal,
reason,
next_delay_ms,
status_code,
stats: stats.clone(),
},
);
}
pub(super) fn generation_matches(generation_atomic: &Arc<AtomicU64>, generation: u64) -> bool {
generation_atomic.load(Ordering::SeqCst) == generation
}
pub(super) fn compute_reconnect_delay_ms(
attempt: u32,
policy: &ResolvedDesktopEventReconnectPolicy,
) -> u64 {
let exponent = attempt.saturating_sub(1) as i32;
let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent);
(scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms)
}

View file

@ -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<Option<KeepAwake>>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
@ -138,7 +133,6 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
#[tauri::command]
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
let dev_mode = is_dev_mode();
state.desktop_events.stop();
state.manager.stop().map_err(|e| e.to_string())?;
state
.manager
@ -147,21 +141,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
#[tauri::command]
fn desktop_events_start(
app: AppHandle,
state: tauri::State<AppState>,
request: Option<DesktopEventsStartRequest>,
) -> DesktopEventsStartResult {
let config = state.manager.desktop_event_stream_config();
state.desktop_events.start(app, config, request)
}
#[tauri::command]
fn desktop_events_stop(state: tauri::State<AppState>) {
state.desktop_events.stop();
}
#[tauri::command]
fn wake_lock_start(
state: tauri::State<AppState>,
@ -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,

View file

@ -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::<AppState>() {
state.desktop_events.stop();
retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || {
state.manager.stop().map_err(|err| err.to_string())
})?;

View file

@ -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",

View file

@ -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,

View file

@ -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<{
<Show
when={field.type !== "external"}
fallback={(
<div class="providers-field">
<a href={field.type === "external" ? field.url : ""} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
<ExternalLink class="w-4 h-4" />
{title(field)}
</a>
<Show when={field.description}><span class="settings-toggle-caption">{field.description}</span></Show>
</div>
<Show when={field.type === "external" && isProviderAuthHttpUrl(field.url)}>
<div class="providers-field">
<a href={field.type === "external" ? field.url : undefined} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
<ExternalLink class="w-4 h-4" />
{title(field)}
</a>
<Show when={field.description}><span class="settings-toggle-caption">{field.description}</span></Show>
</div>
</Show>
)}
>
<div class="providers-field">

View file

@ -11,6 +11,7 @@ import {
genericApiMethod,
getProviderAuthAnswer,
getProviderAuthInitialAnswer,
isProviderAuthHttpUrl,
isProviderAuthFieldComplete,
isAbortError,
shouldShowProviderAuthField,
@ -188,19 +189,27 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (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 = `<div style=\"font-family: sans-serif; padding: 24px; color: #111;\">${t("settings.providers.oauth.popup.loadingBody")}</div>`
}
return popup
} catch {
popup?.close()
return null
}
}
async function launchAuthorizationUrl(url: string, options?: { popup?: Window | null; sameTab?: boolean }): Promise<boolean> {
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<ProviderManagerModalProps> = (props
<Show when={stage() === "code"}><div class="providers-form-stack"><div class="providers-oauth-instructions"><ExternalLink class="providers-instructions-icon" /><span>{authorization()?.instructions || t("settings.providers.oauth.enterCode")}</span></div><label class="providers-field"><span class="settings-form-label">{t("settings.providers.oauth.codeLabel")}</span><input ref={(element) => { oauthCodeInput = element }} type="text" class="providers-input" value={code()} onInput={(event) => setCode(event.currentTarget.value)} placeholder={t("settings.providers.oauth.codePlaceholder")} autocomplete="one-time-code" /></label></div></Show>
<Show when={stage() === "waiting"}><div class="providers-waiting-card" role="status"><Loader2 class="providers-spin-icon" /><div><div class="settings-toggle-title">{selectedMethod().type === "command" ? t("settings.providers.command.waitingTitle") : t("settings.providers.oauth.waitingTitle")}</div><div class="settings-toggle-caption">{selectedMethod().type === "command" ? commandStatusMessage() ?? t("settings.providers.command.waitingMessage") : authorization()?.instructions}</div></div><button type="button" class="selector-button selector-button-secondary providers-wait-cancel" onClick={cancelOAuthWait}>{t("settings.providers.oauth.cancelWait")}</button></div></Show>
<Show when={authorization() && (stage() === "code" || stage() === "waiting")}>
<Show when={authorization() && isProviderAuthHttpUrl(authorization()!.url) && (stage() === "code" || stage() === "waiting")}>
<div class="providers-oauth-actions">
<a href={authorization()?.url} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
<ExternalLink class="w-4 h-4" />

View file

@ -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",
),
)

View file

@ -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.
}
}

View file

@ -1,62 +0,0 @@
export interface DesktopEventTransportReconnectPolicy {
initialDelayMs: number
maxDelayMs: number
multiplier: number
maxAttempts?: number
}
export interface DesktopEventTransportStartOptions {
reconnect?: Partial<DesktopEventTransportReconnectPolicy>
}
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<DesktopEventTransportStartOptions> {
return {
reconnect: {
...DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY,
...options?.reconnect,
},
}
}

View file

@ -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<WorkspaceEventConnection> {
const notifyDisconnected = () => {
@ -45,42 +35,3 @@ async function connectBrowserWorkspaceEvents(
},
}
}
export async function connectWorkspaceEvents(
callbacks: WorkspaceEventTransportCallbacks,
options?: DesktopEventTransportStartOptions,
): Promise<WorkspaceEventConnection> {
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"

View file

@ -33,8 +33,6 @@ function splitKeywords(key: string): string[] {
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
useTauriNativeEventTransport: Accessor<boolean>
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,

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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 לשליחה",

View file

@ -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で送信",

View file

@ -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 थिचेर बुझाउनुहोस्",

View file

@ -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 для отправки",

View file

@ -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": "回车发送",

View file

@ -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"])
})
})

View file

@ -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<WorkspaceEventTransportCallbacks, "onError">) {
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<WorkspaceEventConnection> {
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<WorkspaceEventBatchPayload>("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<DesktopEventTransportStatusPayload>("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<DesktopEventsStartResult>("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)
})
},
}
}

View file

@ -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)
})
})

View file

@ -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<FormField, { type: "string" }>["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 {

View file

@ -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<Preferences>
useTauriNativeEventTransport: Accessor<boolean>
setUseTauriNativeEventTransport: (next: boolean) => void
updatePreferences?: (updates: Partial<Preferences>) => 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",

View file

@ -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<UiConfigBucket>({})
const [serverConfigBucket, setServerConfigBucket] = createSignal<ServerConfigBucket>({})
const [uiStateBucket, setUiStateBucket] = createSignal<UiStateBucket>({})
const [isLoaded, setIsLoaded] = createSignal(false)
const [useTauriNativeEventTransport, setUseTauriNativeEventTransportSignal] = createSignal(
readUseTauriNativeEventTransportPreference(),
)
const uiSettings = createMemo<UiSettings>(() => normalizeUiSettings(uiConfigBucket().settings))
const themePreference = createMemo<ThemePreference>(() => 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<boolean>
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<ConfigContextValue>()
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,

View file

@ -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<void>((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<void>((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", () => {

View file

@ -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<string, { desired: boolean; running: Promise<void> }>()
async function syncVoiceModeInstruction(client: ReturnType<typeof getRootClient>, instanceId: string, sessionId: string): Promise<void> {
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[] {

View file

@ -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,
})

View file

@ -91,7 +91,10 @@ import { messageStoreBus } from "./message-v2/bus"
import { handleConversationAssistantPartUpdated } from "./conversation-speech"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>()
const pendingSessionFetches = new Map<string, {
status: SessionStatus
retry?: SessionRetryState | null
}>()
const NATIVE_REFRESH_DELAY_MS = 75
const nativeRefreshes = new Map<string, {
instanceId: string
@ -306,16 +309,24 @@ function ensureSessionStatus(
}
const key = `${instanceId}:${sessionId}`
if (pendingSessionFetches.has(key)) return
const existingFetch = pendingSessionFetches.get(key)
if (existingFetch) {
existingFetch.status = status
existingFetch.retry = retry
return
}
const pendingState = { status, retry }
const pending = (async () => {
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" {

View file

@ -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<void>((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<void>((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)
}
})
})

View file

@ -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)