diff --git a/docs/features/direct-model-agent/logic-design.md b/docs/features/direct-model-agent/logic-design.md index 4e0f8caf..13cc2487 100644 --- a/docs/features/direct-model-agent/logic-design.md +++ b/docs/features/direct-model-agent/logic-design.md @@ -78,15 +78,14 @@ validating | 输入 | 结果 | | --- | --- | | 未提供 | 当前工作区根目录 | -| `.` 或合法相对子目录 | 解析到工作区内目录 | -| 绝对路径 | 拒绝 | -| `..` 越界、符号链接越界 | 拒绝 | +| `.` 或相对路径 | 以当前工作区为基准解析,可指向工作区外目录 | +| 绝对路径 | 使用指定目录 | +| `..` 或指向工作区外的符号链接 | 解析到实际目标目录,按当前用户权限执行 | | 不存在或不是目录 | 拒绝 | | 当前工作区身份在验证期间变化 | 拒绝本次调用 | -首版进程工具比 Execute 的 OS 权限范围更窄地固定工作目录输入,只是为了保持请求与项目 -一致;命令本身仍以当前用户权限运行,可以显式访问该账号有权访问的其他绝对路径。该规则 -不是 Runtime OS 沙箱,也不能宣称文件系统 containment。 +工作区提供默认目录和相对路径基准,不是 Execute 命令的文件系统边界。单次命令选择其他 +工作目录不会修改父请求绑定的项目或工作区,也不会影响后续未指定 `cwd` 的命令。 ## 6. Shell 选择 diff --git a/docs/features/direct-model-agent/prd.md b/docs/features/direct-model-agent/prd.md index 4aeafbfc..aa297eb5 100644 --- a/docs/features/direct-model-agent/prd.md +++ b/docs/features/direct-model-agent/prd.md @@ -65,7 +65,8 @@ Subagent 使用父请求的项目、执行空间、工作模式、直连模型 - Ask 父请求只能创建 Ask Subagent。 - Execute 父请求创建 Execute Subagent。 -- 子级不能切换项目、目录、Runtime、模型连接或工作模式。 +- 子级不能切换父请求绑定的项目、工作区、Runtime、模型连接或工作模式;Execute 命令的 + 单次工作目录选择遵循 FR-3。 - 子级不再看到 `subagent_delegate`,首版最大委派深度固定为 1。 - 子任务不是新的顶层 Task 或 Conversation,结果先返回父模型。 @@ -79,7 +80,7 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完 ### 4.1 包含 - 本机 Windows、macOS、Linux 的前台 Shell 命令执行。 -- 当前工作区默认工作目录及合法子目录选择。 +- 当前工作区默认工作目录,以及当前用户可访问的本机工作目录选择。 - Exit Code、标准输出、标准错误、Shell、目录、耗时和截断状态。 - 命令超时、用户取消、进程树停止和应用退出清理。 - 新进程使用当前本机工具环境提供的 Node、Python 和 PATH。 @@ -118,7 +119,8 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完 ### FR-3 工作目录和执行位置 - 默认目录是父请求当前工作区。 -- 可选工作目录必须解析为当前工作区本身或其子目录。 +- 可选工作目录支持绝对路径、相对工作区路径及指向工作区外目录的路径或符号链接, + 不额外限制在工作区内;目标必须是实际存在且当前用户可访问的目录。 - 本机执行只能发生在当前桌面执行空间。 - 当前请求绑定 SSH 执行空间且没有远端进程后端时不注册工具,绝不在桌面目录回退执行。 diff --git a/docs/features/direct-model-agent/progress.md b/docs/features/direct-model-agent/progress.md index 6cf7f049..8c1a97cf 100644 --- a/docs/features/direct-model-agent/progress.md +++ b/docs/features/direct-model-agent/progress.md @@ -84,6 +84,26 @@ GoodBuddy 托管 Node/Python 已可用。 - 远端直连模型进程执行不在首版,后续需要独立增加 Agent 进程协议和真机验证。 +## 2026-09-06 命令工作目录限制修复 + +- FR-3 / US-A3:移除命令工作目录的工作区范围限制。绝对路径、`..` 和指向工作区外 + 的符号链接均按本机文件系统解析,工作区只作为默认目录和相对路径基准。 +- 保留目录存在性、本机执行空间绑定、Ask 拒绝命令、取消、超时和进程树回收。 + 本次没有改变工作区文件读写工具或远端 Agent 路径。 +- `direct-model-process-service.test.ts` 覆盖绝对路径、相对路径、外部目录、符号链接、 + 不存在的目录和普通文件;当前 Windows 环境通过。 +- `model-runtime.test.ts` 新增两个生产工具链回归:模型协议响应提供绝对或相对外部 + `cwd`,经 `ModelAgentRuntime → ModelToolProvider → LocalDirectModelProcessService` + 启动真实 PowerShell,在专用工作区外测试目录复制文件并读回验证。模型响应为本机 + 测试替身,没有外部模型调用;不将此记录为真实模型或 macOS/Linux 真机验收。 +- 最终聚焦验证:`opencode-runtime`、`opencode-runtime-lifecycle`、 + `direct-model-process-service`、`model-tool-provider`、`model-runtime` 五个测试文件 + `182 passed, 1 skipped`;`npm run typecheck` 和 `npm run lint` 通过。 +- 本次共享工作区的全量 `npm test` 为 `3542 passed, 57 skipped, 1 failed`。唯一失败是 + `App.test.tsx` 的 `opens the global assistant sidebar and switches work tabs`, + 仍期望“项目工作区”标题,单独复测同样失败;该侧栏处于其他会话的修改范围,本次未改动。 + 因此本次全量验证不能记录为全部通过。 + ## 2026-09-01 至 2026-09-02 实施与验证证据 - 新增 `direct-model-process-service.ts`:PowerShell/Bash/Sh 探测、工作区目录校验、96 KiB diff --git a/docs/features/direct-model-agent/technical-design.md b/docs/features/direct-model-agent/technical-design.md index 389b5956..344b1c74 100644 --- a/docs/features/direct-model-agent/technical-design.md +++ b/docs/features/direct-model-agent/technical-design.md @@ -89,7 +89,8 @@ const processExecuteInputSchema = z.object({ }).strict() ``` -- `cwd` 只接受相对工作区路径。 +- `cwd` 接受绝对路径或相对工作区路径,省略时使用工作区根目录;按本机路径语义解析 + `..` 和符号链接,不限制目标位于工作区内。工具 schema 向模型明确这一规则。 - 首版不接受 `env`、`stdin`、`background`、`pty`、`shell` 或任意 executable 参数。 - Shell 选择由执行后端决定,防止模型绕过平台契约启动另一套受管接口。 - 命令字符串仍可以调用当前账号本来有权运行的程序;Execute 不增加命令白名单。 @@ -114,8 +115,8 @@ type ProcessExecuteResult = { } ``` -`cwd` 返回工作区相对显示值,不把不必要的绝对用户路径送入模型上下文。Shell 在模型结果 -中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。 +`cwd` 在工作区内返回相对显示值,根目录为 `.`;工作区外返回规范化绝对路径,准确说明 +命令实际执行位置。Shell 在模型结果中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。 ### 4.3 输出边界 @@ -293,7 +294,7 @@ DeepSeek Harness 的 Main 工具代理必须使用仅包含分配 MCP 和 Web - `failed`:启动或契约失败。 - `cancelled`:父请求取消。 -结果摘要包含 Shell、相对目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具 +结果摘要包含 Shell、工作目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具 输出字段,不写应用诊断日志。 ### 9.2 Subagent 事件 @@ -351,7 +352,7 @@ type SubagentActor = - 工具可见性矩阵和 Runtime target 过滤。 - Ask 伪造进程调用拒绝。 - Windows PowerShell、POSIX Bash/Sh 选择和参数。 -- cwd 默认、合法子目录、越界、符号链接越界和不存在。 +- cwd 默认、相对子目录、绝对路径、工作区外目录、指向外部的符号链接和不存在的目录。 - exit 0、非零退出、spawn 失败、超时、取消和输出截断。 - 环境筛选、本机工具 PATH 优先且模型凭据不进入进程。 - 子级模式、模型、项目、执行空间和能力继承。 diff --git a/docs/features/direct-model-agent/ui-design.md b/docs/features/direct-model-agent/ui-design.md index d6fe3467..679798c5 100644 --- a/docs/features/direct-model-agent/ui-design.md +++ b/docs/features/direct-model-agent/ui-design.md @@ -28,7 +28,7 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由 ```text 编程能力 进程执行 - 使用当前工作区和当前用户权限运行 PowerShell/Bash 命令。 + 使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。 编程 Subagent 将聚焦任务委派给同一模型连接;继承当前工作模式和工作区。 ``` @@ -54,7 +54,7 @@ PowerShell · packages/app · npm test ``` - 命令只显示单行有界摘要,完整参数进入可展开详情。 -- 目录显示工作区相对路径;工作区根显示 `.`。 +- 工作区内目录显示相对路径,工作区根显示 `.`;工作区外目录显示规范化绝对路径。 - 不在摘要中显示完整环境或凭据。 ### 3.2 状态 diff --git a/docs/features/direct-model-agent/user-stories.md b/docs/features/direct-model-agent/user-stories.md index b6fa8276..85ed8d4c 100644 --- a/docs/features/direct-model-agent/user-stories.md +++ b/docs/features/direct-model-agent/user-stories.md @@ -42,12 +42,13 @@ Shell、目录、退出码、输出和耗时。 Given 当前项目位于 macOS 或 Linux 本机,When 模型运行同一工具,Then GoodBuddy 使用 Bash,Bash 不可用时使用 Sh,工具契约保持不变且实际 Shell 明确可见。 -### US-A3 使用工作区子目录 +### US-A3 选择命令工作目录 关联:FR-3。 -Given 项目包含多个包,When 模型传入工作区内相对目录,Then 命令在该目录执行;When -路径越出工作区、不是目录或不存在,Then 命令不启动并返回准确错误。 +Given 当前请求为本机 Execute,When 模型传入绝对路径或相对工作区路径,Then 命令在 +解析后的目录执行,包括工作区外目录和符号链接目标,And 后续省略 `cwd` 时仍使用原工作区。 +When 目标不是目录或不存在,Then 命令不启动并返回准确错误。 ### US-A4 根据非零退出继续修正 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index a398135e..a42248c1 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -239,6 +239,11 @@ Execute 直接启动已签名 Runtime: 解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP 首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent 状态卡,并持久化任务说明、终态与输出。 +- 本机 OpenCode SDK 路径允许不同会话并行,同一会话仍按请求顺序执行。每个请求独立拥有 + SSE 事件订阅,并在正常完成、错误、取消或消费方结束迭代时主动关闭自己的响应流, + 不关闭其他会话的订阅或共享 Server。取消导致事件迭代结束时保留原取消原因,不改报 + “事件流意外结束”。这一清理规则属于本机 SDK 路径,不改变远端 Agent 持有 ACP Prompt + 的断线继续执行语义。 - 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。 - Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。 - 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。 diff --git a/src/main/agent/direct-model-process-service.test.ts b/src/main/agent/direct-model-process-service.test.ts index 2eb098e6..22113864 100644 --- a/src/main/agent/direct-model-process-service.test.ts +++ b/src/main/agent/direct-model-process-service.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { LocalWorkspaceAccess } from '../workspace' import { @@ -110,10 +110,10 @@ describe('LocalDirectModelProcessService', () => { ).rejects.toThrow() await expect( service.execute( - { command: 'echo ok', cwd: join(process.cwd(), 'absolute') }, + { command: 'echo ok', cwd: 'invalid\0directory' }, context ) - ).rejects.toThrow('相对于工作区') + ).rejects.toThrow('无效字符') expect(spawnProcess).not.toHaveBeenCalled() await service.dispose() }) @@ -198,68 +198,82 @@ describe('LocalDirectModelProcessService', () => { await unavailable.dispose() }) - it('resolves only existing workspace directories and rejects symlink escapes', async () => { - const { root, access } = await workspace() - const child = new FakeChild() - const { service, spawnProcess } = fakeService(child) - await mkdir(join(root, 'nested')) - - const execution = service.execute( - { command: 'pwd', cwd: 'nested' }, - { - conversationId: 'cwd', - workspace: access, - signal: new AbortController().signal + it.each(['relative', 'absolute', 'outside', 'parent', 'symlink'])( + 'resolves an existing %s working directory without workspace containment', + async (kind) => { + const { root, access } = await workspace() + const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-')) + temporaryDirectories.push(outside) + const nested = join(root, 'nested') + await mkdir(nested) + const link = join(root, 'outside-link') + if (kind === 'symlink') { + await symlink( + outside, + link, + process.platform === 'win32' ? 'junction' : 'dir' + ) } - ) - await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce()) - expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe( - await realpath(join(root, 'nested')) - ) - child.close(0) - await expect(execution).resolves.toMatchObject({ cwd: 'nested' }) + const cwd = + kind === 'relative' + ? 'nested' + : kind === 'absolute' + ? nested + : kind === 'outside' + ? outside + : kind === 'parent' + ? relative(root, outside) + : 'outside-link' + const expectedPath = await realpath( + kind === 'relative' || kind === 'absolute' ? nested : outside + ) + const child = new FakeChild() + const { service, spawnProcess } = fakeService(child) - await expect( - service.execute( - { command: 'pwd', cwd: '..' }, + const execution = service.execute( + { command: 'pwd', cwd }, { - conversationId: 'escape', + conversationId: 'cwd', workspace: access, signal: new AbortController().signal } ) - ).rejects.toThrow('不能超出') - - const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-')) - temporaryDirectories.push(outside) - const link = join(root, 'outside-link') - try { - await symlink( - outside, - link, - process.platform === 'win32' ? 'junction' : 'dir' - ) - await expect( - service.execute( - { command: 'pwd', cwd: 'outside-link' }, - { - conversationId: 'symlink', - workspace: access, - signal: new AbortController().signal - } - ) - ).rejects.toThrow('符号链接') - } catch (error) { - if ( - !( - error instanceof Error && - 'code' in error && - error.code === 'EPERM' - ) - ) { - throw error - } + await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce()) + expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(expectedPath) + child.close(0) + await expect(execution).resolves.toMatchObject({ + cwd: + kind === 'relative' || kind === 'absolute' + ? 'nested' + : expectedPath + }) + await service.dispose() } + ) + + it('rejects missing working directories and files before starting a process', async () => { + const { root, access } = await workspace() + const file = join(root, 'file.txt') + await writeFile(file, 'not a directory') + const { service, spawnProcess } = fakeService(new FakeChild()) + const context = { + conversationId: 'invalid-cwd', + workspace: access, + signal: new AbortController().signal + } + await expect( + service.execute( + { command: 'pwd', cwd: join(root, 'missing') }, + context + ) + ).rejects.toThrow() + await expect( + service.execute( + { command: 'pwd', cwd: file }, + context + ) + ).rejects.toThrow('不是目录') + expect(spawnProcess).not.toHaveBeenCalled() await service.dispose() }) diff --git a/src/main/agent/direct-model-process-service.ts b/src/main/agent/direct-model-process-service.ts index 9bdadda7..97a01b3c 100644 --- a/src/main/agent/direct-model-process-service.ts +++ b/src/main/agent/direct-model-process-service.ts @@ -28,7 +28,15 @@ export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER = export const processExecuteInputSchema = z .object({ command: z.string().trim().min(1).max(100_000), - cwd: z.string().trim().min(1).max(4_096).optional(), + cwd: z + .string() + .trim() + .min(1) + .max(4_096) + .describe( + '工作目录,可使用绝对路径或相对于当前工作区的路径;省略时使用工作区根目录,也可选择工作区外目录。' + ) + .optional(), timeoutMs: z .number() .int() @@ -350,10 +358,6 @@ async function resolveWorkingDirectory( if (input.cwd?.includes('\0')) { throw new Error('工作目录包含无效字符') } - if (input.cwd && isAbsolute(input.cwd)) { - throw new Error('工作目录必须相对于工作区') - } - const initialIdentity = await workspace.getIdentity() if (initialIdentity.kind !== 'local') { throw new Error('本机进程不能在远程工作区中运行') @@ -365,23 +369,8 @@ async function resolveWorkingDirectory( throw new Error('项目工作区不是目录') } const candidatePath = resolve(canonicalRoot, input.cwd ?? '.') - const lexicalDifference = relative(canonicalRoot, candidatePath) - if ( - lexicalDifference === '..' || - lexicalDifference.startsWith(`..${sep}`) || - isAbsolute(lexicalDifference) - ) { - throw new Error('工作目录不能超出项目工作区') - } const canonicalPath = await realpath(candidatePath) const canonicalDifference = relative(canonicalRoot, canonicalPath) - if ( - canonicalDifference === '..' || - canonicalDifference.startsWith(`..${sep}`) || - isAbsolute(canonicalDifference) - ) { - throw new Error('工作目录不能通过符号链接超出项目工作区') - } if (!(await stat(canonicalPath)).isDirectory()) { throw new Error('工作目录不是目录') } @@ -397,7 +386,12 @@ async function resolveWorkingDirectory( } return { canonicalPath, - displayPath: canonicalDifference || '.' + displayPath: + canonicalDifference === '..' || + canonicalDifference.startsWith(`..${sep}`) || + isAbsolute(canonicalDifference) + ? canonicalPath + : canonicalDifference || '.' } } diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 517012fe..ef1f8fef 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -1,4 +1,7 @@ import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { RecoverableModelToolError, @@ -2496,6 +2499,69 @@ describe('ModelAgentRuntime', () => { expect(toolProvider.dispose).toHaveBeenCalledOnce() }) + it.each(['absolute', 'relative'])( + 'executes a real command outside the workspace using a %s cwd through the model tool loop', + async (pathKind) => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cwd-')) + const workspace = join(root, 'workspace') + const target = join(root, 'target') + await mkdir(workspace) + await mkdir(target) + await writeFile(join(target, 'input.txt'), 'outside-workspace') + const command = process.platform === 'win32' + ? "Copy-Item 'input.txt' 'output.txt'" + : 'cp input.txt output.txt' + const responses = [ + { + choices: [{ + message: { + role: 'assistant', content: null, + tool_calls: [{ + id: 'call-process', type: 'function', + function: { + name: 'process_execute', + arguments: JSON.stringify({ + command, + cwd: pathKind === 'absolute' ? target : relative(workspace, target) + }) + } + }] + } + }] + }, + { choices: [{ message: { role: 'assistant', content: 'done' } }] } + ] + const fetcher = vi.fn(async () => Response.json(responses.shift())) + const runtime = new ModelAgentRuntime({ + baseUrl: 'http://127.0.0.1:11434/v1', + model: 'test-model', + protocol: 'openai-chat-completions', + authentication: 'none', + fetcher, + defaultWorkspace: workspace + }) + try { + const events: RuntimeEvent[] = [] + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + prompt: 'Copy the file in the requested directory.', + workMode: 'execute' + }, new AbortController().signal, async () => 'once')) { + events.push(event) + } + expect(await readFile(join(target, 'output.txt'), 'utf8')).toBe('outside-workspace') + expect(events).toContainEqual(expect.objectContaining({ + type: 'tool', callId: 'call-process', state: 'completed' + })) + expect(fetcher).toHaveBeenCalledTimes(2) + } finally { + await runtime.dispose() + await rm(root, { recursive: true, force: true }) + } + } + ) + it('delegates one programming Subagent that can run a real project command without recursion', async () => { const command = process.platform === 'win32' diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index bb100364..550361b1 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -1217,7 +1217,7 @@ export class ModelToolProvider implements ModelToolProviderLike { scopeKey: 'model:builtin:process_execute', title: '允许运行项目命令?', description: - '该命令会在当前工作区中使用当前用户权限运行,并可访问该账号有权访问的主机资源。', + '该命令会在指定目录(默认当前工作区)中使用当前用户权限运行,并可访问该账号有权访问的主机资源。', toolName: tool.displayName, argumentSummary, allowPermanent: false diff --git a/src/main/agent/opencode-runtime-lifecycle.test.ts b/src/main/agent/opencode-runtime-lifecycle.test.ts new file mode 100644 index 00000000..efaeba8b --- /dev/null +++ b/src/main/agent/opencode-runtime-lifecycle.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createOpencodeClient } from '@opencode-ai/sdk/v2' +import { expect, it } from 'vitest' +import { KnowledgeMcpGateway } from './knowledge-mcp-gateway' +import { OpenCodeRuntime } from './opencode-runtime' + +const binaryPath = join( + process.cwd(), '.runtime-resources', process.arch, + process.platform === 'win32' ? 'opencode.exe' : 'opencode' +) + +it.skipIf(!existsSync(binaryPath))( + 'closes real event streams after parallel runs without cancelling a peer', + async () => { + const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-lifecycle-')) + let cancelledController: AbortController | undefined + const model = createServer((request, response) => { + void (async () => { + let body = '' + for await (const chunk of request) body += chunk.toString() + if (body.includes('CANCEL_LIFECYCLE')) { + setTimeout(() => cancelledController?.abort( + new DOMException('Cancelled', 'AbortError') + ), 30) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const [delta, finishReason] of [ + [{ role: 'assistant', content: 'LIFECYCLE_OK' }, null], + [{}, 'stop'] + ]) { + response.write(`data: ${JSON.stringify({ + id: 'lifecycle', object: 'chat.completion.chunk', created: 1, + model: 'lifecycle', + choices: [{ index: 0, delta, finish_reason: finishReason }] + })}\n\n`) + } + response.end('data: [DONE]\n\n') + })().catch(() => response.destroy()) + }) + await new Promise((resolve) => model.listen(0, '127.0.0.1', resolve)) + const address = model.address() + if (!address || typeof address === 'string') throw new Error('No model port') + const gateway = new KnowledgeMcpGateway({} as never) + await gateway.start() + let openEventBodies = 0 + const runtime = new OpenCodeRuntime({ + embedded: true, binaryPath: '', bundledBinaryPath: binaryPath, + configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway, + modelProfile: { + id: crypto.randomUUID(), name: 'Lifecycle fixture', modelName: 'lifecycle', + baseUrl: `http://127.0.0.1:${address.port}/v1`, + protocol: 'openai-chat-completions', authentication: 'none' + } + }, { + createClient: (options) => createOpencodeClient({ + ...options, + fetch: async (request, init) => { + const response = await fetch(request, init) + const url = request instanceof Request ? request.url : request + if (new URL(url).pathname !== '/event' || !response.body) return response + openEventBodies++ + const reader = response.body.getReader() + let closed = false + const close = (): void => { + if (!closed) openEventBodies-- + closed = true + } + return new Response(new ReadableStream({ + async pull(controller) { + try { + const item = await reader.read() + if (item.done) { + close() + controller.close() + } else controller.enqueue(item.value) + } catch (error) { + close() + controller.error(error) + } + }, + async cancel(reason) { + close() + await reader.cancel(reason) + } + }), { status: response.status, headers: response.headers }) + } + }) + }) + const run = async (controller = new AbortController(), cancel = false): Promise => { + const requestId = crypto.randomUUID() + const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)! + const deadline = setTimeout(() => controller.abort(), 30_000) + let text = '' + try { + for await (const event of runtime.run({ + requestId, conversationId: crypto.randomUUID(), workMode: 'ask', + prompt: cancel ? 'CANCEL_LIFECYCLE' : 'Reply LIFECYCLE_OK without tools.', + knowledgeCapabilityToken: token + }, controller.signal)) { + if (event.type === 'text') text += event.delta + } + return text + } finally { + clearTimeout(deadline) + gateway.revoke(token) + } + } + try { + for (const concurrency of [1, 4, 8]) { + const results = await Promise.all(Array.from({ length: concurrency }, () => run())) + expect(results).toEqual(Array(concurrency).fill('LIFECYCLE_OK')) + await expect.poll(() => openEventBodies).toBe(0) + } + cancelledController = new AbortController() + const cancelled = run(cancelledController, true) + await Promise.all([ + expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }), + expect(run()).resolves.toBe('LIFECYCLE_OK') + ]) + await expect.poll(() => openEventBodies).toBe(0) + } finally { + await runtime.dispose() + await gateway.dispose() + model.closeAllConnections() + await new Promise((resolve) => model.close(() => resolve())) + await rm(workspace, { recursive: true, force: true }) + } + }, + 120_000 +) diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 9cc4a42f..af475c1b 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1290,6 +1290,59 @@ describe('OpenCodeRuntime embedded launcher', () => { expect(runtime.requiresToolApproval).toBe(false) }) + it.each(['completed', 'failed', 'cancelled', 'consumer-return'])( + 'closes the event subscription when a run is %s', + async (outcome) => { + const setup = runClient([]) + const controller = new AbortController() + let subscriptionSignal: AbortSignal | undefined + const subscribe = setup.event.subscribe as unknown as ReturnType + subscribe.mockImplementation(async (_input, options: { signal: AbortSignal }) => { + subscriptionSignal = options.signal + return { + stream: (async function* () { + if (outcome === 'cancelled') { + controller.abort(new DOMException('Cancelled', 'AbortError')) + return + } + if (outcome === 'failed') return + yield { + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + })() + } + }) + const runtime = new OpenCodeRuntime( + options({ embedded: false, baseUrl: 'http://127.0.0.1:4096' }), + { createClient: (() => setup.client) as typeof createOpencodeClient } + ) + const collect = async (): Promise => { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + prompt: 'test', + workMode: 'execute' + }, controller.signal)) { + if (outcome === 'consumer-return' && event.type === 'done') break + } + } + try { + if (outcome === 'failed') { + await expect(collect()).rejects.toThrow('事件流意外结束') + } else if (outcome === 'cancelled') { + await expect(collect()).rejects.toMatchObject({ name: 'AbortError' }) + } else { + await collect() + } + expect(subscriptionSignal?.aborted).toBe(true) + expect(controller.signal.aborted).toBe(outcome === 'cancelled') + } finally { + await runtime.dispose() + } + } + ) + it('serializes external runs that share one conversation session', async () => { const child = fakeChild() let releaseFirst!: () => void diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index ff5d1d1d..ab71a300 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -1980,6 +1980,7 @@ export class OpenCodeRuntime implements AgentRuntime { let knowledgeToolIds: string[] = [] let customMcpName: string | undefined let customMcpToken: string | undefined + const subscriptionController = new AbortController() try { if ( request.knowledgeCapabilityToken && @@ -2193,7 +2194,7 @@ export class OpenCodeRuntime implements AgentRuntime { { directory }, { signal: controlSignal } ), - signal + AbortSignal.any([signal, subscriptionController.signal]) ) const abortSession = (): void => { @@ -2699,6 +2700,7 @@ export class OpenCodeRuntime implements AgentRuntime { ) ) } + signal.throwIfAborted() throw new Error('OpenCode 事件流意外结束') } catch (error) { abortSession() @@ -2739,6 +2741,7 @@ export class OpenCodeRuntime implements AgentRuntime { } } } finally { + subscriptionController.abort() const cleanupSignal = AbortSignal.timeout(1_000) if (knowledgeMcpName) { await awaitWithAbort( diff --git a/src/shared/builtin-model-tools.ts b/src/shared/builtin-model-tools.ts index 578e9186..0196cf33 100644 --- a/src/shared/builtin-model-tools.ts +++ b/src/shared/builtin-model-tools.ts @@ -98,7 +98,7 @@ export const builtinModelTools = [ name: 'process_execute', displayName: '进程执行', description: - '使用当前工作区和当前用户权限运行 PowerShell、Bash 或 Sh 命令。', + '使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。', access: 'write', group: 'programming' },