This commit is contained in:
heunghingwan 2026-06-22 08:16:52 +08:00 committed by GitHub
commit fa7229e7ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 971 additions and 130 deletions

View file

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

View file

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

View file

@ -0,0 +1,306 @@
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)
})
})
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))
}

View file

@ -0,0 +1,171 @@
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 AutoAcceptManagerDeps {
eventBus: EventBus
logger: Logger
replier: PermissionReplier
}
const PERMISSION_EVENT_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"])
const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"])
const SESSION_REMOVE_TYPES = new Set(["session.deleted"])
export class AutoAcceptManager {
private readonly store = new AutoAcceptStore()
/** instanceId:permissionId entries currently being replied, to dedupe re-emissions */
private readonly inFlight = new Set<string>()
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 })
return enabled
}
clearInstance(instanceId: string): void {
this.store.clearInstance(instanceId)
}
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)
return
}
if (PERMISSION_EVENT_TYPES.has(event.type)) {
this.maybeAutoAccept(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 })
}
private maybeAutoAccept(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
if (!this.store.isEnabled(instanceId, sessionId)) return
const key = `${instanceId}:${permissionId}`
if (this.inFlight.has(key)) return
this.inFlight.add(key)
const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : "legacy"
const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" }
void this.deps.replier(reply)
.then(() => {
this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId })
})
.catch((error) => {
this.deps.logger.error({ instanceId, permissionId, err: error }, "Yolo auto-accept reply failed")
})
.finally(() => {
this.inFlight.delete(key)
})
}
}
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
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined
}

View file

@ -0,0 +1,184 @@
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("re-evaluates family 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
store.upsertSession("inst", { id: "parent", parentId: null })
// child now resolves to "parent"; the original setting was on "child"
// so the parent family is NOT enabled (child's own root id was recorded)
assert.equal(store.isEnabled("inst", "parent"), false)
assert.equal(store.isEnabled("inst", "child"), false)
})
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)
})
})

View file

@ -0,0 +1,108 @@
/**
* 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.resolveRoot(instanceId, sessionId)
return this.enabled.get(instanceId)?.has(root) ?? false
}
setEnabled(instanceId: string, sessionId: string, enabled: boolean): void {
const root = this.resolveRoot(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,
})
}
removeSession(instanceId: string, sessionId: string): void {
this.sessions.get(instanceId)?.delete(sessionId)
}
clearInstance(instanceId: string): void {
this.sessions.delete(instanceId)
this.enabled.delete(instanceId)
}
private resolveRoot(instanceId: string, sessionId: string): string {
const tree = this.sessions.get(instanceId)
return resolveFamilyRoot(sessionId, (id) => tree?.get(id))
}
}

View file

@ -0,0 +1,54 @@
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import { fetch } from "undici"
import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager"
const INSTANCE_HOST = "127.0.0.1"
interface OpencodeReplierDeps {
workspaceManager: WorkspaceManager
logger: Logger
}
/**
* Default {@link PermissionReplier} that calls the OpenCode instance directly
* over loopback using the same `"once"` reply the UI previously sent.
*
* - v2: POST /session/{sessionID}/permissions/{permissionID} body { response }
* - legacy: POST /permission/{requestID}/reply body { reply }
*
* Mirrors the per-instance direct-call pattern used by the background-process
* notifier (`background-processes/manager.ts`).
*/
export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier {
return async (reply: AutoAcceptReply) => {
const port = deps.workspaceManager.getInstancePort(reply.instanceId)
if (!port) {
throw new Error(`Yolo: instance ${reply.instanceId} has no open port`)
}
const headers: Record<string, string> = { "content-type": "application/json" }
const authorization = deps.workspaceManager.getInstanceAuthorizationHeader(reply.instanceId)
if (authorization) {
headers.authorization = authorization
}
const url =
reply.source === "v2"
? `http://${INSTANCE_HOST}:${port}/session/${encodeURIComponent(reply.sessionId)}/permissions/${encodeURIComponent(reply.permissionId)}`
: `http://${INSTANCE_HOST}:${port}/permission/${encodeURIComponent(reply.permissionId)}/reply`
const body =
reply.source === "v2"
? JSON.stringify({ response: reply.reply })
: JSON.stringify({ reply: reply.reply })
const response = await fetch(url, { method: "POST", headers, body })
if (!response.ok) {
const text = await response.text().catch(() => "")
throw new Error(
`Yolo reply failed (${reply.source}): ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`,
)
}
}
}

View file

@ -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,17 @@ 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()
sseClients.add(() => yoloManager.stop())
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
@ -309,6 +323,7 @@ export function createHttpServer(deps: HttpServerDeps) {
voiceModeManager: deps.voiceModeManager,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerYoloRoutes(app, { yoloManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

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

View file

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

View file

@ -41,10 +41,9 @@ import {
pruneRepliedPermissions,
} from "./permission-replies"
import {
clearAutoAcceptPermission,
drainAutoAcceptPermissions,
isPermissionAutoAcceptEnabled,
resolvePermissionAutoAcceptFamilyRoot,
setPermissionAutoAcceptEnabled,
setPermissionAutoAcceptFamilyRootResolver,
togglePermissionAutoAccept,
} from "./permission-auto-accept"
@ -65,6 +64,17 @@ 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)
})
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
@ -326,7 +336,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 })
}
@ -1001,7 +1010,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest,
}
drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission)
return queuedPermission
}
@ -1037,7 +1045,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 +1052,50 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
}
function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void {
const willEnable = !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 optimistic local state on failure
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
})
}
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) 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 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 +1425,6 @@ export {
markPermissionReplied,
hasRepliedPermission,
togglePermissionAutoAcceptForSession,
drainAutoAcceptPermissionsForInstance,
clearPermissionQueue,
sendPermissionResponse,
setActivePermissionIdForInstance,

View file

@ -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,28 @@ 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))
}
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)
}
}
}
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)
}
const next = !isPermissionAutoAcceptEnabled(instanceId, sessionId)
setPermissionAutoAcceptEnabled(instanceId, sessionId, next)
return next
}

View file

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

View file

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