From ce04704af218fbdf1421e482af080f19e0591fe9 Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 25 Aug 2026 13:18:39 +0800 Subject: [PATCH] fix(agent): isolate conversation workspaces --- .opencode/tools/store_render_ref.ts | 29 ++++- README.md | 4 +- opencode.json | 14 +- src/chat/sessionBridge.ts | 12 +- src/config.ts | 6 +- src/results/resolver.ts | 52 +++++++- src/routes/chat.ts | 9 +- src/routes/chatPermissionPolicy.ts | 148 ++++++++++++++++++++++ src/routes/chatStream.ts | 32 +++-- src/runtime/opencode.ts | 47 ++++++- src/runtime/sessionContext.ts | 1 + src/server.ts | 8 ++ tests/results/store.test.ts | 76 ++++++++++- tests/routes/chatPermissionPolicy.test.ts | 38 ++++++ tests/routes/chatStream.test.ts | 48 +++++++ tests/runtime/opencode.test.ts | 76 +++++++++++ tests/runtime/opencodeConfig.test.ts | 65 +++++++++- tests/runtime/sessionContext.test.ts | 4 + 18 files changed, 621 insertions(+), 48 deletions(-) diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts index 5e395b4..794bc72 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -3,12 +3,25 @@ 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"; + +type StoreRenderRefArgs = { + file_path?: unknown; + filePath?: unknown; +}; + +export function resolveStoreRenderFilePath(args: StoreRenderRefArgs): string { + if (typeof args.file_path === "string" && args.file_path.trim() !== "") { + return args.file_path; + } + if (typeof args.filePath === "string" && args.filePath.trim() !== "") { + return args.filePath; + } + throw new Error("file_path is required"); +} export default tool({ description: - `导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受该目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`, + "导入当前对话工作目录下的受控 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() @@ -17,11 +30,17 @@ export default tool({ ), file_path: tool.schema .string() + .optional() .describe( - `位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 data;data 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`, + "位于当前对话工作目录内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 data;data 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。", ), + filePath: tool.schema + .string() + .optional() + .describe("兼容旧调用的参数名;新调用应优先使用 file_path。"), }, async execute(args, context) { + const filePath = resolveStoreRenderFilePath(args); const response = await fetch( `${internalBaseUrl}/internal/tools/store-render-ref`, { @@ -32,7 +51,7 @@ export default tool({ }, body: JSON.stringify({ session_id: context.sessionID, - file_path: args.file_path, + file_path: filePath, }), }, ); diff --git a/README.md b/README.md index bdb8438..22e3453 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,9 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000 前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill,以及真实路径位于工作区安全子树且不涉及 `.env`、`data/`、`logs/` 的 glob/grep;工作区根目录的宽泛搜索仍需确认。“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。 -单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env`、`data/`、`logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则。 +单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env` 的直接访问以及通过结构化读写工具访问普通 `data/`、`logs/` 路径仍保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录,结果导入只能读取当前对话目录。普通 `rm <文件>` 仍可审批执行,常见的 `rm -rf`/`rm -fr` 递归强制删除形式会被静态拒绝。 -`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出导入目录;单文件默认上限为 128 MiB,成功导入后源包装文件会被删除。 +`store_render_ref` 只会从当前对话绑定的工作目录导入包装格式 JSON;工作区根目录固定为项目内的 `./data/conversation-workspaces`,以确保 OpenCode 能继续发现项目配置和工具。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出当前对话目录;单文件默认上限为 128 MiB,成功导入后只删除这一份源包装文件。升级前已经存在的会话没有独立工作目录,需要新建对话后才能使用该导入能力。 CLI 桥接层对 stdout 设置独立的 128 MiB 硬上限(`MAX_CLI_OUTPUT_BYTES`);stderr 最多保留 256 KiB(`MAX_CLI_STDERR_BYTES`),超出后截断但不会终止 CLI。`MAX_INLINE_RESULT_BYTES`(默认 12000 字节)只控制 OpenCode 的内联阈值,较大结果由 OpenCode 写入标准 `tool-output` 目录。Agent 启动时及后续定期清理其中超过 `RESULT_REF_TTL_HOURS`(默认 7 天)的 `tool_*` 文件。 diff --git a/opencode.json b/opencode.json index a1cd451..80e4996 100644 --- a/opencode.json +++ b/opencode.json @@ -44,6 +44,12 @@ "bash": { "*": "ask", "rm *": "ask", + "rm -rf *": "deny", + "rm -fr *": "deny", + "rm -r -f *": "deny", + "rm -f -r *": "deny", + "rm --recursive --force *": "deny", + "rm --force --recursive *": "deny", "rmdir *": "ask", "mv *": "ask", "chmod *": "ask", @@ -51,13 +57,7 @@ "sudo *": "ask", "curl *": "ask", "wget *": "ask", - "*.env*": "deny", - "*data/*": "deny", - "* data": "deny", - "*/data": "deny", - "*logs/*": "deny", - "* logs": "deny", - "*/logs": "deny" + "*.env*": "deny" }, "question": "allow", "task": "deny", diff --git a/src/chat/sessionBridge.ts b/src/chat/sessionBridge.ts index dfdbe2a..dd2c7ba 100644 --- a/src/chat/sessionBridge.ts +++ b/src/chat/sessionBridge.ts @@ -12,6 +12,7 @@ export type SessionBinding = { clientSessionId: string; sessionId: string; startedAt: number; + workspaceDirectory?: string; }; export type SessionContext = { @@ -52,20 +53,26 @@ export class ChatSessionBridge { await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId); let sessionId = existingSessionId; + let runtimeSession; let created = false; if (!sessionId) { - const session = await this.runtime.createSession(); - sessionId = session.id; + runtimeSession = await this.runtime.createSession(undefined, { + conversationWorkspace: true, + }); + sessionId = runtimeSession.id; requestContext = { ...requestContext, clientSessionId: sessionId, }; created = true; + } else { + runtimeSession = await this.runtime.getSession(sessionId); } const binding: SessionBinding = { clientSessionId: requestContext.clientSessionId, sessionId, startedAt: Date.now(), + workspaceDirectory: runtimeSession.directory, }; setRuntimeSessionContext({ accessToken: requestContext.accessToken, @@ -79,6 +86,7 @@ export class ChatSessionBridge { sessionId, tokenExpiresAt: requestContext.tokenExpiresAt, traceId: requestContext.traceId, + workspaceDirectory: runtimeSession.directory, }); return { binding, requestContext, created }; diff --git a/src/config.ts b/src/config.ts index 9ae5999..80fc715 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,8 @@ import { parseAgentModelOptions, } from "./chat/modelConfig.js"; +export const RESULT_REF_IMPORT_DIRECTORY = "./data/conversation-workspaces"; + // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 dotenv.config({ path: ".local.env", override: false }); @@ -116,7 +118,9 @@ const envSchema = z // 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"), + RESULT_REF_IMPORT_DIR: z + .literal(RESULT_REF_IMPORT_DIRECTORY) + .default(RESULT_REF_IMPORT_DIRECTORY), // 单个渲染包装 JSON 的最大导入字节数。 RESULT_REF_IMPORT_MAX_BYTES: z.coerce .number() diff --git a/src/results/resolver.ts b/src/results/resolver.ts index 3bfa94b..9fff4a1 100644 --- a/src/results/resolver.ts +++ b/src/results/resolver.ts @@ -1,4 +1,4 @@ -import { realpath, stat } from "node:fs/promises"; +import { lstat, realpath, stat } from "node:fs/promises"; import { isAbsolute, relative } from "node:path"; import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js"; @@ -68,9 +68,19 @@ export class ResultReferenceResolver { async registerRenderPayloadFile( filePath: string, - input: Omit, + input: Omit & { + workspaceDirectory: string; + }, ) { - const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot); + const resolvedWorkspaceDirectory = await resolveConversationWorkspace( + input.workspaceDirectory, + this.importRoot, + ); + const resolvedFilePath = await resolvePathInsideRoot( + filePath, + resolvedWorkspaceDirectory, + "render payload file must be inside the current conversation workspace", + ); const fileStat = await stat(resolvedFilePath); if (!fileStat.isFile()) { throw new Error("render payload path must point to a regular file"); @@ -95,8 +105,9 @@ export class ResultReferenceResolver { throw new Error("render payload file does not contain a valid junction render payload"); } + const { workspaceDirectory: _workspaceDirectory, ...registrationInput } = input; const record = await this.register({ - ...input, + ...registrationInput, data: payload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, schemaVersion: 1, @@ -186,7 +197,36 @@ export const extractRenderJunctionPayload = ( }; }; -const resolvePathInsideRoot = async (filePath: string, rootPath: string) => { +const resolveConversationWorkspace = async ( + workspaceDirectory: string, + importRoot: string, +) => { + const workspaceLinkStat = await lstat(workspaceDirectory); + if (workspaceLinkStat.isSymbolicLink()) { + throw new Error("conversation workspace must not be a symbolic link"); + } + const resolvedImportRoot = await realpath(importRoot); + const resolvedWorkspaceDirectory = await resolvePathInsideRoot( + workspaceDirectory, + resolvedImportRoot, + "conversation workspace must be inside RESULT_REF_IMPORT_DIR", + ); + const relativeWorkspace = relative(resolvedImportRoot, resolvedWorkspaceDirectory); + if (!relativeWorkspace || relativeWorkspace.includes("/") || relativeWorkspace.includes("\\")) { + throw new Error("conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR"); + } + const workspaceStat = await stat(resolvedWorkspaceDirectory); + if (!workspaceStat.isDirectory()) { + throw new Error("conversation workspace must point to a directory"); + } + return resolvedWorkspaceDirectory; +}; + +const resolvePathInsideRoot = async ( + filePath: string, + rootPath: string, + outsideMessage = "render payload file must be inside RESULT_REF_IMPORT_DIR", +) => { if (!isAbsolute(filePath)) { throw new Error("render payload file_path must be absolute"); } @@ -200,7 +240,7 @@ const resolvePathInsideRoot = async (filePath: string, rootPath: string) => { relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath) ) { - throw new Error("render payload file must be inside RESULT_REF_IMPORT_DIR"); + throw new Error(outsideMessage); } return resolvedFilePath; }; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index a63156a..7cdc993 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -155,7 +155,9 @@ export const buildChatRouter = ( const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const requestedSessionId = parsed.data.session_id?.trim(); - const sessionId = requestedSessionId || (await runtime.createSession()).id; + const sessionId = + requestedSessionId || + (await runtime.createSession(undefined, { conversationWorkspace: true })).id; const { record, created } = await sessionMetadataStore.ensure({ actorKey, @@ -451,7 +453,9 @@ export const buildChatRouter = ( res.status(404).json({ message: "source session not found" }); return; } - const forkSession = await runtime.createSession(); + const forkSession = await runtime.createSession(undefined, { + conversationWorkspace: true, + }); const { record: targetSessionRecord } = await sessionMetadataStore.ensure({ actorKey, parentSessionId: sourceSessionId, @@ -874,6 +878,7 @@ export const buildChatRouter = ( traceId: requestContext.traceId, projectId: requestContext.projectId, signal: abortController.signal, + workspaceRoot: binding.workspaceDirectory, write: (event, data) => { publish(event, data); }, diff --git a/src/routes/chatPermissionPolicy.ts b/src/routes/chatPermissionPolicy.ts index b87d8f7..7ea172a 100644 --- a/src/routes/chatPermissionPolicy.ts +++ b/src/routes/chatPermissionPolicy.ts @@ -62,9 +62,32 @@ export const resolvePermissionApproval = ( permission: string, context: PermissionApprovalContext = {}, ) => { + if (isDirectRecursiveForceRemove(permission, context)) { + return { + autoApprove: false, + autoReject: true, + title: "已拒绝递归强制删除", + detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。", + } as const; + } + + if ( + approvalMode === "always" && + normalizePermission(permission) === "bash" && + containsPotentialRemoveCommand(context) + ) { + return { + autoApprove: false, + autoReject: false, + title: "等待删除命令确认", + detail: "删除命令不会由始终允许模式代为批准,请确认本次具体操作。", + } as const; + } + if (approvalMode === "always") { return { autoApprove: true, + autoReject: false, title: "已按始终允许模式放行", detail: "当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。", @@ -74,6 +97,7 @@ export const resolvePermissionApproval = ( if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) { return { autoApprove: true, + autoReject: false, title: "已自动批准低风险权限", detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。", } as const; @@ -81,11 +105,135 @@ export const resolvePermissionApproval = ( return { autoApprove: false, + autoReject: false, title: "等待权限确认", detail: undefined, } as const; }; +const isDirectRecursiveForceRemove = ( + permission: string, + context: PermissionApprovalContext, +): boolean => { + if (normalizePermission(permission) !== "bash") { + return false; + } + const command = + typeof context.metadata?.command === "string" + ? context.metadata.command + : context.patterns?.join("\n"); + if (!command) { + return false; + } + + return splitShellCommandSegments(command).some((segment) => { + const words = tokenizeShellSegment(segment); + let commandIndex = 0; + while (commandIndex < words.length) { + const word = words[commandIndex]!; + const executable = word.split("/").at(-1)?.toLowerCase(); + if (word === "!" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { + commandIndex += 1; + continue; + } + if (executable === "command") { + commandIndex += 1; + while (words[commandIndex]?.startsWith("-") && words[commandIndex] !== "--") { + commandIndex += 1; + } + if (words[commandIndex] === "--") commandIndex += 1; + continue; + } + if (executable === "env") { + commandIndex += 1; + while (commandIndex < words.length) { + const envWord = words[commandIndex]!; + if (envWord === "--") { + commandIndex += 1; + break; + } + if (envWord === "-u" || envWord === "--unset") { + commandIndex += 2; + continue; + } + if ( + envWord.startsWith("-") || + /^[A-Za-z_][A-Za-z0-9_]*=/.test(envWord) + ) { + commandIndex += 1; + continue; + } + break; + } + continue; + } + if (executable === "sudo" || executable === "doas") { + commandIndex += 1; + while (words[commandIndex]?.startsWith("-")) { + commandIndex += 1; + } + continue; + } + if (executable === "busybox" && words[commandIndex + 1] === "rm") { + commandIndex += 1; + } + break; + } + + const executable = words[commandIndex]?.split("/").at(-1)?.toLowerCase(); + if (executable !== "rm") { + return false; + } + + let recursive = false; + let force = false; + for (const word of words.slice(commandIndex + 1)) { + if (word === "--") { + break; + } + if (word === "--recursive") { + recursive = true; + } else if (word === "--force") { + force = true; + } else if (/^-[^-]/.test(word)) { + recursive ||= /[rR]/.test(word.slice(1)); + force ||= word.slice(1).includes("f"); + } + } + return recursive && force; + }); +}; + +const containsPotentialRemoveCommand = ( + context: PermissionApprovalContext, +): boolean => { + const command = + typeof context.metadata?.command === "string" + ? context.metadata.command + : context.patterns?.join("\n"); + if (!command) { + return false; + } + const normalized = command + .replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu, "") + .replace(/["'\\]/gu, ""); + return /(^|[^A-Za-z0-9_])(?:[^\s/]+\/)*rm(?=$|[^A-Za-z0-9_])/iu.test(normalized); +}; + +const splitShellCommandSegments = (command: string): string[] => + command.split(/&&|\|\||[;|()\n]/u); + +const tokenizeShellSegment = (segment: string): string[] => + (segment.match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/gu) ?? []).map( + (word) => { + const quoted = word.match(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u); + return (quoted ? (quoted[1] ?? quoted[2] ?? "") : word).replace( + /(["'])|\\(.)/gu, + "$2", + ); + }, + ); + const isSafeWorkspaceSearch = ( permission: "glob" | "grep", context: PermissionApprovalContext, diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index d1de2af..a5dc3dd 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -71,6 +71,7 @@ type StreamPromptOptions = { traceId?: string; projectId?: string; signal?: AbortSignal; + workspaceRoot?: string; write: (event: string, data: Record) => void; }; @@ -157,6 +158,7 @@ export const streamPromptResponse = async ({ traceId, projectId, signal, + workspaceRoot, write, }: StreamPromptOptions): Promise<{ aborted: boolean; @@ -397,7 +399,7 @@ export const streamPromptResponse = async ({ { metadata: event.properties.metadata, patterns: event.properties.patterns, - workspaceRoot: process.cwd(), + workspaceRoot: workspaceRoot ?? process.cwd(), }, ); logDevelopmentDebug("permission request received", { @@ -410,20 +412,25 @@ export const streamPromptResponse = async ({ emitProgress({ id: `permission-${event.properties.id}`, phase: "permission", - status: permissionApproval.autoApprove ? "completed" : "running", + status: permissionApproval.autoReject + ? "error" + : permissionApproval.autoApprove + ? "completed" + : "running", title: permissionApproval.title, detail: permissionApproval.detail ?? buildPermissionDetail(event), }); - if (permissionApproval.autoApprove) { + if (permissionApproval.autoApprove || permissionApproval.autoReject) { + const reply = permissionApproval.autoReject ? "reject" : "once"; await runtime.replyPermission({ requestId: event.properties.id, sessionId, - reply: "once", + reply, }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.id, - reply: "once" satisfies PermissionReply, + reply: reply satisfies PermissionReply, }); continue; } @@ -448,7 +455,7 @@ export const streamPromptResponse = async ({ { metadata: event.properties.metadata, patterns: event.properties.resources, - workspaceRoot: process.cwd(), + workspaceRoot: workspaceRoot ?? process.cwd(), }, ); logDevelopmentDebug("permission v2 request received", { @@ -461,20 +468,25 @@ export const streamPromptResponse = async ({ emitProgress({ id: `permission-${event.properties.id}`, phase: "permission", - status: permissionApproval.autoApprove ? "completed" : "running", + status: permissionApproval.autoReject + ? "error" + : permissionApproval.autoApprove + ? "completed" + : "running", title: permissionApproval.title, detail: permissionApproval.detail ?? buildPermissionV2Detail(event), }); - if (permissionApproval.autoApprove) { + if (permissionApproval.autoApprove || permissionApproval.autoReject) { + const reply = permissionApproval.autoReject ? "reject" : "once"; await runtime.replyPermission({ requestId: event.properties.id, sessionId, - reply: "once", + reply, }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.id, - reply: "once" satisfies PermissionReply, + reply: reply satisfies PermissionReply, }); continue; } diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index 100f487..440a329 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -2,11 +2,14 @@ import { createOpencode, type OpencodeClient, } from "@opencode-ai/sdk/v2"; +import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; +import { chmod, mkdir, rmdir } from "node:fs/promises"; import { resolve } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; +import { ensureDirectory } from "../utils/fileStore.js"; import { cleanupExpiredToolOutputs, resolveOpencodeToolOutputDirectory, @@ -126,12 +129,46 @@ export class OpencodeRuntimeAdapter { } } - async createSession(title?: string) { + async createSession( + title?: string, + options: { + conversationWorkspace?: boolean; + workspaceRoot?: string; + } = {}, + ) { const client = await this.ensureClient(); - const response = await client.session.create({ - title, - }); - return requireData(response.data, "session.create"); + if (!options.conversationWorkspace) { + const response = await client.session.create({ title }); + return requireData(response.data, "session.create"); + } + + const workspaceRoot = resolve( + options.workspaceRoot ?? config.RESULT_REF_IMPORT_DIR, + ); + const directory = resolve(workspaceRoot, `conversation-${randomUUID()}`); + await ensureDirectory(workspaceRoot); + await chmod(workspaceRoot, 0o700); + await mkdir(directory, { mode: 0o700 }); + try { + const response = await client.session.create({ + directory, + title, + permission: [ + { permission: "read", pattern: `${directory}/**`, action: "allow" }, + { permission: "edit", pattern: `${directory}/**`, action: "ask" }, + ], + }); + return requireData(response.data, "session.create"); + } catch (error) { + await rmdir(directory).catch(() => undefined); + throw error; + } + } + + async getSession(sessionId: string) { + const client = await this.ensureClient(); + const response = await client.session.get({ sessionID: sessionId }); + return requireData(response.data, "session.get"); } async sendPrompt(sessionId: string, text: string) { diff --git a/src/runtime/sessionContext.ts b/src/runtime/sessionContext.ts index b3b1d30..44154c9 100644 --- a/src/runtime/sessionContext.ts +++ b/src/runtime/sessionContext.ts @@ -15,6 +15,7 @@ export type RuntimeSessionContext = { sessionId: string; tokenExpiresAt?: string; traceId: string; + workspaceDirectory?: string; }; const contexts = new Map(); diff --git a/src/server.ts b/src/server.ts index 03d0cfe..d3a463c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -342,6 +342,13 @@ app.post("/internal/tools/store-render-ref", async (req, res) => { res.status(400).json({ message: "file_path is required" }); return; } + if (!context.workspaceDirectory) { + res.status(400).json({ + message: "conversation workspace is required", + detail: "create a new conversation before importing render data", + }); + return; + } try { const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, { @@ -352,6 +359,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => { sessionId: context.clientSessionId, source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: context.traceId, + workspaceDirectory: context.workspaceDirectory, }); res.json({ ok: true, diff --git a/tests/results/store.test.ts b/tests/results/store.test.ts index 380b476..5dc631a 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, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,13 +12,18 @@ import { describe("ResultReferenceResolver", () => { let tempDir: string; + let importRoot: string; + let conversationWorkspace: string; let store: ResultReferenceStore; let resolver: ResultReferenceResolver; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-")); - store = new ResultReferenceStore(tempDir, 60_000); - resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024); + importRoot = join(tempDir, "conversation-workspaces"); + conversationWorkspace = join(importRoot, "conversation-1"); + await mkdir(conversationWorkspace, { recursive: true }); + store = new ResultReferenceStore(join(tempDir, "refs"), 60_000); + resolver = new ResultReferenceResolver(store, importRoot, 1024 * 1024); await store.initialize(); }); @@ -127,7 +132,7 @@ describe("ResultReferenceResolver", () => { }); it("registers render refs from local wrapper files and normalizes payloads", async () => { - const filePath = join(tempDir, "render-wrapper.json"); + const filePath = join(conversationWorkspace, "render-wrapper.json"); await writeFile( filePath, JSON.stringify( @@ -166,6 +171,7 @@ describe("ResultReferenceResolver", () => { sessionId: "session-3", source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: "trace-3", + workspaceDirectory: conversationWorkspace, }); expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); @@ -218,6 +224,7 @@ describe("ResultReferenceResolver", () => { sessionId: "session-4", source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: "trace-4", + workspaceDirectory: outsideDir, }), ).rejects.toThrow("RESULT_REF_IMPORT_DIR"); } finally { @@ -226,9 +233,9 @@ describe("ResultReferenceResolver", () => { }); it("rejects oversized render payload files before parsing", async () => { - const filePath = join(tempDir, "oversized.json"); + const filePath = join(conversationWorkspace, "oversized.json"); await writeFile(filePath, "x".repeat(128), "utf8"); - const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 64); + const sizeLimitedResolver = new ResultReferenceResolver(store, importRoot, 64); await expect( sizeLimitedResolver.registerRenderPayloadFile(filePath, { @@ -238,8 +245,65 @@ describe("ResultReferenceResolver", () => { sessionId: "session-5", source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: "trace-5", + workspaceDirectory: conversationWorkspace, }), ).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES"); }); + it("rejects render payload files owned by another conversation workspace", async () => { + const otherWorkspace = join(importRoot, "conversation-2"); + await mkdir(otherWorkspace); + const filePath = join(otherWorkspace, "render-wrapper.json"); + await writeFile( + filePath, + JSON.stringify({ + metadata: {}, + location: { file_path: filePath }, + data: { node_area_map: { J1: "DMA-1" } }, + }), + "utf8", + ); + + await expect( + resolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-6", + clientSessionId: "client-6", + projectKey: "project-key-6", + sessionId: "session-6", + source: RESULT_REFERENCE_SOURCE.agentGenerated, + traceId: "trace-6", + workspaceDirectory: conversationWorkspace, + }), + ).rejects.toThrow("current conversation workspace"); + }); + + it("rejects a conversation workspace that is itself a symbolic link", async () => { + const targetWorkspace = join(importRoot, "conversation-target"); + const linkedWorkspace = join(importRoot, "conversation-linked"); + await mkdir(targetWorkspace); + await symlink(targetWorkspace, linkedWorkspace, "dir"); + const filePath = join(targetWorkspace, "render-wrapper.json"); + await writeFile( + filePath, + JSON.stringify({ + metadata: {}, + location: { file_path: filePath }, + data: { node_area_map: { J1: "DMA-1" } }, + }), + "utf8", + ); + + await expect( + resolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-7", + clientSessionId: "client-7", + projectKey: "project-key-7", + sessionId: "session-7", + source: RESULT_REFERENCE_SOURCE.agentGenerated, + traceId: "trace-7", + workspaceDirectory: linkedWorkspace, + }), + ).rejects.toThrow("symbolic link"); + }); + }); diff --git a/tests/routes/chatPermissionPolicy.test.ts b/tests/routes/chatPermissionPolicy.test.ts index 277c6b2..2622619 100644 --- a/tests/routes/chatPermissionPolicy.test.ts +++ b/tests/routes/chatPermissionPolicy.test.ts @@ -129,4 +129,42 @@ describe("permission approval policy", () => { title: "已按始终允许模式放行", }); }); + + it.each([ + "rm -rf ./target", + "rm -rf ./target", + "rm -Rf ./target", + "/bin/rm -rf ./target", + "command rm --force --recursive ./target", + "env LANG=C rm -r -f ./target", + "SAFE=1 rm -rf ./target", + "env -u HOME rm -rf ./target", + "r\"\"m -rf ./target", + "(rm -rf ./target)", + "! rm -rf ./target", + "npm test && rm --recursive --force ./target", + ])("rejects direct recursive force removal in always mode: %s", (command) => { + expect( + resolvePermissionApproval("always", "bash", { + metadata: { command }, + patterns: [command], + }), + ).toMatchObject({ + autoApprove: false, + autoReject: true, + title: "已拒绝递归强制删除", + }); + }); + + it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])( + "keeps non-recursive or non-executed removal text available for confirmation: %s", + (command) => { + expect( + resolvePermissionApproval("always", "bash", { + metadata: { command }, + patterns: [command], + }), + ).toMatchObject({ autoApprove: false, autoReject: false }); + }, + ); }); diff --git a/tests/routes/chatStream.test.ts b/tests/routes/chatStream.test.ts index 0466da5..11e15f5 100644 --- a/tests/routes/chatStream.test.ts +++ b/tests/routes/chatStream.test.ts @@ -435,6 +435,54 @@ describe("streamPromptResponse", () => { expect(events.some((item) => item.event === "permission_request")).toBe(false); }); + it("rejects recursive force removal even in always mode", async () => { + const replies: Array> = []; + const runtime = { + subscribeEvents: async () => + createEventStream([ + { + type: "permission.asked", + properties: { + id: "perm-always-rm-rf", + sessionID: "runtime-session-1", + permission: "bash", + patterns: ["/bin/rm -rf ./target"], + metadata: { command: "/bin/rm -rf ./target" }, + always: ["/bin/rm -rf ./target"], + }, + }, + { 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: "delete recursively", + approvalMode: "always", + write: (event, data) => events.push({ event, data }), + }); + + expect(replies).toEqual([ + { + requestId: "perm-always-rm-rf", + sessionId: "runtime-session-1", + reply: "reject", + }, + ]); + 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-always-rm-rf", + reply: "reject", + }); + }); + 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 08a8a0a..4fc82bb 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; import { type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; import { config } from "../../src/config.js"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; @@ -87,6 +90,79 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { }); }); +describe("OpencodeRuntimeAdapter.createSession", () => { + it("creates a real chat session inside a dedicated conversation workspace", async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-")); + const calls: Array> = []; + const client = { + session: { + create: async (input: Record) => { + calls.push(input); + return { + data: { + id: "runtime-session-1", + directory: input.directory, + }, + }; + }, + }, + } as unknown as OpencodeClient; + const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { + clientPromise: null, + closeServer: null, + ensureClient: async () => client, + }) as OpencodeRuntimeAdapter; + + try { + const session = await runtime.createSession("chat", { + conversationWorkspace: true, + workspaceRoot, + }); + const directory = String(calls[0]?.directory); + + expect(relative(workspaceRoot, directory).startsWith("..")).toBe(false); + const workspaceStat = await stat(directory); + expect(workspaceStat.isDirectory()).toBe(true); + expect(workspaceStat.mode & 0o777).toBe(0o700); + expect(session.directory).toBe(directory); + expect(calls[0]?.permission).toEqual([ + { permission: "read", pattern: `${directory}/**`, action: "allow" }, + { permission: "edit", pattern: `${directory}/**`, action: "ask" }, + ]); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } + }); + + it("removes an empty conversation workspace when session creation fails", async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-")); + const client = { + session: { + create: async () => { + throw new Error("session creation failed"); + }, + }, + } as unknown as OpencodeClient; + const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { + clientPromise: null, + closeServer: null, + ensureClient: async () => client, + }) as OpencodeRuntimeAdapter; + + try { + await expect( + runtime.createSession("chat", { + conversationWorkspace: true, + workspaceRoot, + }), + ).rejects.toThrow("session creation failed"); + expect(await readdir(workspaceRoot)).toEqual([]); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } + }); +}); + describe("OpencodeRuntimeAdapter.warmup", () => { it("initializes the project session and model tools before reporting ready", async () => { const calls: string[] = []; diff --git a/tests/runtime/opencodeConfig.test.ts b/tests/runtime/opencodeConfig.test.ts index ce8caa3..4ab9f0a 100644 --- a/tests/runtime/opencodeConfig.test.ts +++ b/tests/runtime/opencodeConfig.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; import { readFile } from "node:fs/promises"; +import storeRenderRef, { + resolveStoreRenderFilePath, +} from "../../.opencode/tools/store_render_ref.js"; describe("internal OpenCode permissions", () => { it("keeps protected paths denied in every approval mode", async () => { @@ -23,8 +26,66 @@ describe("internal OpenCode permissions", () => { expect(edit?.["data/**"]).toBe("deny"); expect(edit?.["**/logs/**"]).toBe("deny"); expect(bash?.["*"]).toBe("ask"); + expect(bash?.["rm *"]).toBe("ask"); + expect(bash?.["rm -rf *"]).toBe("deny"); + expect(bash?.["rm -fr *"]).toBe("deny"); + expect(bash?.["rm -r -f *"]).toBe("deny"); + expect(bash?.["rm -f -r *"]).toBe("deny"); + expect(bash?.["rm --recursive --force *"]).toBe("deny"); + expect(bash?.["rm --force --recursive *"]).toBe("deny"); expect(bash?.["*.env*"]).toBe("deny"); - expect(bash?.["*data/*"]).toBe("deny"); - expect(bash?.["*logs/*"]).toBe("deny"); + expect(bash?.["*data/*"]).toBeUndefined(); + expect(bash?.["*logs/*"]).toBeUndefined(); + }); +}); + +describe("store_render_ref arguments", () => { + it("accepts the observed camelCase alias without changing snake_case precedence", () => { + expect( + resolveStoreRenderFilePath({ + filePath: "/app/data/conversation-workspaces/chat-1/partition.json", + }), + ).toBe("/app/data/conversation-workspaces/chat-1/partition.json"); + + expect( + resolveStoreRenderFilePath({ + file_path: "/app/data/conversation-workspaces/chat-1/preferred.json", + filePath: "/app/data/conversation-workspaces/chat-1/compatibility.json", + }), + ).toBe("/app/data/conversation-workspaces/chat-1/preferred.json"); + }); + + it("forwards a camelCase compatibility argument as file_path", async () => { + const originalFetch = globalThis.fetch; + let requestBody: unknown; + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + requestBody = JSON.parse(String(init?.body)); + return new Response('{"render_ref":"res-test"}'); + }) as unknown as typeof fetch; + + try { + const definition = storeRenderRef as unknown as { + args: Record; + execute: (args: unknown, context: unknown) => Promise; + }; + expect(definition.args.filePath).toBeDefined(); + await definition.execute( + { + reason: "regression test", + filePath: "/app/data/conversation-workspaces/chat-1/partition.json", + }, + { sessionID: "session-test" } as never, + ); + } finally { + globalThis.fetch = originalFetch; + } + + expect(requestBody).toEqual({ + session_id: "session-test", + file_path: "/app/data/conversation-workspaces/chat-1/partition.json", + }); }); }); diff --git a/tests/runtime/sessionContext.test.ts b/tests/runtime/sessionContext.test.ts index 69b7ccb..949900c 100644 --- a/tests/runtime/sessionContext.test.ts +++ b/tests/runtime/sessionContext.test.ts @@ -19,6 +19,7 @@ describe("runtime session context", () => { projectKey: "project-1", sessionId: "runtime-session-1", traceId: "trace-1", + workspaceDirectory: "/app/data/conversation-workspaces/chat-session-1", }); const runtimeContext = getRuntimeSessionContext("runtime-session-1"); @@ -27,6 +28,9 @@ describe("runtime session context", () => { expect(runtimeContext?.clientSessionId).toBe("chat-session-1"); expect(runtimeContext?.network).toBe("fengyang"); expect(runtimeContext?.sessionId).toBe("runtime-session-1"); + expect(runtimeContext?.workspaceDirectory).toBe( + "/app/data/conversation-workspaces/chat-session-1", + ); removeRuntimeSessionContext("runtime-session-1"); expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();