diff --git a/.opencode/tools/render_junctions.ts b/.opencode/tools/render_junctions.ts index 49043f2..b5a5d9e 100644 --- a/.opencode/tools/render_junctions.ts +++ b/.opencode/tools/render_junctions.ts @@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: - "在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record, area_ids?: string[], area_colors?: Record },其中 node_area_map 的 key 是 junction/node id,value 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。", + "在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIR,location.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。", args: { reason: tool.schema .string() diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts index e9fab74..5e395b4 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -3,10 +3,12 @@ import { tool } from "@opencode-ai/plugin"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; +const importDirectory = + process.env.RESULT_REF_IMPORT_DIR ?? "./data/result-imports"; export default tool({ description: - "将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_ref(res-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。", + `导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受该目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`, args: { reason: tool.schema .string() @@ -16,7 +18,7 @@ export default tool({ file_path: tool.schema .string() .describe( - "本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。", + `位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 data;data 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`, ), }, async execute(args, context) { diff --git a/Dockerfile b/Dockerfile index 6fb2791..5bffb1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM smanx/opencode:1.18.4@sha256:719821683648f5c28431ed28a068cb96f92c15374148783a1e0d34d5e2f16ed3 AS base +FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base USER root ARG UBUNTU_APT_MIRROR= ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple diff --git a/README.md b/README.md index ee57613..2377220 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000 `opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。 +前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具,其他请求仍需确认;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。 + +单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env`、`data/`、`logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则。 + +`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出导入目录;单文件默认上限为 64 MiB,成功导入后源包装文件会被删除。 + ## 配置与安全 不要提交 `.env`、`.local.env`、`data/`、`logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。 diff --git a/contracts/agent-v1.openapi.json b/contracts/agent-v1.openapi.json index f4d1ffa..04b46ba 100644 --- a/contracts/agent-v1.openapi.json +++ b/contracts/agent-v1.openapi.json @@ -1011,8 +1011,10 @@ "type": "string", "enum": [ "request", + "auto", "always" - ] + ], + "description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode." } }, "required": [ diff --git a/contracts/manifest.json b/contracts/manifest.json index 10bde5e..f89affa 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "agent": { "file": "agent-v1.openapi.json", - "sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a" + "sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039" } } } diff --git a/opencode.json b/opencode.json index d512492..bea795c 100644 --- a/opencode.json +++ b/opencode.json @@ -13,10 +13,36 @@ "port": 4096 }, "permission": { - "*": "allow", - "external_directory": "ask", - "bash": { + "*": "ask", + "external_directory": "deny", + "read": { "*": "allow", + ".env": "deny", + ".env.*": "deny", + "*.env": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + "**/*.env": "deny", + "data/**": "deny", + "**/data/**": "deny", + "logs/**": "deny", + "**/logs/**": "deny" + }, + "edit": { + "*": "ask", + ".env": "deny", + ".env.*": "deny", + "*.env": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + "**/*.env": "deny", + "data/**": "deny", + "**/data/**": "deny", + "logs/**": "deny", + "**/logs/**": "deny" + }, + "bash": { + "*": "ask", "rm *": "ask", "rmdir *": "ask", "mv *": "ask", @@ -24,9 +50,19 @@ "chown *": "ask", "sudo *": "ask", "curl *": "ask", - "wget *": "ask" + "wget *": "ask", + "*.env*": "deny", + "*data/*": "deny", + "* data": "deny", + "*/data": "deny", + "*logs/*": "deny", + "* logs": "deny", + "*/logs": "deny" }, - "edit": "ask" + "question": "allow", + "todo": "allow", + "todoread": "allow", + "todowrite": "allow" }, "experimental": { "continue_loop_on_deny": true diff --git a/src/config.ts b/src/config.ts index 182538c..097864a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -103,6 +103,14 @@ const envSchema = z LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), // result_ref 持久化存储目录。 RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"), + // 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。 + RESULT_REF_IMPORT_DIR: z.string().default("./data/result-imports"), + // 单个渲染包装 JSON 的最大导入字节数。 + RESULT_REF_IMPORT_MAX_BYTES: z.coerce + .number() + .int() + .positive() + .default(64 * 1024 * 1024), // result_ref 保留时长(小时)。 RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168), // 定时清理过期 result_ref 的扫描周期(毫秒)。 diff --git a/src/contracts/openapi.ts b/src/contracts/openapi.ts index f0b2aad..67587c4 100644 --- a/src/contracts/openapi.ts +++ b/src/contracts/openapi.ts @@ -214,7 +214,12 @@ register("/api/v1/agent/sessions/{session_id}/runs", "post", { schema: z.object({ message: z.string().min(1).max(10000), model: z.string().optional(), - approval_mode: z.enum(["request", "always"]).optional(), + approval_mode: z + .enum(["request", "auto", "always"]) + .optional() + .describe( + "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.", + ), }), }, }, diff --git a/src/results/resolver.ts b/src/results/resolver.ts index 7b6995a..3bfa94b 100644 --- a/src/results/resolver.ts +++ b/src/results/resolver.ts @@ -1,4 +1,7 @@ -import { readJsonFile } from "../utils/fileStore.js"; +import { realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative } from "node:path"; + +import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js"; import { type ResultReferenceKind, type ResultReferenceRecord, @@ -33,7 +36,11 @@ export type RenderJunctionPayload = { }; export class ResultReferenceResolver { - constructor(private readonly store: ResultReferenceStore) {} + constructor( + private readonly store: ResultReferenceStore, + private readonly importRoot: string, + private readonly importMaxBytes: number, + ) {} // Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。 async register(input: RegisterResultReferenceInput) { @@ -63,7 +70,17 @@ export class ResultReferenceResolver { filePath: string, input: Omit, ) { - const raw = await readJsonFile(filePath); + const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot); + const fileStat = await stat(resolvedFilePath); + if (!fileStat.isFile()) { + throw new Error("render payload path must point to a regular file"); + } + if (fileStat.size > this.importMaxBytes) { + throw new Error( + `render payload file exceeds RESULT_REF_IMPORT_MAX_BYTES (${this.importMaxBytes})`, + ); + } + const raw = await readJsonFile(resolvedFilePath); if (raw === null) { throw new Error(`render payload file not found: ${filePath}`); } @@ -78,13 +95,15 @@ export class ResultReferenceResolver { throw new Error("render payload file does not contain a valid junction render payload"); } - return this.register({ + const record = await this.register({ ...input, data: payload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, schemaVersion: 1, source: RESULT_REFERENCE_SOURCE.agentGenerated, }); + await removeFileIfExists(resolvedFilePath); + return record; } async getFullAuthorized( @@ -167,6 +186,25 @@ export const extractRenderJunctionPayload = ( }; }; +const resolvePathInsideRoot = async (filePath: string, rootPath: string) => { + if (!isAbsolute(filePath)) { + throw new Error("render payload file_path must be absolute"); + } + const [resolvedFilePath, resolvedRootPath] = await Promise.all([ + realpath(filePath), + realpath(rootPath), + ]); + const relativePath = relative(resolvedRootPath, resolvedFilePath); + if ( + relativePath === ".." || + relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || + isAbsolute(relativePath) + ) { + throw new Error("render payload file must be inside RESULT_REF_IMPORT_DIR"); + } + return resolvedFilePath; +}; + const normalizeDataForKind = ( kind: ResultReferenceKind, data: unknown, diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 8b03a78..a63156a 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -65,7 +65,13 @@ const payloadSchema = z.object({ model: z.string().refine(isSupportedModel, { message: "unsupported model", }).optional(), - approval_mode: z.enum(["request", "always"]).optional().default("request"), + approval_mode: z + .enum(["request", "auto", "always"]) + .optional() + .default("request") + .describe( + "request forwards approval prompts; auto approves only low-risk allowlisted tools; always approves every prompt not explicitly denied by OpenCode", + ), }); const createSessionPayloadSchema = z.object({ diff --git a/src/routes/chatPermissionPolicy.ts b/src/routes/chatPermissionPolicy.ts new file mode 100644 index 0000000..e4b0a94 --- /dev/null +++ b/src/routes/chatPermissionPolicy.ts @@ -0,0 +1,57 @@ +export type ApprovalMode = "request" | "auto" | "always"; + +const lowRiskToolPermissions = new Set([ + "apply_layer_style", + "geocode", + "locate_features", + "render_junctions", + "show_chart", + "tjwater_server_query", + "view_history", + "view_scada", + "web_search", + "zoom_to_map", +]); + +const normalizePermission = (permission: string) => permission.trim().toLowerCase(); + +export const canAutoApprovePermission = (permission: string): boolean => { + const normalized = normalizePermission(permission); + if (lowRiskToolPermissions.has(normalized)) { + return true; + } + + if (normalized.startsWith("tjwater_")) { + return lowRiskToolPermissions.has(normalized.slice("tjwater_".length)); + } + + return false; +}; + +export const resolvePermissionApproval = ( + approvalMode: ApprovalMode, + permission: string, +) => { + if (approvalMode === "always") { + return { + autoApprove: true, + title: "已按始终允许模式放行", + detail: + "当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。", + } as const; + } + + if (approvalMode === "auto" && canAutoApprovePermission(permission)) { + return { + autoApprove: true, + title: "已自动批准低风险权限", + detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。", + } as const; + } + + return { + autoApprove: false, + title: "等待权限确认", + detail: undefined, + } as const; +}; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 8bb75f4..f06830a 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -46,6 +46,10 @@ import { type TodoItemPayload, type TodoUpdatePayload, } from "./chatStreamEvents.js"; +import { + resolvePermissionApproval, + type ApprovalMode, +} from "./chatPermissionPolicy.js"; export { collectTextContent, @@ -55,7 +59,7 @@ export { type TodoUpdatePayload, } from "./chatStreamEvents.js"; -export type ApprovalMode = "request" | "always"; +export type { ApprovalMode } from "./chatPermissionPolicy.js"; type StreamPromptOptions = { runtime: OpencodeRuntimeAdapter; @@ -372,6 +376,10 @@ export const streamPromptResponse = async ({ if (isPermissionAskedEvent(event)) { sawResponseActivity = true; + const permissionApproval = resolvePermissionApproval( + approvalMode, + event.properties.permission, + ); logDevelopmentDebug("permission request received", { ...debugContext, requestId: event.properties.id, @@ -382,23 +390,20 @@ export const streamPromptResponse = async ({ emitProgress({ id: `permission-${event.properties.id}`, phase: "permission", - status: approvalMode === "always" ? "completed" : "running", - title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", - detail: - approvalMode === "always" - ? "当前批准模式为始终允许,已自动允许本次权限请求。" - : buildPermissionDetail(event), + status: permissionApproval.autoApprove ? "completed" : "running", + title: permissionApproval.title, + detail: permissionApproval.detail ?? buildPermissionDetail(event), }); - if (approvalMode === "always") { + if (permissionApproval.autoApprove) { await runtime.replyPermission({ requestId: event.properties.id, sessionId, - reply: "always", + reply: "once", }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.id, - reply: "always" satisfies PermissionReply, + reply: "once" satisfies PermissionReply, }); continue; } @@ -417,6 +422,10 @@ export const streamPromptResponse = async ({ if (isPermissionV2AskedEvent(event)) { sawResponseActivity = true; + const permissionApproval = resolvePermissionApproval( + approvalMode, + event.properties.action, + ); logDevelopmentDebug("permission v2 request received", { ...debugContext, requestId: event.properties.id, @@ -427,23 +436,20 @@ export const streamPromptResponse = async ({ emitProgress({ id: `permission-${event.properties.id}`, phase: "permission", - status: approvalMode === "always" ? "completed" : "running", - title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", - detail: - approvalMode === "always" - ? "当前批准模式为始终允许,已自动允许本次权限请求。" - : buildPermissionV2Detail(event), + status: permissionApproval.autoApprove ? "completed" : "running", + title: permissionApproval.title, + detail: permissionApproval.detail ?? buildPermissionV2Detail(event), }); - if (approvalMode === "always") { + if (permissionApproval.autoApprove) { await runtime.replyPermission({ requestId: event.properties.id, sessionId, - reply: "always", + reply: "once", }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.id, - reply: "always" satisfies PermissionReply, + reply: "once" satisfies PermissionReply, }); continue; } diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index bbd7885..2427531 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -393,6 +393,7 @@ export class OpencodeRuntimeAdapter { config.AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR; logger.info( { diff --git a/src/server.ts b/src/server.ts index d79980a..ac29b8e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,6 +37,7 @@ import { markRuntimeSessionAuthExpired, type RuntimeSessionContext, } from "./runtime/sessionContext.js"; +import { ensureDirectory } from "./utils/fileStore.js"; import { SkillStore } from "./skills/store.js"; const app = express(); @@ -55,7 +56,11 @@ const learningOrchestrator = new LearningOrchestrator( skillStore, ); const resultReferenceStore = new ResultReferenceStore(); -const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); +const resultReferenceResolver = new ResultReferenceResolver( + resultReferenceStore, + config.RESULT_REF_IMPORT_DIR, + config.RESULT_REF_IMPORT_MAX_BYTES, +); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const credentialRefreshCoordinator = new CredentialRefreshCoordinator(); @@ -660,6 +665,7 @@ const bootstrap = async () => { learningOrchestrator.initialize(), memoryStore.initialize(), resultReferenceStore.initialize(), + ensureDirectory(config.RESULT_REF_IMPORT_DIR), sessionTranscriptStore.initialize(), ]); }; diff --git a/tests/contracts/openapi.test.ts b/tests/contracts/openapi.test.ts index bc031e5..a9de4f8 100644 --- a/tests/contracts/openapi.test.ts +++ b/tests/contracts/openapi.test.ts @@ -61,6 +61,34 @@ describe("Agent REST OpenAPI", () => { expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined(); }); + test("separates automatic approval from persistent permission grants", () => { + const document = generateAgentOpenApi(); + const runRequest = document.paths["/api/v1/agent/sessions/{session_id}/runs"] + ?.post?.requestBody; + const permissionRequest = document.paths[ + "/api/v1/agent/sessions/{session_id}/permission-responses" + ]?.post?.requestBody; + + expect( + runRequest && !("$ref" in runRequest) + ? runRequest.content["application/json"]?.schema + : undefined, + ).toMatchObject({ + properties: { + approval_mode: { enum: ["request", "auto", "always"] }, + }, + }); + expect( + permissionRequest && !("$ref" in permissionRequest) + ? permissionRequest.content["application/json"]?.schema + : undefined, + ).toMatchObject({ + properties: { + reply: { enum: ["once", "always", "reject"] }, + }, + }); + }); + test("matches the public session runtime response shapes", () => { const document = generateAgentOpenApi(); const schemas = document.components?.schemas ?? {}; diff --git a/tests/results/store.test.ts b/tests/results/store.test.ts index 4c5f0ab..380b476 100644 --- a/tests/results/store.test.ts +++ b/tests/results/store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,7 +18,7 @@ describe("ResultReferenceResolver", () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-")); store = new ResultReferenceStore(tempDir, 60_000); - resolver = new ResultReferenceResolver(store); + resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024); await store.initialize(); }); @@ -193,6 +193,53 @@ describe("ResultReferenceResolver", () => { "DMA-2": "#00ff00", }, }); + await expect(stat(filePath)).rejects.toThrow(); + }); + + it("rejects render payload files outside the configured import directory", async () => { + const outsideDir = await mkdtemp(join(tmpdir(), "tjwater-result-outside-")); + const filePath = join(outsideDir, "render-wrapper.json"); + await writeFile( + filePath, + JSON.stringify({ + metadata: {}, + location: { file_path: filePath }, + data: { node_area_map: { J1: "DMA-1" } }, + }), + "utf8", + ); + + try { + await expect( + resolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-4", + clientSessionId: "client-4", + projectKey: "project-key-4", + sessionId: "session-4", + source: RESULT_REFERENCE_SOURCE.agentGenerated, + traceId: "trace-4", + }), + ).rejects.toThrow("RESULT_REF_IMPORT_DIR"); + } finally { + await rm(outsideDir, { force: true, recursive: true }); + } + }); + + it("rejects oversized render payload files before parsing", async () => { + const filePath = join(tempDir, "oversized.json"); + await writeFile(filePath, "x".repeat(128), "utf8"); + const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 64); + + await expect( + sizeLimitedResolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-5", + clientSessionId: "client-5", + projectKey: "project-key-5", + sessionId: "session-5", + source: RESULT_REFERENCE_SOURCE.agentGenerated, + traceId: "trace-5", + }), + ).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES"); }); }); diff --git a/tests/routes/chatInteractionRoutes.test.ts b/tests/routes/chatInteractionRoutes.test.ts new file mode 100644 index 0000000..956e542 --- /dev/null +++ b/tests/routes/chatInteractionRoutes.test.ts @@ -0,0 +1,153 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test"; +import express, { Router } from "express"; +import type { Server } from "node:http"; + +import { CredentialRefreshCoordinator } from "../../src/auth/credentialRefresh.js"; +import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js"; +import type { ActiveRun } from "../../src/routes/chatUiState.js"; + +describe("chat interaction routes", () => { + let baseUrl = ""; + let server: Server; + const replyQuestion = mock(async () => ({ ok: true })); + const replyPermission = mock(async () => ({ ok: true })); + + beforeAll(async () => { + const activeRuns = new Map(); + activeRuns.set("runtime-session", { + clientSessionId: "client-session", + controller: new AbortController(), + messages: [ + { + id: "assistant-1", + role: "assistant", + permissions: [ + { + requestId: "permission-1", + sessionId: "runtime-session", + permission: "bash", + patterns: ["npm test"], + always: ["npm test"], + createdAt: 1, + status: "pending", + }, + ], + questions: [{ requestId: "question-1", status: "pending" }], + }, + ], + pendingPermissions: new Map([ + [ + "permission-1", + { + session_id: "runtime-session", + request_id: "permission-1", + permission: "bash", + patterns: ["npm test"], + always: ["npm test"], + created_at: 1, + }, + ], + ]), + pendingQuestions: new Map([ + [ + "question-1", + { + created_at: 1, + request_id: "question-1", + session_id: "runtime-session", + questions: [], + }, + ], + ]), + status: "running", + subscribers: new Set(), + }); + + const router = Router(); + router.use((req, _res, next) => { + req.agentAuth = { + accessToken: "access-token", + userId: "user-1", + keycloakSub: "keycloak-1", + username: "tester", + role: "user", + isSuperuser: false, + projectId: "project-1", + network: "network-1", + projectRole: "member", + }; + next(); + }); + registerChatInteractionRoutes(router, { + activeRuns, + credentialRefreshCoordinator: new CredentialRefreshCoordinator(), + runtime: { replyPermission, replyQuestion } as never, + sessionMetadataStore: { + get: async () => ({ sessionId: "runtime-session" }), + } as never, + sessionUiStateStore: { + read: async () => null, + write: async () => undefined, + } as never, + }); + + const app = express(); + app.use(express.json()); + app.use(router); + server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not expose a TCP port"); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(() => { + server.close(); + }); + + it("submits answers to the stable OpenCode question adapter", async () => { + const response = await fetch( + `${baseUrl}/sessions/client-session/question-responses`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request_id: "question-1", + action: "reply", + answers: [["继续"]], + }), + }, + ); + + expect(response.status).toBe(202); + expect(replyQuestion).toHaveBeenCalledWith({ + requestId: "question-1", + sessionId: "runtime-session", + answers: [["继续"]], + }); + }); + + it("forwards saved permission grants to OpenCode", async () => { + const response = await fetch( + `${baseUrl}/sessions/client-session/permission-responses`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request_id: "permission-1", + reply: "always", + }), + }, + ); + + expect(response.status).toBe(202); + expect(replyPermission).toHaveBeenCalledWith({ + requestId: "permission-1", + sessionId: "runtime-session", + reply: "always", + message: undefined, + }); + }); +}); diff --git a/tests/routes/chatPermissionPolicy.test.ts b/tests/routes/chatPermissionPolicy.test.ts new file mode 100644 index 0000000..cda7581 --- /dev/null +++ b/tests/routes/chatPermissionPolicy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; + +import { + canAutoApprovePermission, + resolvePermissionApproval, +} from "../../src/routes/chatPermissionPolicy.js"; + +describe("permission approval policy", () => { + it.each([ + "show_chart", + "web_search", + "tjwater_server_query", + "tjwater_tjwater_server_query", + ])("allows low-risk permission %s", (permission) => { + expect(canAutoApprovePermission(permission)).toBe(true); + }); + + it.each(["bash", "edit", "external_directory", "store_render_ref"])( + "requires confirmation for permission %s", + (permission) => { + expect(canAutoApprovePermission(permission)).toBe(false); + }, + ); + + it("resolves request, auto, and always modes", () => { + expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false); + expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true); + expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false); + expect(resolvePermissionApproval("always", "bash")).toMatchObject({ + autoApprove: true, + title: "已按始终允许模式放行", + }); + }); +}); diff --git a/tests/routes/chatStream.test.ts b/tests/routes/chatStream.test.ts index 6a39e84..fa02c73 100644 --- a/tests/routes/chatStream.test.ts +++ b/tests/routes/chatStream.test.ts @@ -61,7 +61,7 @@ describe("streamPromptResponse", () => { } satisfies Partial); }); - it("auto replies always when approval mode is always", async () => { + it("auto approves an allowlisted low-risk permission once", async () => { const replies: Array> = []; const runtime = { subscribeEvents: async () => @@ -71,10 +71,10 @@ describe("streamPromptResponse", () => { properties: { id: "perm-1", sessionID: "runtime-session-1", - permission: "bash", - patterns: ["npm test"], - metadata: { command: "npm test" }, - always: ["npm test"], + permission: "tjwater_tjwater_server_query", + patterns: ["*"], + metadata: {}, + always: ["*"], }, }, { @@ -97,7 +97,7 @@ describe("streamPromptResponse", () => { sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "run tests", - approvalMode: "always", + approvalMode: "auto", write: (event, data) => events.push({ event, data }), }); @@ -105,17 +105,100 @@ describe("streamPromptResponse", () => { { requestId: "perm-1", sessionId: "runtime-session-1", - reply: "always", + reply: "once", }, ]); expect(events.some((item) => item.event === "permission_request")).toBe(false); expect(events.find((item) => item.event === "permission_response")?.data).toEqual({ session_id: "client-session-1", request_id: "perm-1", - reply: "always", + reply: "once", }); }); + it("keeps high-risk permissions interactive in auto mode", async () => { + const replies: Array> = []; + const runtime = { + subscribeEvents: async () => + createEventStream([ + { + type: "permission.asked", + properties: { + id: "perm-auto-bash", + sessionID: "runtime-session-1", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm test"], + }, + }, + { type: "session.idle", properties: { sessionID: "runtime-session-1" } }, + ]), + prompt: async () => undefined, + messages: async () => [], + replyPermission: async (options: Record) => replies.push(options), + } as unknown as OpencodeRuntimeAdapter; + const events: Array<{ event: string; data: Record }> = []; + + await streamPromptResponse({ + runtime, + sessionId: "runtime-session-1", + clientSessionId: "client-session-1", + message: "run tests", + approvalMode: "auto", + write: (event, data) => events.push({ event, data }), + }); + + expect(replies).toEqual([]); + expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({ + request_id: "perm-auto-bash", + permission: "bash", + }); + }); + + it("approves every OpenCode ask once in always mode", async () => { + const replies: Array> = []; + const runtime = { + subscribeEvents: async () => + createEventStream([ + { + type: "permission.asked", + properties: { + id: "perm-always-bash", + sessionID: "runtime-session-1", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm test"], + }, + }, + { type: "session.idle", properties: { sessionID: "runtime-session-1" } }, + ]), + prompt: async () => undefined, + messages: async () => [], + replyPermission: async (options: Record) => replies.push(options), + } as unknown as OpencodeRuntimeAdapter; + const events: Array<{ event: string; data: Record }> = []; + + await streamPromptResponse({ + runtime, + sessionId: "runtime-session-1", + clientSessionId: "client-session-1", + message: "run tests", + approvalMode: "always", + write: (event, data) => events.push({ event, data }), + }); + + expect(replies).toEqual([ + { + requestId: "perm-always-bash", + sessionId: "runtime-session-1", + reply: "once", + }, + ]); + expect(events.some((item) => item.event === "permission_request")).toBe(false); + }); + it("forwards opencode v2 permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index 7fb4548..08a8a0a 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -129,4 +129,31 @@ describe("OpencodeRuntimeAdapter.warmup", () => { "session.delete:warmup-session", ]); }); + + it("submits question answers through the stable question API", async () => { + const calls: unknown[] = []; + const client = { + question: { + reply: async (input: unknown) => { + calls.push(input); + return { data: { ok: true } }; + }, + }, + } as unknown as OpencodeClient; + const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { + clientPromise: null, + closeServer: null, + ensureClient: async () => client, + }) as OpencodeRuntimeAdapter; + + await runtime.replyQuestion({ + requestId: "question-1", + sessionId: "session-1", + answers: [["继续"]], + }); + + expect(calls).toEqual([ + { requestID: "question-1", answers: [["继续"]] }, + ]); + }); }); diff --git a/tests/runtime/opencodeConfig.test.ts b/tests/runtime/opencodeConfig.test.ts new file mode 100644 index 0000000..5e59155 --- /dev/null +++ b/tests/runtime/opencodeConfig.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { readFile } from "node:fs/promises"; + +describe("internal OpenCode permissions", () => { + it("keeps protected paths denied in every approval mode", async () => { + const config = JSON.parse(await readFile("opencode.json", "utf8")) as { + permission?: Record>; + }; + const permission = config.permission ?? {}; + const bash = permission.bash as Record | undefined; + const edit = permission.edit as Record | undefined; + const read = permission.read as Record | undefined; + + expect(permission["*"]).toBe("ask"); + expect(permission.external_directory).toBe("deny"); + expect(permission.question).toBe("allow"); + expect(permission.todowrite).toBe("allow"); + expect(read?.["*"]).toBe("allow"); + expect(read?.["data/**"]).toBe("deny"); + expect(read?.["**/logs/**"]).toBe("deny"); + expect(edit?.["*"]).toBe("ask"); + expect(edit?.["data/**"]).toBe("deny"); + expect(edit?.["**/logs/**"]).toBe("deny"); + expect(bash?.["*"]).toBe("ask"); + expect(bash?.["*.env*"]).toBe("deny"); + expect(bash?.["*data/*"]).toBe("deny"); + expect(bash?.["*logs/*"]).toBe("deny"); + }); +});