mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-09 16:00:52 +00:00
feat(yolo): move permission auto-accept (Yolo) to the server (#561)
## 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é <pascalandr@gmail.com>
This commit is contained in:
parent
bce975d940
commit
ca06bd99d7
20 changed files with 1583 additions and 146 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
668
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
668
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>[] = []
|
||||
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<string, unknown>[] = []
|
||||
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<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
296
packages/server/src/permissions/auto-accept-manager.ts
Normal file
296
packages/server/src/permissions/auto-accept-manager.ts
Normal file
|
|
@ -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<void>
|
||||
|
||||
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<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 */
|
||||
private readonly replyAttempts = new Map<string, number>()
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
182
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
182
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
128
packages/server/src/permissions/auto-accept-store.ts
Normal file
128
packages/server/src/permissions/auto-accept-store.ts
Normal file
|
|
@ -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<string>()
|
||||
|
||||
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<string, Set<string>>()
|
||||
/** instanceId -> (sessionId -> info) */
|
||||
private readonly sessions = new Map<string, Map<string, AutoAcceptSessionInfo>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
packages/server/src/permissions/opencode-replier.ts
Normal file
47
packages/server/src/permissions/opencode-replier.ts
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
26
packages/server/src/server/routes/yolo.ts
Normal file
26
packages/server/src/server/routes/yolo.ts
Normal file
|
|
@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
49
packages/server/src/workspaces/instance-client.ts
Normal file
49
packages/server/src/workspaces/instance-client.ts
Normal file
|
|
@ -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<string, string> = {}
|
||||
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 } : {}),
|
||||
})
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
|
|
|
|||
|
|
@ -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<YoloStateResponse> {
|
||||
return request<YoloStateResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
},
|
||||
toggleYolo(instanceId: string, sessionId: string): Promise<YoloStateResponse> {
|
||||
return request<YoloStateResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}/toggle`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise<void> {
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -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<Map<string, Instance>>(new Map())
|
||||
|
||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||
|
|
@ -326,7 +351,6 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
|
|||
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<string>()
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
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<string, boolean>()
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return new Map<string, boolean>()
|
||||
const parsed = JSON.parse(raw) as Record<string, boolean>
|
||||
return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true))
|
||||
} catch {
|
||||
return new Map<string, boolean>()
|
||||
}
|
||||
}
|
||||
|
||||
function persist(next: Map<string, boolean>) {
|
||||
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<string>()
|
||||
const [autoAcceptState, setAutoAcceptState] = createSignal<Map<string, boolean>>(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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Session> | 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue