diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 30630d30..69b908d6 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.9", + "agentVersion": "0.11.10", "protocol": { "major": 2, "minor": 0 diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 8dc5c82e..692d892f 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -284,7 +284,12 @@ Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进 产生冲突的项目创建和设置入口。远端 RPC 拒绝只向 Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。 `runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding, -不会把确定性失败错误保留为 `outcome-unknown`。 +不会把确定性失败错误保留为 `outcome-unknown`。未确认终态的 Prompt 仍不会自动重放; +用户之后显式发送的新 Prompt 会关闭旧的非就绪 Main binding,并以新的 ACP session +继续当前 Conversation,避免一次恢复不确定永久阻塞后续输入。 +Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent +model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件 +的有界转换,再登记为运行中进程。 不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id` diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts index 1f74d8d0..7e2d8917 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts @@ -119,6 +119,47 @@ describe('direct Linux stdio Runtime ownership', () => { registry.close() }) + it('waits for bubblewrap to exec the supervised bridge helper', async () => { + const registry = createRegistry() + const child = fakeChild() + const bwrapIdentity = identity() + const helperIdentity = { + ...bwrapIdentity, + executablePath: '/agent/node' + } + const spawn: DirectLinuxStdioSpawn = () => { + queueMicrotask(() => child.emit('spawn')) + return child + } + const readProcessIdentity = vi + .fn() + .mockResolvedValueOnce(bwrapIdentity) + .mockResolvedValue(helperIdentity) + + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), + profile: { + ...profile(), + processExecutable: '/agent/node' + }, + identity: { launchId: 'launch-1', processId: 'process-1' }, + installationId: 'installation-1', + registry, + deadlineAt: '2030-01-01T00:00:00.000Z', + maximumInputBytes: 1024, + platform: 'linux', + spawn, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity + }) + + expect(readProcessIdentity).toHaveBeenCalledTimes(2) + expect(owner.processIdentity).toEqual(helperIdentity) + child.exitCode = 0 + child.emit('close', 0, null) + registry.close() + }) + it('rejects PID reuse before sending any signal', async () => { const registry = createRegistry() const child = fakeChild() diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.ts b/src/agent-daemon/direct-linux-stdio-process-owner.ts index 9acc2e20..86208728 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.ts @@ -31,6 +31,7 @@ const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024 const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128 const DEFAULT_MAXIMUM_LISTENERS = 8 const DEFAULT_STOP_TIMEOUT_MS = 10_000 +const DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS = 5_000 const TERM_GRACE_MS = 2_000 const POLL_INTERVAL_MS = 25 const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN' @@ -603,8 +604,18 @@ export async function launchDirectLinuxStdioProcessOwner( await waitForSpawn(child, options.signal, timeoutMs) if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID') const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity - const processIdentity = await readIdentity(child.pid) - assertLaunchedIdentity(processIdentity, profile) + const processIdentity = await waitForLaunchedIdentity({ + child, + pid: child.pid, + profile, + readIdentity, + signal: options.signal, + deadline: Math.min( + Date.parse(effectiveDeadlineAt), + now() + DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS + ), + now + }) const supervisorIdentityDigest = supervisorDigest({ installationId, ownerId, @@ -871,15 +882,72 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile } } -function assertLaunchedIdentity( - identity: LinuxRuntimeProcessIdentity, +async function waitForLaunchedIdentity(input: { + child: DirectLinuxStdioChild + pid: number profile: OpenCodeLaunchProfile -): void { - if ( - identity.executablePath !== profile.processExecutable || - identity.processGroupId !== identity.pid - ) { - throw identityError('Spawned Runtime does not match the launch identity') + readIdentity: typeof readLinuxRuntimeProcessIdentity + signal?: AbortSignal + deadline: number + now: () => number +}): Promise { + for (;;) { + input.signal?.throwIfAborted() + if ( + input.child.exitCode !== null || + input.child.signalCode !== null + ) { + throw new DirectLinuxStdioProcessOwnerError( + 'Runtime process exited during launch', + 'process' + ) + } + let identity: LinuxRuntimeProcessIdentity + try { + identity = await input.readIdentity(input.pid) + } catch (error) { + if (!isMissingProcess(error)) { + throw error + } + if (input.now() >= input.deadline) { + throw new DirectLinuxStdioProcessOwnerError( + 'Runtime launch identity was not observed', + 'process' + ) + } + await delay( + Math.min( + POLL_INTERVAL_MS, + Math.max(1, input.deadline - input.now()) + ) + ) + continue + } + if (identity.processGroupId !== identity.pid) { + throw identityError( + 'Spawned Runtime does not match the launch identity' + ) + } + if (identity.executablePath === input.profile.processExecutable) { + return identity + } + if (identity.executablePath !== input.profile.executable) { + throw identityError( + 'Spawned Runtime does not match the launch identity' + ) + } + if (input.now() >= input.deadline) { + throw new DirectLinuxStdioProcessOwnerError( + 'Runtime launch did not reach its supervised executable', + 'process' + ) + } + await delay( + Math.min( + POLL_INTERVAL_MS, + Math.max(1, input.deadline - input.now()) + ) + ) } } diff --git a/src/agent-daemon/opencode-runtime-profile.test.ts b/src/agent-daemon/opencode-runtime-profile.test.ts index e1fa9716..84160d66 100644 --- a/src/agent-daemon/opencode-runtime-profile.test.ts +++ b/src/agent-daemon/opencode-runtime-profile.test.ts @@ -241,6 +241,9 @@ describe('OpenCode bubblewrap launch profile', () => { '--opencode-entrypoint', resolve(fixture.bundleDirectory, 'bin', 'opencode') ]) + expect(profile.processExecutable).toBe( + resolve(agentInstallationDirectory, 'node') + ) expect(profile.args.join('\0')).not.toMatch( /api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu ) diff --git a/src/agent-daemon/opencode-runtime-profile.ts b/src/agent-daemon/opencode-runtime-profile.ts index 62704e16..2ebd38c4 100644 --- a/src/agent-daemon/opencode-runtime-profile.ts +++ b/src/agent-daemon/opencode-runtime-profile.ts @@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: { return { executable: OPENCODE_BWRAP_EXECUTABLE, - processExecutable: OPENCODE_BWRAP_EXECUTABLE, + processExecutable: runtimeCommand.executable, args, cwd: scratchDirectory, env: { diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts index 392be6b6..32f887c0 100644 --- a/src/main/agent/acp-remote-runtime.test.ts +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -599,7 +599,7 @@ describe('AcpRemoteRuntime', () => { }) it.each(['prompt-running', 'outcome-unknown'] as const)( - 'does not replace a %s binding after identity changes', + 'retires a %s binding before starting an explicit later prompt', async (state) => { const store = new MemoryRuntimeSessionBindingStore() const active = { @@ -625,7 +625,13 @@ describe('AcpRemoteRuntime', () => { lastMainAckSequence: '0' } await store.put(active) - const factory = vi.fn(async () => fakeServer().channel) + const replacement = fakeServer() + const factory = vi.fn( + async (bindingId: string) => { + expect(bindingId).not.toBe(active.bindingId) + return replacement.channel + } + ) const instance = factoryRuntime(factory, store, { identity: { ...identity, @@ -633,22 +639,28 @@ describe('AcpRemoteRuntime', () => { } }) - await expect( - collect( - instance.run( - { ...request, requestId: 'prompt-2' }, - new AbortController().signal - ) + await collect( + instance.run( + { ...request, requestId: 'prompt-2' }, + new AbortController().signal ) - ).rejects.toThrow('不会自动重放') + ) - expect(factory).not.toHaveBeenCalled() + expect(factory).toHaveBeenCalledOnce() + expect(factory).not.toHaveBeenCalledWith(active.bindingId) + await expect( + store.getById(active.bindingId) + ).resolves.toMatchObject({ + bindingId: active.bindingId, + state: 'closed', + activePromptOperationId: undefined + }) await expect( store.getByConversation(request.conversationId) ).resolves.toMatchObject({ - bindingId: active.bindingId, - state: 'outcome-unknown', - activePromptOperationId: request.requestId + bindingId: factory.mock.calls[0]![0], + state: 'ready', + activePromptOperationId: undefined }) await instance.dispose() } diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index fb992168..76458042 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -833,7 +833,7 @@ export class AcpRemoteRuntime implements AgentRuntime { return interrupted } - private async persistRejectedPrompt( + private async persistClosedBinding( binding: RuntimeSessionBinding ): Promise { const closed = runtimeSessionBindingSchema.parse({ @@ -1198,24 +1198,19 @@ export class AcpRemoteRuntime implements AgentRuntime { request: AgentExecutionRequest, signal: AbortSignal ): Promise { - const old = await this.awaitLocalOperation( + let binding = await this.awaitLocalOperation( '读取会话绑定', this.options.bindingStore.getByConversation(conversationId), signal ) if ( - old && - old.state !== 'closed' && - old.state !== 'ready' + binding && + binding.state !== 'closed' && + binding.state !== 'ready' ) { - await this.persistInterrupted(old) - throw new Error( - old.state === 'prompt-running' - ? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放' - : '远端 ACP 会话恢复状态不确定,已中断且不会自动重放' - ) + await this.persistClosedBinding(binding) + binding = undefined } - let binding = old let bindingId = binding?.bindingId ?? randomUUID() if ( binding?.state === 'ready' && @@ -1437,7 +1432,7 @@ export class AcpRemoteRuntime implements AgentRuntime { error, 'runtime/preparePrompt' ) - ? this.persistRejectedPrompt(binding) + ? this.persistClosedBinding(binding) : this.persistInterrupted(binding) await persist.catch(() => undefined) } @@ -1483,7 +1478,7 @@ export class AcpRemoteRuntime implements AgentRuntime { error, 'runtime/preparePrompt' ) - ? this.persistRejectedPrompt(current.binding) + ? this.persistClosedBinding(current.binding) : this.persistInterrupted(current.binding) current.binding = await persist.catch(() => current.binding) this.sessions.delete(conversationId)