mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-10 17:03:36 +00:00
Merge 55da0541f4 into 31414521f6
This commit is contained in:
commit
fa7229e7ee
14 changed files with 971 additions and 130 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
306
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
306
packages/server/src/permissions/auto-accept-manager.test.ts
Normal 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))
|
||||
}
|
||||
171
packages/server/src/permissions/auto-accept-manager.ts
Normal file
171
packages/server/src/permissions/auto-accept-manager.ts
Normal 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
|
||||
}
|
||||
184
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
184
packages/server/src/permissions/auto-accept-store.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
108
packages/server/src/permissions/auto-accept-store.ts
Normal file
108
packages/server/src/permissions/auto-accept-store.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
54
packages/server/src/permissions/opencode-replier.ts
Normal file
54
packages/server/src/permissions/opencode-replier.ts
Normal 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}` : ""}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
||||
|
||||
|
|
|
|||
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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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