diff --git a/opencode.json b/opencode.json index b4b12ab..a54c136 100644 --- a/opencode.json +++ b/opencode.json @@ -12,5 +12,21 @@ "hostname": "127.0.0.1", "port": 4096 }, + "permission": { + "*": "allow", + "external_directory": "ask", + "bash": { + "*": "allow", + "rm *": "ask", + "rmdir *": "ask", + "mv *": "ask", + "chmod *": "ask", + "chown *": "ask", + "sudo *": "ask", + "curl *": "ask", + "wget *": "ask" + }, + "edit": "ask" + }, "default_agent": "instruction" -} \ No newline at end of file +} diff --git a/src/routes/chat.ts b/src/routes/chat.ts index a36a1c5..557a61a 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -9,7 +9,10 @@ import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type ResultReferenceResolver } from "../results/resolver.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js"; -import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; +import { + type PermissionReply, + type OpencodeRuntimeAdapter, +} from "../runtime/opencode.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type SessionRecord } from "../sessions/metadataStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; @@ -21,6 +24,7 @@ import { } from "./chatSession.js"; import { collectTextContent, + type PermissionRequestPayload, streamPromptResponse, supportedModels, type SupportedModel, @@ -36,6 +40,12 @@ const abortPayloadSchema = z.object({ session_id: z.string().max(128), }); +const permissionReplyPayloadSchema = z.object({ + session_id: z.string().max(128), + reply: z.enum(["once", "always", "reject"]), + message: z.string().max(1000).optional(), +}); + const createSessionPayloadSchema = z.object({ session_id: z.string().max(128).optional(), parent_session_id: z.string().max(128).optional(), @@ -64,6 +74,7 @@ type ActiveRun = { clientSessionId: string; controller: AbortController; messages: unknown[]; + pendingPermissions: Map; status: RunStatus; subscribers: Set; }; @@ -186,6 +197,46 @@ const updateLastAssistantMessage = ( return messages; }; +const updateLastAssistantPermission = ( + messages: unknown[], + requestId: string, + updater: (permission: Record) => Record, +) => + updateLastAssistantMessage(messages, (message) => { + const permissions = Array.isArray(message.permissions) + ? message.permissions + : []; + return { + ...message, + permissions: permissions.map((permission) => + isObjectRecord(permission) && permission.requestId === requestId + ? updater(permission) + : permission, + ), + }; + }); + +const toFrontendPermission = ( + payload: PermissionRequestPayload, + status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending", +) => ({ + requestId: payload.request_id, + sessionId: payload.session_id, + permission: payload.permission, + patterns: payload.patterns, + metadata: payload.metadata, + always: payload.always, + tool: payload.tool, + createdAt: payload.created_at, + status, +}); + +const toPermissionStatus = (reply: PermissionReply) => { + if (reply === "always") return "approved_always"; + if (reply === "once") return "approved_once"; + return "rejected"; +}; + export const buildChatRouter = ( sessionBridge: ChatSessionBridge, runtime: OpencodeRuntimeAdapter, @@ -636,6 +687,131 @@ export const buildChatRouter = ( } }); + chatRouter.post("/permission/:requestId/reply", async (req, res) => { + const requestId = req.params.requestId?.trim(); + const parsed = permissionReplyPayloadSchema.safeParse(req.body); + if (!requestId) { + res.status(400).json({ message: "request_id is required" }); + return; + } + if (!parsed.success) { + res.status(400).json({ + message: "invalid request payload", + detail: parsed.error.flatten(), + }); + return; + } + + try { + const projectId = req.header("x-project-id") ?? undefined; + const userId = req.header("x-user-id") ?? undefined; + const actorKey = toActorKey(userId); + const projectKey = toProjectKey(projectId); + const sessionRecord = await sessionMetadataStore.get( + { actorKey, projectId, projectKey, userId }, + parsed.data.session_id, + ); + if (!sessionRecord) { + res.status(404).json({ message: "session not found" }); + return; + } + + const run = activeRuns.get(sessionRecord.sessionId); + if (!run || run.status !== "running") { + res.status(409).json({ message: "session is not waiting for permissions" }); + return; + } + + const pendingPermission = run.pendingPermissions.get(requestId); + if (!pendingPermission) { + res.status(404).json({ message: "permission request not found" }); + return; + } + const persistPermissionState = async () => { + const currentState = await sessionUiStateStore.read( + toSessionUiStateContext(sessionRecord), + ); + await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), { + sessionId: sessionRecord.sessionId, + isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, + messages: run.messages, + branchGroups: currentState?.branchGroups ?? [], + }); + }; + + try { + await runtime.replyPermission({ + requestId, + sessionId: sessionRecord.sessionId, + reply: parsed.data.reply, + message: parsed.data.message, + }); + } catch (error) { + run.messages = updateLastAssistantPermission( + run.messages, + requestId, + (permission) => ({ + ...permission, + status: "error", + error: + error instanceof Error + ? error.message + : "failed to reply permission", + }), + ); + await persistPermissionState().catch((persistError) => { + logger.warn( + { err: persistError, sessionId: sessionRecord.sessionId }, + "failed to persist permission error state", + ); + }); + res.status(502).json({ + message: "permission reply failed", + detail: error instanceof Error ? error.message : String(error), + }); + return; + } + + run.pendingPermissions.delete(requestId); + const status = toPermissionStatus(parsed.data.reply); + run.messages = updateLastAssistantPermission( + run.messages, + requestId, + (permission) => ({ + ...permission, + status, + repliedAt: Date.now(), + }), + ); + await persistPermissionState().catch((persistError) => { + logger.warn( + { err: persistError, sessionId: sessionRecord.sessionId }, + "failed to persist permission reply state", + ); + }); + for (const subscriber of run.subscribers) { + subscriber.write("permission_response", { + session_id: sessionRecord.sessionId, + request_id: requestId, + reply: parsed.data.reply, + }); + } + + res.status(202).json({ + session_id: sessionRecord.sessionId, + request_id: requestId, + reply: parsed.data.reply, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + logger.error({ err: error }, "permission reply route failed"); + res.status(500).json({ + message: "permission reply route failed", + detail, + }); + } + }); + chatRouter.post("/fork", async (req, res) => { const parsed = forkPayloadSchema.safeParse(req.body); if (!parsed.success) { @@ -818,6 +994,7 @@ export const buildChatRouter = ( clientSessionId, controller: abortController, messages: initialMessages, + pendingPermissions: new Map(), status: "running", subscribers: new Set(), }; @@ -903,6 +1080,35 @@ export const buildChatRouter = ( isError: true, progress: completeBackendProgress(message.progress), })); + } else if (event === "permission_request") { + const payload = data as PermissionRequestPayload; + activeRun.pendingPermissions.set(payload.request_id, payload); + activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ + ...message, + permissions: [ + ...(Array.isArray(message.permissions) ? message.permissions : []), + toFrontendPermission(payload), + ], + })); + } else if (event === "permission_response") { + const requestId = + typeof data.request_id === "string" ? data.request_id : undefined; + const reply = + data.reply === "once" || data.reply === "always" || data.reply === "reject" + ? data.reply + : undefined; + if (requestId && reply) { + activeRun.pendingPermissions.delete(requestId); + activeRun.messages = updateLastAssistantPermission( + activeRun.messages, + requestId, + (permission) => ({ + ...permission, + status: toPermissionStatus(reply), + repliedAt: Date.now(), + }), + ); + } } for (const subscriber of activeRun.subscribers) { diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 362b9ad..f59d124 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -2,7 +2,7 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { logger } from "../logger.js"; -import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; +import { type PermissionReply, type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; export const supportedModels = [ "deepseek/deepseek-v4-flash", @@ -33,6 +33,20 @@ type ProgressPayload = { detail?: string; }; +export type PermissionRequestPayload = { + session_id: string; + request_id: string; + permission: string; + patterns: string[]; + metadata: Record; + always: string[]; + tool?: { + messageID: string; + callID: string; + }; + created_at: number; +}; + const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const toolLabels: Record = { @@ -158,6 +172,42 @@ const isSessionEvent = (event: OpencodeEvent, sessionId: string) => "sessionID" in event.properties && event.properties.sessionID === sessionId; +const isPermissionAskedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "permission.asked"; + +const isPermissionV2AskedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "permission.v2.asked"; + +const isPermissionRepliedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "permission.replied"; + +const isPermissionV2RepliedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "permission.v2.replied"; + +const buildPermissionDetail = (event: Extract) => { + const patterns = event.properties.patterns.length + ? event.properties.patterns.join(", ") + : event.properties.permission; + return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`; +}; + +const buildPermissionV2Detail = ( + event: Extract, +) => { + const resources = event.properties.resources.length + ? event.properties.resources.join(", ") + : event.properties.action; + return `需要用户确认权限:${event.properties.action};资源:${resources}`; +}; + export const collectTextContent = (parts: Part[]) => parts .filter((part): part is Extract => part.type === "text") @@ -529,6 +579,126 @@ export const streamPromptResponse = async ({ }); } + if (isPermissionAskedEvent(event)) { + sawResponseActivity = true; + logDevelopmentDebug("permission request received", { + ...debugContext, + requestId: event.properties.id, + permission: event.properties.permission, + patterns: event.properties.patterns, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `permission-${event.properties.id}`, + phase: "permission", + status: "running", + title: "等待权限确认", + detail: buildPermissionDetail(event), + }); + write("permission_request", { + session_id: clientSessionId, + request_id: event.properties.id, + permission: event.properties.permission, + patterns: event.properties.patterns, + metadata: event.properties.metadata, + always: event.properties.always, + tool: event.properties.tool, + created_at: Date.now(), + } satisfies PermissionRequestPayload); + continue; + } + + if (isPermissionV2AskedEvent(event)) { + sawResponseActivity = true; + logDevelopmentDebug("permission v2 request received", { + ...debugContext, + requestId: event.properties.id, + action: event.properties.action, + resources: event.properties.resources, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `permission-${event.properties.id}`, + phase: "permission", + status: "running", + title: "等待权限确认", + detail: buildPermissionV2Detail(event), + }); + write("permission_request", { + session_id: clientSessionId, + request_id: event.properties.id, + permission: event.properties.action, + patterns: event.properties.resources, + metadata: event.properties.metadata ?? {}, + always: event.properties.save ?? [], + tool: undefined, + created_at: Date.now(), + } satisfies PermissionRequestPayload); + continue; + } + + if (isPermissionRepliedEvent(event)) { + sawResponseActivity = true; + logDevelopmentDebug("permission request replied", { + ...debugContext, + requestId: event.properties.requestID, + reply: event.properties.reply, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `permission-${event.properties.requestID}`, + phase: "permission", + status: event.properties.reply === "reject" ? "error" : "completed", + title: + event.properties.reply === "reject" + ? "权限请求已拒绝" + : "权限请求已允许", + detail: + event.properties.reply === "always" + ? "已允许本次请求,并记住同类权限。" + : event.properties.reply === "once" + ? "已允许本次请求。" + : "已拒绝本次请求。", + }); + write("permission_response", { + session_id: clientSessionId, + request_id: event.properties.requestID, + reply: event.properties.reply satisfies PermissionReply, + }); + continue; + } + + if (isPermissionV2RepliedEvent(event)) { + sawResponseActivity = true; + logDevelopmentDebug("permission v2 request replied", { + ...debugContext, + requestId: event.properties.requestID, + reply: event.properties.reply, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `permission-${event.properties.requestID}`, + phase: "permission", + status: event.properties.reply === "reject" ? "error" : "completed", + title: + event.properties.reply === "reject" + ? "权限请求已拒绝" + : "权限请求已允许", + detail: + event.properties.reply === "always" + ? "已允许本次请求,并记住同类权限。" + : event.properties.reply === "once" + ? "已允许本次请求。" + : "已拒绝本次请求。", + }); + write("permission_response", { + session_id: clientSessionId, + request_id: event.properties.requestID, + reply: event.properties.reply satisfies PermissionReply, + }); + continue; + } + if (isSkillEvent(event)) { sawResponseActivity = true; const { name, reason, payload } = extractSkillAuditInfo(event); diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index c6851f9..3b689f1 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -3,6 +3,8 @@ import { createOpencodeClient, type OpencodeClient, } from "@opencode-ai/sdk/v2"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; @@ -29,6 +31,8 @@ type RuntimeModelOverride = { modelID: string; }; +export type PermissionReply = "once" | "always" | "reject"; + export class OpencodeRuntimeAdapter { private clientPromise: Promise | null = null; private closeServer: (() => void) | null = null; @@ -129,6 +133,34 @@ export class OpencodeRuntimeAdapter { return response.stream; } + async replyPermission(options: { + requestId: string; + sessionId?: string; + reply: PermissionReply; + message?: string; + }) { + const client = await this.ensureClient(); + if ("permission" in client && client.permission?.reply) { + const response = await client.permission.reply({ + requestID: options.requestId, + reply: options.reply, + message: options.message, + }); + return response.data; + } + + if ("permission" in client && client.permission?.respond && options.sessionId) { + const response = await client.permission.respond({ + sessionID: options.sessionId, + permissionID: options.requestId, + response: options.reply, + }); + return response.data; + } + + throw new Error("opencode permission reply API is unavailable"); + } + async dispose(): Promise { this.closeServer?.(); this.closeServer = null; @@ -174,9 +206,7 @@ export class OpencodeRuntimeAdapter { hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, timeout: config.OPENCODE_TIMEOUT_MS, - config: { - model: config.OPENCODE_MODEL, - }, + config: buildOpencodeConfig(), }); } catch (error) { if (isMissingOpencodeCli(error)) { @@ -207,6 +237,59 @@ export class OpencodeRuntimeAdapter { export const opencodeRuntime = new OpencodeRuntimeAdapter(); +function buildOpencodeConfig(): Record { + return deepMerge( + deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), + { + model: config.OPENCODE_MODEL, + }, + ); +} + +function readProjectOpencodeConfig(): Record { + const path = resolve(process.cwd(), "opencode.json"); + if (!existsSync(path)) { + return {}; + } + return parseConfigJson(readFileSync(path, "utf8"), path); +} + +function readEnvOpencodeConfig(): Record { + const content = process.env.OPENCODE_CONFIG_CONTENT; + if (!content?.trim()) { + return {}; + } + return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT"); +} + +function parseConfigJson(content: string, source: string): Record { + const parsed = JSON.parse(content) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${source} must contain a JSON object`); + } + return parsed as Record; +} + +function deepMerge( + left: Record, + right: Record, +): Record { + const next = { ...left }; + for (const [key, value] of Object.entries(right)) { + const existing = next[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + next[key] = deepMerge(existing, value); + } else { + next[key] = value; + } + } + return next; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException { return ( typeof error === "object" && diff --git a/tests/routes/chatStream.test.ts b/tests/routes/chatStream.test.ts new file mode 100644 index 0000000..7a242f4 --- /dev/null +++ b/tests/routes/chatStream.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; + +import { + streamPromptResponse, + type PermissionRequestPayload, +} from "../../src/routes/chatStream.js"; +import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; + +const createEventStream = (events: unknown[]) => ({ + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event; + } + }, +}); + +describe("streamPromptResponse", () => { + it("forwards opencode permission requests as SSE payloads", async () => { + const runtime = { + subscribeEvents: async () => + createEventStream([ + { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "runtime-session-1", + permission: "bash", + patterns: ["rm *"], + metadata: { command: "rm tmp.txt" }, + always: ["rm *"], + }, + }, + { + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, + }, + ]), + prompt: async () => undefined, + messages: async () => [], + } as unknown as OpencodeRuntimeAdapter; + const events: Array<{ event: string; data: Record }> = []; + + await streamPromptResponse({ + runtime, + sessionId: "runtime-session-1", + clientSessionId: "client-session-1", + message: "delete temp", + write: (event, data) => events.push({ event, data }), + }); + + const permissionEvent = events.find((item) => item.event === "permission_request"); + expect(permissionEvent?.data).toMatchObject({ + session_id: "client-session-1", + request_id: "perm-1", + permission: "bash", + patterns: ["rm *"], + metadata: { command: "rm tmp.txt" }, + always: ["rm *"], + } satisfies Partial); + }); + + it("forwards opencode v2 permission requests as SSE payloads", async () => { + const runtime = { + subscribeEvents: async () => + createEventStream([ + { + type: "permission.v2.asked", + properties: { + id: "perm-v2-1", + sessionID: "runtime-session-1", + action: "external_directory", + resources: ["/tmp"], + save: ["/tmp"], + metadata: { path: "/tmp" }, + }, + }, + { + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, + }, + ]), + prompt: async () => undefined, + messages: async () => [], + } as unknown as OpencodeRuntimeAdapter; + const events: Array<{ event: string; data: Record }> = []; + + await streamPromptResponse({ + runtime, + sessionId: "runtime-session-1", + clientSessionId: "client-session-1", + message: "read /tmp", + write: (event, data) => events.push({ event, data }), + }); + + const permissionEvent = events.find((item) => item.event === "permission_request"); + expect(permissionEvent?.data).toMatchObject({ + session_id: "client-session-1", + request_id: "perm-v2-1", + permission: "external_directory", + patterns: ["/tmp"], + metadata: { path: "/tmp" }, + always: ["/tmp"], + } satisfies Partial); + }); +});