From ca06bd99d733d7e4f0e8f1480e623c01a04d7485 Mon Sep 17 00:00:00 2001 From: heunghingwan Date: Thu, 9 Jul 2026 03:13:44 +0800 Subject: [PATCH] feat(yolo): move permission auto-accept (Yolo) to the server (#561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Yolo (permission auto-accept) currently lives entirely in the UI. Each browser keeps its own toggle in `localStorage` and auto-replies to permission requests over a 4-hop path (`OpenCode → server SSE → UI → server proxy → OpenCode`). The server — which already sits on the event stream that carries every `permission.asked` — does none of the work. This PR makes the **server authoritative**: it owns the toggle state, resolves family-root inheritance, and performs the auto-reply in-process via loopback using the same `"once"` semantics the UI used to send. The UI becomes a pure view: it toggles via REST and mirrors state from a `yolo.stateChanged` SSE event. ## Why - **Correctness**: the server already consumes the instance SSE stream (`InstanceEventBridge`); auto-accepting there is the natural choke point instead of bouncing to the UI and back. - **Multi-client**: previously each browser had independent `localStorage` state and never synced. Toggles now broadcast to all connected clients in real time. - **Headless**: Yolo keeps auto-accepting even when no UI is connected (useful for long autonomous runs). - **Latency**: drops from 4 hops to a single in-process loopback call. ## What changed **Server (new, authoritative)** - `permissions/auto-accept-store.ts` — in-memory state keyed by family root. Faithful port of `resolvePermissionAutoAcceptFamilyRoot`: fork/`revert` sessions root at themselves; enabling any member enables the whole family. - `permissions/auto-accept-manager.ts` — subscribes to `instance.event`, builds the session tree from `session.created/updated/deleted` (`properties.info`), intercepts `permission.v2.asked` / `permission.asked`, dedupes in-flight replies, emits `yolo.stateChanged` / `yolo.autoAccepted`, clears per-instance state on `workspace.stopped/error`. - `permissions/opencode-replier.ts` — default replier calling OpenCode directly (`getInstancePort` + auth header), mirroring `background-processes/manager.ts`. - `server/routes/yolo.ts` — `GET/POST /workspaces/:id/yolo/sessions/:sid[/toggle]`, following existing route conventions. - `api-types.ts` / `events/bus.ts` — `YoloStateResponse` + the two new event types registered in `onEvent` so they flow over `/api/events`. **UI (pure view)** - `permission-auto-accept.ts` — removed `localStorage`, persistence, and drain logic; now a runtime (non-persisted) projection. `resolvePermissionAutoAcceptFamilyRoot` is **retained as a display aid** so the badge still lights up for child/sub-sessions of an enabled family (preserving the inheritance UX). - `instances.ts` — toggle calls REST (optimistic, reconciled on success, reverted on failure); subscribes to `yolo.stateChanged`; `ensureYoloStateSynced` backfills the active session's state on first connect (deduped per session, reset on SSE reconnect so it re-syncs after a server restart). - `session-events.ts` — removed the three `drainAutoAcceptPermissionsForInstance` hooks (the server drains now). - `api-client.ts` — `getYoloState` / `toggleYolo`. ## Behavior parity | Aspect | Before | After | |---|---|---| | Reply semantics | `"once"` | `"once"` (unchanged) | | Inheritance | whole family (root + non-fork descendants) | **same** | | Fork isolation | `revert` session is its own root | **same** | | Persistence | UI `localStorage` | none (intentional, see Notes) | | Multi-client sync | ❌ independent per browser | ✅ real-time via SSE | | Works with UI closed | ❌ | ✅ | | Auto-reply path | 4 hops | 1 in-process hop | ## Testing 32 unit tests added (`node:test`), all passing. Server + UI typecheck clean. - **Store (16)**: inheritance (parent/child/sibling), fork isolation, cyclic parent chains, late parent discovery, `revert` re-rooting, per-instance independence, tree maintenance. - **Manager (13)**: real `properties.info` event shapes, v2 vs legacy reply, in-flight dedup + retry-after-resolve, `yolo.stateChanged` emission, `session.deleted` keeps toggle, `workspace.stopped` clears state, `stop()` unsubscribes. - **UI (3)**: retained `resolvePermissionAutoAcceptFamilyRoot` display-projection tests. ## Notes for reviewers - **No persistence is intentional** for this milestone — server restart resets all Yolo state (matches the agreed scope). The UI mirror self-heals via SSE reconnect + `ensureYoloStateSynced`. Persistence can be layered on later (e.g. into `~/.config/codenomad/config.json`) without touching the manager. - **Family-root resolution lives in two places on purpose**: the server resolves it to decide whether to auto-reply; the UI resolves the same pure function to render the badge instantly (synchronous memo). Both are faithful ports; no network round-trip is added for display. - **`properties.info` nesting**: OpenCode wraps session records under `properties.info` for `session.*` events (permission events are flat). The manager handles both and the tests use the real nested shape to guard against regressions. - `getYoloState` / `yolo.autoAccepted` are wired but `yolo.autoAccepted` is not yet consumed by the UI — it's available on the wire for future observability (e.g. an audit log / toast). ## Risk / rollback The change is additive on the server and the UI gracefully degrades to the SSE mirror. If the server lacks the new routes (mixed-version), the UI's optimistic toggle still flips locally and `toggleYolo` failures are logged + reverted, so no hard breakage. --------- Co-authored-by: Pascal André --- .../references/feature-traces.md | 11 +- .../references/sdk-integration-patterns.md | 26 +- package-lock.json | 1 + packages/server/package.json | 1 + packages/server/src/api-types.ts | 8 + packages/server/src/events/bus.ts | 4 + .../permissions/auto-accept-manager.test.ts | 668 ++++++++++++++++++ .../src/permissions/auto-accept-manager.ts | 296 ++++++++ .../src/permissions/auto-accept-store.test.ts | 182 +++++ .../src/permissions/auto-accept-store.ts | 128 ++++ .../src/permissions/opencode-replier.ts | 47 ++ packages/server/src/server/http-server.ts | 15 + packages/server/src/server/routes/yolo.ts | 26 + .../server/src/workspaces/instance-client.ts | 49 ++ packages/server/tsconfig.json | 2 +- packages/ui/src/lib/api-client.ts | 12 + packages/ui/src/stores/instances.ts | 97 ++- .../ui/src/stores/permission-auto-accept.ts | 137 ++-- packages/ui/src/stores/session-events.ts | 14 +- packages/ui/src/stores/session-state.ts | 5 +- 20 files changed, 1583 insertions(+), 146 deletions(-) create mode 100644 packages/server/src/permissions/auto-accept-manager.test.ts create mode 100644 packages/server/src/permissions/auto-accept-manager.ts create mode 100644 packages/server/src/permissions/auto-accept-store.test.ts create mode 100644 packages/server/src/permissions/auto-accept-store.ts create mode 100644 packages/server/src/permissions/opencode-replier.ts create mode 100644 packages/server/src/server/routes/yolo.ts create mode 100644 packages/server/src/workspaces/instance-client.ts diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 286d32d8..2aa5a42d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references. 1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` - Events are pushed through the instance event stream -2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler - - **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true - - **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - **Action:** Automatically calls `Permission.reply()`, skips modal display - - **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()` +2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled) + - **File:** `packages/server/src/permissions/auto-accept-manager.ts` + - **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup + - **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes +3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` + - **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately - **Branch:** ELSE (normal flow) - **Mechanism:** Permission queued in `permissionQueues` signal - **Action:** Display approval modal diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index eda86515..3f5d4321 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync: 1. **Server emits** `permission.asked` or `permission.updated` SSE event - Pushed through instance event stream -2. **UI Store receives** via `serverEvents` +2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled) + - File: `packages/server/src/permissions/auto-accept-manager.ts` + - Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change + - Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI +3. **UI Store receives** via `serverEvents` - File: `packages/ui/src/stores/instances.ts` - - **Branch:** IF `isPermissionAutoAcceptEnabled()` - - Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - Action: Calls reply immediately, skips modal - - **Branch:** ELSE - - Mechanism: Queued in `permissionQueues` - - Action: Display modal -3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` -4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays -5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` -6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` -7. **Optimistic Update:** `removePermissionV2()` in bridge -8. **SSE Confirmation:** `permission.replied` event + - **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue + - **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal +4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` +5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays +6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` +7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` +8. **Optimistic Update:** `removePermissionV2()` in bridge +9. **SSE Confirmation:** `permission.replied` event - **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect ## Session Event Handling diff --git a/package-lock.json b/package-lock.json index 5d08c142..6777e1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13454,6 +13454,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/package.json b/packages/server/package.json index 15228764..3ca35925 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d2349cde..32eea774 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -365,6 +365,10 @@ export interface VoiceModeStateResponse { enabled: boolean } +export interface YoloStateResponse { + enabled: boolean +} + export interface RemoteServerProfile { id: string name: string @@ -414,6 +418,8 @@ export type WorkspaceEventType = | "instance.dataChanged" | "instance.event" | "instance.eventStatus" + | "yolo.stateChanged" + | "yolo.autoAccepted" export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } @@ -428,6 +434,8 @@ export type WorkspaceEventPayload = | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } + | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } export interface NetworkAddress { ip: string diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index fd1e3ce6..7929c7e2 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -31,6 +31,8 @@ export class EventBus extends EventEmitter { this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) + this.on("yolo.stateChanged", handler) + this.on("yolo.autoAccepted", handler) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) @@ -44,6 +46,8 @@ export class EventBus extends EventEmitter { this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) + this.off("yolo.stateChanged", handler) + this.off("yolo.autoAccepted", handler) } } } diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts new file mode 100644 index 00000000..629ccf99 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -0,0 +1,668 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { EventBus } from "../events/bus" +import { AutoAcceptManager, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager" +import type { InstanceStreamEvent } from "../api-types" +import type { Logger } from "../logger" + +const noopLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + trace() {}, + isLevelEnabled() { + return false + }, + child() { + return noopLogger + }, +} as unknown as Logger + +function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { + bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) +} + +/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */ +function publishSession( + bus: EventBus, + instanceId: string, + eventType: "session.updated" | "session.created" | "session.deleted", + info: Record, +) { + publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } }) +} + +describe("AutoAcceptManager session tree", () => { + it("ingests session.updated to build the parent chain", () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.toggle("inst", "child") + assert.equal(manager.isEnabled("inst", "child"), true) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) + + it("treats a session with revert as a fork root", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { + id: "fork", + parentID: "master", + revert: { messageID: "m", partID: "p" }, + }) + + manager.toggle("inst", "fork") + assert.equal(manager.isEnabled("inst", "fork"), true) + assert.equal(manager.isEnabled("inst", "master"), false) + + manager.stop() + }) + + it("session.deleted removes the tree entry but keeps the toggle", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + publishSession(bus, "inst", "session.deleted", { id: "master" }) + + // toggle is independent of the tree (survives deletion) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission interception", () => { + it("auto-replies to a v2 permission on an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const accepted: Record[] = [] + bus.on("yolo.autoAccepted", (e) => accepted.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + manager.toggle("inst", "child") // enable the whole family root + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + const call = replier.calls[0] + assert.equal(call.instanceId, "inst") + assert.equal(call.permissionId, "perm-1") + assert.equal(call.sessionId, "child") + assert.equal(call.source, "v2") + assert.equal(call.reply, "once") + assert.equal(accepted.length, 1) + assert.equal((accepted[0] as any).permissionId, "perm-1") + + manager.stop() + }) + + it("auto-replies to a legacy permission.asked event", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-2", sessionID: "solo", type: "bash" }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "legacy") + assert.equal(replier.calls[0].permissionId, "perm-2") + + manager.stop() + }) + + it("does not reply when the family is disabled", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-3", sessionID: "solo" }, + }) + + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.stop() + }) + + it("ignores permission events without an id or sessionID", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } }) + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("deduplicates repeated emission of the same permission", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + for (let i = 0; i < 3; i++) { + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-dup", sessionID: "solo" }, + }) + } + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + manager.stop() + }) + + it("clears in-flight tracking after the reply resolves so it can retry", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 2) + manager.stop() + }) +}) + +describe("AutoAcceptManager state events", () => { + it("publishes yolo.stateChanged with the new enabled value on toggle", () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + bus.on("yolo.stateChanged", (e) => changes.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + + manager.toggle("inst", "master") + manager.toggle("inst", "master") + + assert.equal(changes.length, 2) + assert.equal((changes[0] as any).enabled, true) + assert.equal((changes[1] as any).enabled, false) + assert.equal((changes[0] as any).sessionId, "master") + assert.equal((changes[0] as any).instanceId, "inst") + + manager.stop() + }) +}) + +describe("AutoAcceptManager lifecycle", () => { + it("clearInstance drops tree and enabled state for the instance", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + manager.clearInstance("inst") + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("clears state when the workspace stops", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + bus.publish({ type: "workspace.stopped", workspaceId: "inst" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("stop() unsubscribes so no further events are processed", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + manager.stop() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "p", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + }) +}) + +describe("AutoAcceptManager pending permissions drain", () => { + it("drains a pending permission that arrived before enable", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-pending", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // enabling yolo should drain the pending permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-pending") + + manager.stop() + }) + + it("drains pending permissions for the same family only", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-a", sessionID: "root-a" }, + }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-b", sessionID: "root-b" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.toggle("inst", "root-a") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-a") + + manager.stop() + }) + + it("does not re-drain already-auto-accepted permissions", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 1) + + // toggling off then on should not re-drain the already-replied permission + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain runs but pending set is empty + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + + manager.stop() + }) + + it("re-drains pending when late session ancestry joins an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + // master exists, yolo enabled on master + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + + // child permission arrives BEFORE child's session ancestry is known. + // At this point "child" is unknown to the tree, so it resolves as its + // own root and the permission is NOT auto-accepted. + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-late", sessionID: "child" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // child session ancestry arrives — child.parentID = "master". + // ingestSession → upsertSession → migrateEnabledRoots makes "child" + // resolve to "master" (enabled). drainPending should fire and accept + // the previously-pending permission. + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-late") + assert.equal(replier.calls[0].sessionId, "child") + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission replied cleanup", () => { + it("removes a pending permission on permission.v2.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-x", sessionID: "solo" }, + }) + await flushMicrotasks() + + // user manually replies → replied event cleans up pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.replied", + properties: { id: "perm-x" }, + }) + + // enabling yolo now should NOT drain the already-replied permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("removes a pending permission on legacy permission.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-y", sessionID: "solo" }, + }) + await flushMicrotasks() + + publishInstanceEvent(bus, "inst", { + type: "permission.replied", + properties: { requestID: "perm-y" }, + }) + + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager clearInstance clears pending", () => { + it("drops pending permissions on clearInstance", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-z", sessionID: "solo" }, + }) + await flushMicrotasks() + + manager.clearInstance("inst") + + // re-create session and enable — pending set should be empty + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager permission.updated source inference", () => { + it("preserves the original v2 source when permission.updated arrives", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + // yolo is OFF — permission goes to pending with source "v2" + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + // enable yolo, then send permission.updated — should keep source "v2" + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "v2") + + manager.stop() + }) + + it("skips permission.updated for a permission not in pending", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + // permission.updated for a permission that was never asked (not in pending) + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-unknown", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager replier failure handling", () => { + it("keeps permission pending and does not emit autoAccepted on failure", async () => { + const bus = new EventBus(noopLogger) + const autoAccepted: unknown[] = [] + bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e)) + const failingReplier: PermissionReplier = async () => { + throw new Error("connection refused") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-fail", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + // permission should still be pending (enabling again would try again) + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain retries + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + + manager.stop() + }) + + it("stops retrying after MAX_REPLY_ATTEMPTS", async () => { + const bus = new EventBus(noopLogger) + let callCount = 0 + const failingReplier: PermissionReplier = async () => { + callCount++ + throw new Error("timeout") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-stuck", sessionID: "solo" }, + }) + await flushMicrotasks() + const attemptsAfterFirst = callCount + + // trigger drains via session events + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + + // should have attempted at most MAX_REPLY_ATTEMPTS times total + assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`) + assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops + + manager.stop() + }) +}) + +describe("AutoAcceptManager workspace.error cleanup", () => { + it("clears state when the workspace errors", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + assert.equal(manager.isEnabled("inst", "solo"), true) + + bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any }) + + assert.equal(manager.isEnabled("inst", "solo"), false) + manager.stop() + }) +}) + +describe("AutoAcceptManager session.deleted clears pending", () => { + it("removes pending permissions for a deleted session", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF → goes to pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-doomed", sessionID: "solo" }, + }) + await flushMicrotasks() + + // session deleted → pending should be cleared + publishSession(bus, "inst", "session.deleted", { id: "solo" }) + + // enabling yolo should not drain the deleted session's permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +function makeRecordingReplier() { + const calls: AutoAcceptReply[] = [] + const replier: PermissionReplier = async (reply) => { + calls.push(reply) + } + return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] } +} + +function flushMicrotasks() { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts new file mode 100644 index 00000000..07b79d15 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -0,0 +1,296 @@ +import type { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import { AutoAcceptStore } from "./auto-accept-store" + +/** + * Server-side owner of Yolo (permission auto-accept). + * + * Subscribes to the instance SSE stream that the server already consumes + * (`InstanceEventBridge` -> EventBus `instance.event`) and: + * - maintains a per-instance session tree so family-root inheritance can + * be resolved identically to the previous frontend implementation + * - when a permission request arrives for an enabled family, auto-replies + * via the injected {@link PermissionReplier} (same `"once"` semantics the + * UI used to send) + * - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus + * so the UI stays a pure view + */ + +export type PermissionSource = "v2" | "legacy" +export type PermissionReplyValue = "once" + +export interface AutoAcceptReply { + instanceId: string + permissionId: string + sessionId: string + source: PermissionSource + reply: PermissionReplyValue +} + +export type PermissionReplier = (reply: AutoAcceptReply) => Promise + +interface PendingPermission { + permissionId: string + sessionId: string + source: PermissionSource +} + +interface AutoAcceptManagerDeps { + eventBus: EventBus + logger: Logger + replier: PermissionReplier +} + +const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) +const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) +const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) +const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) + +export class AutoAcceptManager { + private static readonly MAX_REPLY_ATTEMPTS = 3 + private readonly store = new AutoAcceptStore() + /** instanceId:permissionId entries currently being replied, to dedupe re-emissions */ + private readonly inFlight = new Set() + /** instanceId -> (permissionId -> pending permission) awaiting a reply */ + private readonly pending = new Map>() + /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */ + private readonly replyAttempts = new Map() + private unsubscribe?: () => void + + constructor(private readonly deps: AutoAcceptManagerDeps) {} + + start(): void { + if (this.unsubscribe) return + const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => { + if (!payload || !payload.instanceId || !payload.event) return + this.handleInstanceEvent(payload.instanceId, payload.event) + } + const onStopped = (event: { workspaceId?: string }) => { + if (event?.workspaceId) this.clearInstance(event.workspaceId) + } + const onError = (event: { workspace?: { id?: string } }) => { + if (event?.workspace?.id) this.clearInstance(event.workspace.id) + } + this.deps.eventBus.on("instance.event", handler) + this.deps.eventBus.on("workspace.stopped", onStopped) + this.deps.eventBus.on("workspace.error", onError) + this.unsubscribe = () => { + this.deps.eventBus.off("instance.event", handler) + this.deps.eventBus.off("workspace.stopped", onStopped) + this.deps.eventBus.off("workspace.error", onError) + } + } + + stop(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + isEnabled(instanceId: string, sessionId: string): boolean { + return this.store.isEnabled(instanceId, sessionId) + } + + toggle(instanceId: string, sessionId: string): boolean { + const enabled = this.store.toggle(instanceId, sessionId) + this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled }) + if (enabled) { + this.drainPending(instanceId, sessionId) + } + return enabled + } + + clearInstance(instanceId: string): void { + this.store.clearInstance(instanceId) + this.pending.delete(instanceId) + const prefix = `${instanceId}:` + for (const key of Array.from(this.inFlight.keys())) { + if (key.startsWith(prefix)) this.inFlight.delete(key) + } + for (const key of Array.from(this.replyAttempts.keys())) { + if (key.startsWith(prefix)) this.replyAttempts.delete(key) + } + } + + handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void { + if (!event || typeof event.type !== "string") return + + if (SESSION_UPSERT_TYPES.has(event.type)) { + this.ingestSession(instanceId, event.properties) + return + } + if (SESSION_REMOVE_TYPES.has(event.type)) { + const info = (event.properties as { info?: SessionProperties } | undefined)?.info + const id = readString(info?.id) ?? readString(event.properties?.id) + if (id) { + this.store.removeSession(instanceId, id) + this.removePendingForSession(instanceId, id) + } + return + } + if (PERMISSION_REPLIED_TYPES.has(event.type)) { + this.handlePermissionReplied(instanceId, event.properties) + return + } + if (PERMISSION_ASK_TYPES.has(event.type)) { + this.handlePermissionRequest(instanceId, event.type, event.properties) + } + } + + private ingestSession(instanceId: string, properties: unknown): void { + // OpenCode wraps session records under `properties.info` for + // session.created/updated/deleted (see SDK EventSessionUpdated). Accept a + // flat fallback only for defensive compatibility. + const info = (properties as { info?: SessionProperties } | SessionProperties | undefined) + const session = (info && typeof info === "object" && "info" in info ? info.info : info) as + | SessionProperties + | undefined + if (!session || typeof session.id !== "string") return + const parentId = session.parentID ?? session.parentId ?? null + const revert = session.revert ?? undefined + this.store.upsertSession(instanceId, { id: session.id, parentId, revert }) + // Session ancestry may have changed (parent discovered, revert toggled). + // Re-drain pending permissions whose family root may have migrated into + // an enabled family — mirrors the old UI's drainAutoAcceptPermissions- + // ForInstance trigger on session.updated (#497). + this.drainPending(instanceId, session.id) + } + + private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void { + const request = permission as PermissionProperties | undefined + if (!request) return + const permissionId = readString(request.id) + const sessionId = readString(request.sessionID) ?? readString(request.sessionId) + if (!permissionId || !sessionId) return + + // Infer source from the event type, but prefer the already-tracked source + // for permission.updated (which may belong to a v2 permission). + const existing = this.pending.get(instanceId)?.get(permissionId) + const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy") + + // `permission.updated` represents a detail change for a permission that + // is *already* pending. If it is no longer in our pending set it was + // already replied to (by us or the user) — skip to avoid a duplicate reply. + if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) { + return + } + + this.addPending(instanceId, { permissionId, sessionId, source }) + + if (!this.store.isEnabled(instanceId, sessionId)) return + this.tryAutoAccept(instanceId, permissionId, sessionId, source) + } + + private handlePermissionReplied(instanceId: string, properties: unknown): void { + const request = (properties as PermissionRepliedProperties | undefined) ?? {} + const permissionId = + readString(request.id) ?? + readString(request.requestID) ?? + readString(request.permissionID) ?? + readString(request.requestId) ?? + readString(request.permissionId) + if (permissionId) this.removePending(instanceId, permissionId) + } + + private tryAutoAccept( + instanceId: string, + permissionId: string, + sessionId: string, + source: PermissionSource, + ): void { + const key = `${instanceId}:${permissionId}` + if (this.inFlight.has(key)) return + const attempts = this.replyAttempts.get(key) ?? 0 + if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return + this.inFlight.add(key) + this.replyAttempts.set(key, attempts + 1) + + const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" } + + void this.deps.replier(reply) + .then(() => { + this.replyAttempts.delete(key) + this.removePending(instanceId, 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) + } + }) + .finally(() => { + this.inFlight.delete(key) + }) + } + + /** Auto-accept all pending permissions belonging to the same family root. */ + private drainPending(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending || instancePending.size === 0) return + const root = this.store.familyRoot(instanceId, sessionId) + for (const entry of Array.from(instancePending.values())) { + if (this.store.familyRoot(instanceId, entry.sessionId) === root) { + this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source) + } + } + } + + private addPending(instanceId: string, entry: PendingPermission): void { + let instancePending = this.pending.get(instanceId) + if (!instancePending) { + instancePending = new Map() + this.pending.set(instanceId, instancePending) + } + instancePending.set(entry.permissionId, entry) + } + + private removePending(instanceId: string, permissionId: string): void { + const instancePending = this.pending.get(instanceId) + if (instancePending?.delete(permissionId)) { + this.replyAttempts.delete(`${instanceId}:${permissionId}`) + if (instancePending.size === 0) this.pending.delete(instanceId) + } + } + + 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}`) + } + } + if (instancePending.size === 0) this.pending.delete(instanceId) + } +} + +interface InstanceStreamPayload { + type?: string + properties?: Record +} + +interface SessionProperties { + id?: string + parentID?: string | null + parentId?: string | null + revert?: unknown +} + +interface PermissionProperties { + id?: string + sessionID?: string + sessionId?: string +} + +interface PermissionRepliedProperties { + id?: string + requestID?: string + permissionID?: string + requestId?: string + permissionId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts new file mode 100644 index 00000000..5ccfd540 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store" + +describe("resolveFamilyRoot", () => { + it("returns the session id itself when no info is known", () => { + assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan") + }) + + it("keeps a loaded child as root when its parent is missing", () => { + const root = resolveFamilyRoot("child", (id) => + id === "child" ? { id: "child", parentId: "parent" } : undefined, + ) + assert.equal(root, "child") + }) + + it("resolves to the master session when the full parent chain is loaded", () => { + const root = resolveFamilyRoot("grandchild", (id) => { + if (id === "grandchild") return { id: "grandchild", parentId: "child" } + if (id === "child") return { id: "child", parentId: "master" } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "master") + }) + + it("keeps a fork session (with revert) as its own root", () => { + const root = resolveFamilyRoot("fork", (id) => { + if (id === "fork") + return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "fork") + }) + + it("terminates on cyclic parent chains without looping forever", () => { + const root = resolveFamilyRoot("a", (id) => { + if (id === "a") return { id: "a", parentId: "b" } + if (id === "b") return { id: "b", parentId: "a" } + return undefined + }) + // cycle: last known id before re-entering the cycle is returned + assert.ok(root === "a" || root === "b") + }) +}) + +describe("AutoAcceptStore inheritance", () => { + it("is disabled by default for an unknown session", () => { + const store = new AutoAcceptStore() + assert.equal(store.isEnabled("inst", "s1"), false) + }) + + it("enabling a parent enables every descendant that resolves to it", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.upsertSession("inst", { id: "grandchild", parentId: "child" }) + + store.setEnabled("inst", "master", true) + + assert.equal(store.isEnabled("inst", "master"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.isEnabled("inst", "grandchild"), true) + }) + + it("enabling a child also covers the parent family root and siblings", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child-a", parentId: "master" }) + store.upsertSession("inst", { id: "child-b", parentId: "master" }) + + store.setEnabled("inst", "child-a", true) + + assert.equal(store.isEnabled("inst", "child-a"), true) + assert.equal(store.isEnabled("inst", "child-b"), true) + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("a fork session is isolated: enabling it does not enable its parent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { + id: "fork", + parentId: "master", + revert: { messageID: "msg", partID: "part" }, + }) + + store.setEnabled("inst", "fork", true) + + assert.equal(store.isEnabled("inst", "fork"), true) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("disabling the family root clears the setting for all descendants", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + store.setEnabled("inst", "child", true) + assert.equal(store.isEnabled("inst", "child"), true) + + store.setEnabled("inst", "master", false) + assert.equal(store.isEnabled("inst", "child"), false) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("toggle flips the resolved family-root state and reports the new value", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + assert.equal(store.toggle("inst", "child"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.toggle("inst", "master"), false) + assert.equal(store.isEnabled("inst", "child"), false) + }) + + it("keeps per-instance state independent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst-a", { id: "root", parentId: null }) + store.upsertSession("inst-b", { id: "root", parentId: null }) + + store.setEnabled("inst-a", "root", true) + assert.equal(store.isEnabled("inst-a", "root"), true) + assert.equal(store.isEnabled("inst-b", "root"), false) + }) +}) + +describe("AutoAcceptStore session tree maintenance", () => { + it("migrates enabled root when a parent is discovered later", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "child", parentId: "parent" }) + // parent unknown -> child is its own root + store.setEnabled("inst", "child", true) + + // later the parent shows up — root should migrate from "child" to "parent" + store.upsertSession("inst", { id: "parent", parentId: null }) + assert.equal(store.isEnabled("inst", "parent"), true) + assert.equal(store.isEnabled("inst", "child"), true) + }) + + it("removing a session does not clear an enabled family root", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.removeSession("inst", "master") + // the toggle is independent of the session tree: it survives session deletion + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("clearInstance drops both tree and enabled state", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.clearInstance("inst") + assert.equal(store.isEnabled("inst", "master"), false) + store.upsertSession("inst", { id: "master", parentId: null }) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("changing revert status re-roots a session as a fork", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.setEnabled("inst", "child", true) + // parent family enabled + assert.equal(store.isEnabled("inst", "master"), true) + + // child becomes a fork + store.upsertSession("inst", { + id: "child", + parentId: "master", + revert: { messageID: "m", partID: "p" }, + }) + // now child resolves to itself; the family setting was on "master" so still on for master + assert.equal(store.isEnabled("inst", "master"), true) + // child is its own root now, not enabled unless toggled + assert.equal(store.isEnabled("inst", "child"), false) + }) +}) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts new file mode 100644 index 00000000..0e34a24e --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -0,0 +1,128 @@ +/** + * In-memory permission auto-accept (Yolo) state, owned by the server. + * + * This is a faithful port of the previous frontend implementation + * (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance + * semantics are preserved exactly: + * - state is keyed by the resolved *family root* session id + * - a session with a `revert` snapshot is treated as its own root (fork) + * - enabling any session enables its whole family root and vice-versa + * + * No persistence: state is lost on server restart, matching the "no + * persistence for now" milestone. + */ + +export interface AutoAcceptSessionInfo { + id: string + parentId?: string | null + /** Truthy value marks the session as a fork that roots at itself. */ + revert?: unknown +} + +type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined + +/** + * Resolve the family-root session id for `sessionId` by walking the parent + * chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so + * inheritance behaviour does not change. + */ +export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string { + let currentId = sessionId + let lastKnownId = sessionId + const seen = new Set() + + while (currentId && !seen.has(currentId)) { + seen.add(currentId) + const session = getSession(currentId) + if (!session) return lastKnownId + lastKnownId = session.id + if (session.revert) return session.id + if (!session.parentId) return session.id + currentId = session.parentId + } + + return currentId || sessionId +} + +export class AutoAcceptStore { + /** instanceId -> set of enabled family-root session ids */ + private readonly enabled = new Map>() + /** instanceId -> (sessionId -> info) */ + private readonly sessions = new Map>() + + isEnabled(instanceId: string, sessionId: string): boolean { + const root = this.familyRoot(instanceId, sessionId) + return this.enabled.get(instanceId)?.has(root) ?? false + } + + setEnabled(instanceId: string, sessionId: string, enabled: boolean): void { + const root = this.familyRoot(instanceId, sessionId) + let roots = this.enabled.get(instanceId) + if (!roots) { + if (!enabled) return + roots = new Set() + this.enabled.set(instanceId, roots) + } + if (enabled) { + roots.add(root) + } else { + roots.delete(root) + if (roots.size === 0) { + this.enabled.delete(instanceId) + } + } + } + + toggle(instanceId: string, sessionId: string): boolean { + const next = !this.isEnabled(instanceId, sessionId) + this.setEnabled(instanceId, sessionId, next) + return next + } + + upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void { + let tree = this.sessions.get(instanceId) + if (!tree) { + tree = new Map() + this.sessions.set(instanceId, tree) + } + tree.set(info.id, { + id: info.id, + parentId: info.parentId ?? null, + revert: info.revert, + }) + this.migrateEnabledRoots(instanceId) + } + + removeSession(instanceId: string, sessionId: string): void { + this.sessions.get(instanceId)?.delete(sessionId) + } + + clearInstance(instanceId: string): void { + this.sessions.delete(instanceId) + this.enabled.delete(instanceId) + } + + /** Resolves the family-root session id for the given session. */ + familyRoot(instanceId: string, sessionId: string): string { + const tree = this.sessions.get(instanceId) + return resolveFamilyRoot(sessionId, (id) => tree?.get(id)) + } + + /** + * Re-resolves every enabled family root for an instance after the session + * tree changes (new session, updated parent/revert). If a root now resolves + * to a different id, the enabled entry is migrated so toggles survive late + * ancestry discovery. + */ + private migrateEnabledRoots(instanceId: string): void { + const roots = this.enabled.get(instanceId) + if (!roots || roots.size === 0) return + for (const oldRoot of Array.from(roots)) { + const newRoot = this.familyRoot(instanceId, oldRoot) + if (newRoot !== oldRoot) { + roots.delete(oldRoot) + roots.add(newRoot) + } + } + } +} diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts new file mode 100644 index 00000000..2ed9bfc9 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.ts @@ -0,0 +1,47 @@ +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import { createInstanceClient } from "../workspaces/instance-client" +import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" + +interface OpencodeReplierDeps { + workspaceManager: WorkspaceManager + logger: Logger +} + +/** + * Default {@link PermissionReplier} that calls the OpenCode instance via the + * generated SDK client over loopback, using the same `"once"` reply the UI + * previously sent. + * + * Uses `createInstanceClient` so routes and body shapes are always correct + * for the installed SDK version — no hand-assembled URLs. + */ +export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { + return async (reply: AutoAcceptReply) => { + const client = createInstanceClient(deps.workspaceManager, reply.instanceId) + if (!client) { + throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + } + + const opts = { throwOnError: true } as const + + if (reply.source === "v2") { + await client.v2.session.permission.reply( + { + sessionID: reply.sessionId, + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } else { + await client.permission.reply( + { + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 5dd85468..a5b13b37 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,6 +22,7 @@ import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" +import { registerYoloRoutes } from "./routes/yolo" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" @@ -31,6 +32,8 @@ import { registerPreviewRoutes } from "./routes/previews" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" +import { AutoAcceptManager } from "../permissions/auto-accept-manager" +import { createOpencodePermissionReplier } from "../permissions/opencode-replier" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" @@ -192,6 +195,16 @@ export function createHttpServer(deps: HttpServerDeps) { logger: deps.logger.child({ component: "background-processes" }), }) + const yoloManager = new AutoAcceptManager({ + eventBus: deps.eventBus, + logger: deps.logger.child({ component: "yolo" }), + replier: createOpencodePermissionReplier({ + workspaceManager: deps.workspaceManager, + logger: deps.logger.child({ component: "yolo" }), + }), + }) + yoloManager.start() + registerAuthRoutes(app, { authManager: deps.authManager }) app.addHook("preHandler", (request, reply, done) => { @@ -309,6 +322,7 @@ export function createHttpServer(deps: HttpServerDeps) { voiceModeManager: deps.voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) + registerYoloRoutes(app, { yoloManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) @@ -371,6 +385,7 @@ export function createHttpServer(deps: HttpServerDeps) { return { port: actualPort, url: serverUrl, displayHost } }, stop: () => { + yoloManager.stop() closeSseClients() return app.close() }, diff --git a/packages/server/src/server/routes/yolo.ts b/packages/server/src/server/routes/yolo.ts new file mode 100644 index 00000000..ddd66985 --- /dev/null +++ b/packages/server/src/server/routes/yolo.ts @@ -0,0 +1,26 @@ +import { FastifyInstance } from "fastify" +import type { AutoAcceptManager } from "../../permissions/auto-accept-manager" + +interface RouteDeps { + yoloManager: AutoAcceptManager +} + +export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId", + async (request) => { + const { id, sessionId } = request.params + return { enabled: deps.yoloManager.isEnabled(id, sessionId) } + }, + ) + + app.post<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId/toggle", + async (request, reply) => { + const { id, sessionId } = request.params + const enabled = deps.yoloManager.toggle(id, sessionId) + reply.code(200) + return { enabled } + }, + ) +} diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts new file mode 100644 index 00000000..76be830c --- /dev/null +++ b/packages/server/src/workspaces/instance-client.ts @@ -0,0 +1,49 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { WorkspaceManager } from "./manager" + +const INSTANCE_HOST = "127.0.0.1" +const LOOPBACK_TIMEOUT_MS = 10_000 + +/** + * Creates an OpenCode SDK client for direct loopback communication with a + * running workspace instance. + * + * Routes and body shapes come from the auto-generated SDK contract + * (`@opencode-ai/sdk`), eliminating handwritten URL construction that can + * drift between SDK versions. Other server modules that need to call the + * OpenCode instance directly should use this factory rather than building + * `http://127.0.0.1:{port}/...` URLs by hand. + * + * All requests carry a 10-second timeout — loopback calls should be + * near-instant; a hang indicates a stuck instance. + * + * The client is cheap to create (object only, no connection); create one per + * call or cache per instance as needed. Returns `null` when the instance has + * no open port yet. + */ +export function createInstanceClient( + workspaceManager: WorkspaceManager, + instanceId: string, +): OpencodeClient | null { + const port = workspaceManager.getInstancePort(instanceId) + if (!port) return null + + const headers: Record = {} + const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId) + if (authorization) { + headers.authorization = authorization + } + + const workspace = workspaceManager.get(instanceId) + + return createOpencodeClient({ + baseUrl: `http://${INSTANCE_HOST}:${port}/`, + headers, + fetch: (url, init) => + fetch(url, { + ...(init as RequestInit), + signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(LOOPBACK_TIMEOUT_MS), + }), + ...(workspace?.path ? { directory: workspace.path } : {}), + }) +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 5f9cd234..e6f63b22 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "Bundler", "strict": true, "esModuleInterop": true, "resolveJsonModule": true, diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 353360ea..9cd6b02a 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -22,6 +22,7 @@ import type { RemoteServerProbeRequest, RemoteServerProbeResponse, VoiceModeStateResponse, + YoloStateResponse, WorkspaceCloneRequest, WorkspaceCloneResponse, WorktreeGitCommitRequest, @@ -520,6 +521,17 @@ export const serverApi = { body: JSON.stringify({ ...identity, enabled }), }) }, + getYoloState(instanceId: string, sessionId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}`, + ) + }, + toggleYolo(instanceId: string, sessionId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}/toggle`, + { method: "POST" }, + ) + }, sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise { const init: RequestInit = { method: "POST", diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 96fddf6d..056ebbea 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -41,10 +41,10 @@ import { pruneRepliedPermissions, } from "./permission-replies" import { - clearAutoAcceptPermission, - drainAutoAcceptPermissions, + clearPermissionAutoAcceptForInstance, isPermissionAutoAcceptEnabled, resolvePermissionAutoAcceptFamilyRoot, + setPermissionAutoAcceptEnabled, setPermissionAutoAcceptFamilyRootResolver, togglePermissionAutoAccept, } from "./permission-auto-accept" @@ -65,6 +65,31 @@ setPermissionAutoAcceptFamilyRootResolver((instanceId, sessionId) => { return resolvePermissionAutoAcceptFamilyRoot(sessionId, (id) => instanceSessions.get(id)) }) +// Server is authoritative for Yolo state; mirror toggles (incl. from other +// clients) arriving over the CodeNomad server event stream into the local +// projection so the badge/switch stay in sync. +serverEvents.on("yolo.stateChanged", (event) => { + if (event.type !== "yolo.stateChanged") return + const { instanceId, sessionId, enabled } = event + if (typeof instanceId !== "string" || typeof sessionId !== "string" || typeof enabled !== "boolean") return + log.info(`[SSE] Yolo state changed: ${instanceId}:${sessionId} -> ${enabled}`) + setPermissionAutoAcceptEnabled(instanceId, sessionId, enabled) +}) + +// When the server auto-accepts a permission, clean up the UI queue immediately +// instead of waiting for the OpenCode permission.replied SSE event (which may +// be delayed or missed on a flaky connection). This preserves the #424 +// invariant: auto-accept cleanup happens at the queue level, not tied to +// external event timing. +serverEvents.on("yolo.autoAccepted", (event) => { + if (event.type !== "yolo.autoAccepted") return + const { instanceId, permissionId } = event + if (typeof instanceId !== "string" || typeof permissionId !== "string") return + markPermissionReplied(instanceId, permissionId) + removePermissionFromQueue(instanceId, permissionId) + removePermissionV2(instanceId, permissionId) +}) + const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) @@ -326,7 +351,6 @@ async function syncPendingPermissions(instanceId: string): Promise { const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) } - drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) } catch (error) { log.warn("Failed to sync pending permissions", { instanceId, error }) } @@ -519,6 +543,8 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { case "workspace.error": upsertWorkspace(event.workspace) showWorkspaceLaunchError(event.workspace) + clearPermissionAutoAcceptForInstance(event.workspace.id) + clearSyncedYoloSessionsForInstance(event.workspace.id) break case "workspace.stopped": releaseInstanceResources(event.workspaceId) @@ -657,6 +683,8 @@ function removeInstance(id: string) { clearRepliedPermissions(id) clearQuestionQueue(id) clearInstanceMetadata(id) + clearPermissionAutoAcceptForInstance(id) + clearSyncedYoloSessionsForInstance(id) if (activeInstanceId() === id) { setActiveInstanceId(nextActiveId) @@ -1001,7 +1029,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest, } - drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission) return queuedPermission } @@ -1037,7 +1064,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo if (removed) { const removedSessionId = getPermissionSessionId(removed) if (removedSessionId) { - clearAutoAcceptPermission(instanceId, removedSessionId, permissionId) const remaining = decrementSessionPendingCount(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } @@ -1045,23 +1071,61 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo } function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void { - const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + const wasEnabled = isPermissionAutoAcceptEnabled(instanceId, sessionId) togglePermissionAutoAccept(instanceId, sessionId) - if (!willEnable) return - drainAutoAcceptPermissionsForInstance(instanceId) + void serverApi + .toggleYolo(instanceId, sessionId) + .then((state) => { + setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled) + }) + .catch((error) => { + log.warn("Failed to toggle Yolo on server", { instanceId, sessionId, error }) + // revert to the pre-toggle state (not a naive flip, which can be wrong + // if an SSE yolo.stateChanged arrived between toggle and catch) + setPermissionAutoAcceptEnabled(instanceId, sessionId, wasEnabled) + }) } -function drainAutoAcceptPermissionsForInstance(instanceId: string): void { - drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) +/** + * Sessions whose Yolo state has been backfilled from the server. The server is + * authoritative but only pushes changes (`yolo.stateChanged`); a freshly + * connected client must fetch the effective state for a session so the badge + * matches reality from the start. De-duped per session and reset on SSE + * reconnect so state re-syncs after a server restart. + */ +const syncedYoloSessions = new Set() + +export function ensureYoloStateSynced(instanceId: string, sessionId: string): void { + if (!instanceId || !sessionId || sessionId === "info") return + const key = `${instanceId}:${sessionId}` + if (syncedYoloSessions.has(key)) return + syncedYoloSessions.add(key) + void serverApi + .getYoloState(instanceId, sessionId) + .then((state) => { + setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled) + }) + .catch((error) => { + // allow retry on next activation (e.g. instance not ready yet) + syncedYoloSessions.delete(key) + log.warn("Failed to sync Yolo state", { instanceId, sessionId, error }) + }) +} + +serverEvents.onOpen(() => { + syncedYoloSessions.clear() +}) + +function clearSyncedYoloSessionsForInstance(instanceId: string): void { + const prefix = `${instanceId}:` + for (const key of Array.from(syncedYoloSessions)) { + if (key.startsWith(prefix)) { + syncedYoloSessions.delete(key) + } + } } function clearPermissionQueue(instanceId: string): void { - for (const permission of getPermissionQueue(instanceId)) { - const sessionId = getPermissionSessionId(permission) - if (sessionId) { - clearAutoAcceptPermission(instanceId, sessionId, permission.id) - } - } for (const permission of getPermissionQueue(instanceId)) { permissionEnqueuedAt.delete(permission.id) } @@ -1391,7 +1455,6 @@ export { markPermissionReplied, hasRepliedPermission, togglePermissionAutoAcceptForSession, - drainAutoAcceptPermissionsForInstance, clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index aca94793..f991186b 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -1,15 +1,28 @@ import { createSignal } from "solid-js" -import type { PermissionReply, PermissionRequest } from "../types/permission" -import { getPermissionSessionId } from "../types/permission" -import { getLogger } from "../lib/logger" -const STORAGE_KEY = "codenomad:permission-auto-accept:v1" +/** + * UI-side mirror of the server-owned Yolo (permission auto-accept) state. + * + * The server is authoritative: it holds the toggle state, resolves + * family-root inheritance, and performs the actual `"once"` replies. This + * module only keeps a runtime (NON-persisted) projection so the UI can render + * the badge / switch synchronously. + * + * State is populated from: + * - local toggles (optimistic, then confirmed via REST) + * - `yolo.stateChanged` SSE events (wired in the app bootstrap, see + * `stores/instances.ts`, so toggles from other clients reflect) + * + * `resolvePermissionAutoAcceptFamilyRoot` is retained as a display aid so the + * badge correctly lights up for child/sub-sessions of an enabled family root, + * preserving the previous inheritance UX exactly. The server performs the same + * resolution independently when deciding whether to auto-reply. + * + * NOTE: intentionally pure — no SSE/REST side effects at module load, so it + * stays unit-testable. + */ -const log = getLogger("api") - -type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise -type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean -type PermissionAutoAcceptSession = { +export type PermissionAutoAcceptSession = { id: string parentId?: string | null revert?: unknown @@ -45,112 +58,44 @@ function makeKey(instanceId: string, sessionId: string) { return `${instanceId}:${resolveFamilyRoot(instanceId, sessionId)}` } -function readInitialState() { - if (typeof window === "undefined" || !window.localStorage) { - return new Map() - } - - try { - const raw = window.localStorage.getItem(STORAGE_KEY) - if (!raw) return new Map() - const parsed = JSON.parse(raw) as Record - return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true)) - } catch { - return new Map() - } -} - -function persist(next: Map) { - if (typeof window === "undefined" || !window.localStorage) { - return - } - - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next))) - } catch { - // ignore persistence failures - } -} - -const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState()) - -const inFlight = new Set() +const [autoAcceptState, setAutoAcceptState] = createSignal>(new Map()) export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) { return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false } export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) { - const key = makeKey(instanceId, sessionId) setAutoAcceptState((prev) => { + const key = makeKey(instanceId, sessionId) + if (prev.get(key) === enabled) return prev const next = new Map(prev) if (enabled) { next.set(key, true) } else { next.delete(key) } - persist(next) return next }) - if (!enabled) { - clearAutoAcceptSession(instanceId, sessionId) - } } export function togglePermissionAutoAccept(instanceId: string, sessionId: string) { - setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId)) + const next = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + setPermissionAutoAcceptEnabled(instanceId, sessionId, next) + return next } -function makeRequestKey(instanceId: string, sessionId: string, requestId: string) { - return `${makeKey(instanceId, sessionId)}:${requestId}` -} - -export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) { - const requestKey = makeRequestKey(instanceId, sessionId, requestId) - inFlight.delete(requestKey) -} - -export function clearAutoAcceptSession(instanceId: string, sessionId: string) { - const prefix = `${makeKey(instanceId, sessionId)}:` - for (const requestKey of Array.from(inFlight)) { - if (requestKey.startsWith(prefix)) { - inFlight.delete(requestKey) +/** Remove all Yolo state entries for an instance (workspace stop / removal). */ +export function clearPermissionAutoAcceptForInstance(instanceId: string) { + setAutoAcceptState((prev) => { + const prefix = `${instanceId}:` + let changed = false + const next = new Map(prev) + for (const key of Array.from(next.keys())) { + if (key.startsWith(prefix)) { + next.delete(key) + changed = true + } } - } -} - -export function drainAutoAcceptPermission( - instanceId: string, - permission: PermissionRequest, - responder: AutoAcceptResponder, - isPending: PendingPermissionChecker, -) { - const sessionId = getPermissionSessionId(permission) - if (!sessionId || !permission?.id) return - if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return - if (!isPending(instanceId, permission.id)) return - - const requestKey = makeRequestKey(instanceId, sessionId, permission.id) - if (inFlight.has(requestKey)) return - - inFlight.add(requestKey) - - void responder(instanceId, sessionId, permission.id, "once") - .catch((error) => { - log.error("Failed to auto-accept permission", error) - }) - .finally(() => { - inFlight.delete(requestKey) - }) -} - -export function drainAutoAcceptPermissions( - instanceId: string, - permissions: PermissionRequest[], - responder: AutoAcceptResponder, - isPending: PendingPermissionChecker, -) { - for (const permission of permissions) { - drainAutoAcceptPermission(instanceId, permission, responder, isPending) - } + return changed ? next : prev + }) } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 1742e325..36473700 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -50,7 +50,6 @@ import { hasRepliedPermission, addQuestionToQueue, removeQuestionFromQueue, - drainAutoAcceptPermissionsForInstance, } from "./instances" import { showAlertDialog } from "./alerts" import { @@ -235,7 +234,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory let updatedInstanceSessions: Map | undefined let shouldExpandParent: string | null = null - let shouldDrainAutoAcceptPermissions = false setSessions((prev) => { const next = new Map(prev) @@ -258,7 +256,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) updatedInstanceSessions = instanceSessions - shouldDrainAutoAcceptPermissions = Boolean(merged.parentId) if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") { shouldExpandParent = merged.parentId @@ -268,10 +265,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - if (shouldDrainAutoAcceptPermissions) { - drainAutoAcceptPermissionsForInstance(instanceId) - } - if (shouldExpandParent) { ensureSessionParentExpanded(instanceId, shouldExpandParent) } @@ -542,9 +535,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) setSessionRevertV2(instanceId, info.id, info.revert ?? null) - if (newSession.parentId) { - drainAutoAcceptPermissionsForInstance(instanceId) - } else { + if (!newSession.parentId) { prependSessionListId(instanceId, newSession.id) } @@ -585,9 +576,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) setSessionRevertV2(instanceId, info.id, info.revert ?? null) - if (updatedSession.parentId) { - drainAutoAcceptPermissionsForInstance(instanceId) - } } } diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index eb9cf36a..634d9df7 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -4,7 +4,7 @@ import { getIdleSinceForStatusTransition, type Session, type SessionStatus, type import { deleteSession, loadMessages } from "./session-api" import { showToastNotification } from "../lib/notifications" import { messageStoreBus } from "./message-v2/bus" -import { instances } from "./instances" +import { instances, ensureYoloStateSynced } from "./instances" import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" @@ -483,6 +483,9 @@ function setActiveSession(instanceId: string, sessionId: string): void { next.set(instanceId, sessionId) return next }) + // Backfill authoritative Yolo state for the now-active session so the badge + // matches the server even on first connect / multi-client scenarios. + ensureYoloStateSynced(instanceId, sessionId) } function setActiveParentSession(instanceId: string, parentSessionId: string): void {