feat: handle opencode permission requests
This commit is contained in:
+17
-1
@@ -12,5 +12,21 @@
|
|||||||
"hostname": "127.0.0.1",
|
"hostname": "127.0.0.1",
|
||||||
"port": 4096
|
"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"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-1
@@ -9,7 +9,10 @@ import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
|||||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||||
import { RESULT_REFERENCE_KIND } from "../results/store.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 ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
} from "./chatSession.js";
|
} from "./chatSession.js";
|
||||||
import {
|
import {
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
|
type PermissionRequestPayload,
|
||||||
streamPromptResponse,
|
streamPromptResponse,
|
||||||
supportedModels,
|
supportedModels,
|
||||||
type SupportedModel,
|
type SupportedModel,
|
||||||
@@ -36,6 +40,12 @@ const abortPayloadSchema = z.object({
|
|||||||
session_id: z.string().max(128),
|
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({
|
const createSessionPayloadSchema = z.object({
|
||||||
session_id: z.string().max(128).optional(),
|
session_id: z.string().max(128).optional(),
|
||||||
parent_session_id: z.string().max(128).optional(),
|
parent_session_id: z.string().max(128).optional(),
|
||||||
@@ -64,6 +74,7 @@ type ActiveRun = {
|
|||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
|
pendingPermissions: Map<string, PermissionRequestPayload>;
|
||||||
status: RunStatus;
|
status: RunStatus;
|
||||||
subscribers: Set<StreamSubscriber>;
|
subscribers: Set<StreamSubscriber>;
|
||||||
};
|
};
|
||||||
@@ -186,6 +197,46 @@ const updateLastAssistantMessage = (
|
|||||||
return messages;
|
return messages;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateLastAssistantPermission = (
|
||||||
|
messages: unknown[],
|
||||||
|
requestId: string,
|
||||||
|
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
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 = (
|
export const buildChatRouter = (
|
||||||
sessionBridge: ChatSessionBridge,
|
sessionBridge: ChatSessionBridge,
|
||||||
runtime: OpencodeRuntimeAdapter,
|
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) => {
|
chatRouter.post("/fork", async (req, res) => {
|
||||||
const parsed = forkPayloadSchema.safeParse(req.body);
|
const parsed = forkPayloadSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -818,6 +994,7 @@ export const buildChatRouter = (
|
|||||||
clientSessionId,
|
clientSessionId,
|
||||||
controller: abortController,
|
controller: abortController,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
|
pendingPermissions: new Map(),
|
||||||
status: "running",
|
status: "running",
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
};
|
};
|
||||||
@@ -903,6 +1080,35 @@ export const buildChatRouter = (
|
|||||||
isError: true,
|
isError: true,
|
||||||
progress: completeBackendProgress(message.progress),
|
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) {
|
for (const subscriber of activeRun.subscribers) {
|
||||||
|
|||||||
+171
-1
@@ -2,7 +2,7 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
|||||||
|
|
||||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||||
import { logger } from "../logger.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 = [
|
export const supportedModels = [
|
||||||
"deepseek/deepseek-v4-flash",
|
"deepseek/deepseek-v4-flash",
|
||||||
@@ -33,6 +33,20 @@ type ProgressPayload = {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PermissionRequestPayload = {
|
||||||
|
session_id: string;
|
||||||
|
request_id: string;
|
||||||
|
permission: string;
|
||||||
|
patterns: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
always: string[];
|
||||||
|
tool?: {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const toolLabels: Record<string, string> = {
|
const toolLabels: Record<string, string> = {
|
||||||
@@ -158,6 +172,42 @@ const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
|
|||||||
"sessionID" in event.properties &&
|
"sessionID" in event.properties &&
|
||||||
event.properties.sessionID === sessionId;
|
event.properties.sessionID === sessionId;
|
||||||
|
|
||||||
|
const isPermissionAskedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
|
||||||
|
event.type === "permission.asked";
|
||||||
|
|
||||||
|
const isPermissionV2AskedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
|
||||||
|
event.type === "permission.v2.asked";
|
||||||
|
|
||||||
|
const isPermissionRepliedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
|
||||||
|
event.type === "permission.replied";
|
||||||
|
|
||||||
|
const isPermissionV2RepliedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
|
||||||
|
event.type === "permission.v2.replied";
|
||||||
|
|
||||||
|
const buildPermissionDetail = (event: Extract<OpencodeEvent, { type: "permission.asked" }>) => {
|
||||||
|
const patterns = event.properties.patterns.length
|
||||||
|
? event.properties.patterns.join(", ")
|
||||||
|
: event.properties.permission;
|
||||||
|
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPermissionV2Detail = (
|
||||||
|
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
|
||||||
|
) => {
|
||||||
|
const resources = event.properties.resources.length
|
||||||
|
? event.properties.resources.join(", ")
|
||||||
|
: event.properties.action;
|
||||||
|
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const collectTextContent = (parts: Part[]) =>
|
export const collectTextContent = (parts: Part[]) =>
|
||||||
parts
|
parts
|
||||||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
.filter((part): part is Extract<Part, { type: "text" }> => 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)) {
|
if (isSkillEvent(event)) {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
const { name, reason, payload } = extractSkillAuditInfo(event);
|
||||||
|
|||||||
+86
-3
@@ -3,6 +3,8 @@ import {
|
|||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
@@ -29,6 +31,8 @@ type RuntimeModelOverride = {
|
|||||||
modelID: string;
|
modelID: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
export class OpencodeRuntimeAdapter {
|
export class OpencodeRuntimeAdapter {
|
||||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||||
private closeServer: (() => void) | null = null;
|
private closeServer: (() => void) | null = null;
|
||||||
@@ -129,6 +133,34 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return response.stream;
|
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<void> {
|
async dispose(): Promise<void> {
|
||||||
this.closeServer?.();
|
this.closeServer?.();
|
||||||
this.closeServer = null;
|
this.closeServer = null;
|
||||||
@@ -174,9 +206,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
hostname: config.OPENCODE_HOSTNAME,
|
hostname: config.OPENCODE_HOSTNAME,
|
||||||
port: config.OPENCODE_PORT,
|
port: config.OPENCODE_PORT,
|
||||||
timeout: config.OPENCODE_TIMEOUT_MS,
|
timeout: config.OPENCODE_TIMEOUT_MS,
|
||||||
config: {
|
config: buildOpencodeConfig(),
|
||||||
model: config.OPENCODE_MODEL,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingOpencodeCli(error)) {
|
if (isMissingOpencodeCli(error)) {
|
||||||
@@ -207,6 +237,59 @@ export class OpencodeRuntimeAdapter {
|
|||||||
|
|
||||||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||||||
|
|
||||||
|
function buildOpencodeConfig(): Record<string, unknown> {
|
||||||
|
return deepMerge(
|
||||||
|
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
||||||
|
{
|
||||||
|
model: config.OPENCODE_MODEL,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProjectOpencodeConfig(): Record<string, unknown> {
|
||||||
|
const path = resolve(process.cwd(), "opencode.json");
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parseConfigJson(readFileSync(path, "utf8"), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvOpencodeConfig(): Record<string, unknown> {
|
||||||
|
const content = process.env.OPENCODE_CONFIG_CONTENT;
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigJson(content: string, source: string): Record<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(
|
||||||
|
left: Record<string, unknown>,
|
||||||
|
right: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
|
|||||||
@@ -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<string, unknown> }> = [];
|
||||||
|
|
||||||
|
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<PermissionRequestPayload>);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, unknown> }> = [];
|
||||||
|
|
||||||
|
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<PermissionRequestPayload>);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user