From 1305c03e24297805e96f6697a68c8fee2f336ece Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Thu, 22 Oct 2026 13:43:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(buddy):=20=E4=BF=AE=E5=A4=8D=20Windows=20?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=E9=80=80=E5=87=BA=E5=BB=B6=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .playwright/scripts/__tests__/sandbox.e2e.mjs | 2 +- .../main/sandbox/registerSandboxHostRpc.ts | 107 ++++++++++++------ .../main/sandbox/verifySandboxInstallation.ts | 43 ++++++- .../backends/windows/runWindowsSandbox.ts | 7 +- .../service/src/sandbox/runSandboxCommand.ts | 6 +- .../src/sandbox/sandboxExecutionLifecycle.ts | 1 + 6 files changed, 124 insertions(+), 42 deletions(-) diff --git a/.playwright/scripts/__tests__/sandbox.e2e.mjs b/.playwright/scripts/__tests__/sandbox.e2e.mjs index 189cb8f69..0c474254b 100644 --- a/.playwright/scripts/__tests__/sandbox.e2e.mjs +++ b/.playwright/scripts/__tests__/sandbox.e2e.mjs @@ -357,7 +357,7 @@ for (const workspaceKind of ['managed', 'bound']) { for (const phase of phases.filter(phase => phase.toolName === shell)) { expect(phase.timing.executionBasis).toBe('backend') expect(phase.timing.durations.capturing).toBeUndefined() - if (process.platform === 'linux') + if (process.platform === 'linux' || process.platform === 'win32') expect(phase.timing.durations.cleaning).toBeLessThan(1000) } measurements.push({ label, runId: turn.runId, timings: phases.map(event => event.timing), tools: completed }) diff --git a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts index e8f815727..37d0aeb58 100644 --- a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts +++ b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts @@ -4,15 +4,17 @@ import { userInfo } from 'node:os' import { dirname } from 'node:path' import process from 'node:process' import { utilityProcess } from 'electron' +import { z } from 'zod' import { createProxyEnvironment } from '../../../platform/process/proxyEnvironment' import { createSandboxDirectory } from '../../../platform/process/sandboxDirectory' import { createSandboxEnvironment } from '../../../platform/process/sandboxEnvironment' import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox' import { resolveSensitivePathRoots } from '../../../service/src/permissions/sensitivePaths' +import { runSandboxCommand } from '../../../service/src/sandbox/runSandboxCommand' import sandboxProcessPath from '../../../service/src/sandbox/sandboxProcess?modulePath' import { Emitter } from '../../../shared/events/Emitter' import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle' -import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema, sandboxPreparationSchema, sandboxSettlementAckSchema, sandboxSettlementSchema } from '../../../shared/permissions/shellSandbox' +import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema, sandboxPreparationSchema, sandboxProcessInputSchema, sandboxSettlementAckSchema, sandboxSettlementSchema } from '../../../shared/permissions/shellSandbox' import { isLinux, isWindows, OPERATING_SYSTEM, SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' import { BuddyServicePeer } from '../runtime/BuddyServicePeer' import { WindowsSandboxPreparation } from './WindowsSandboxPreparation' @@ -36,6 +38,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa const preparation = isWindows(process.platform) ? new WindowsSandboxPreparation({ ...options, protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)] }) : undefined const active = new Map() const accepted = new Set() + const finishing = new Map>() let disposed = false const disposers = [ peer.onRequest('host.sandbox.prepare', async (params) => { @@ -64,6 +67,10 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa preparation?.start() const controller = new AbortController() active.set(input.requestId, controller) + let finished!: () => void + finishing.set(input.requestId, new Promise((resolve) => { + finished = resolve + })) let dispatched = false let responded = false let directory: string | undefined @@ -73,6 +80,8 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa let exited: Promise | undefined let forceKill: ReturnType | undefined const cancel = () => { + if (!childPeer) + return try { childPeer?.notify('sandbox.cancel', {}) } @@ -110,10 +119,29 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa } finally { active.delete(input.requestId) + finishing.delete(input.requestId) + finished() if (disposed && !active.size) changes.dispose() } } + const settle = async (params: unknown) => { + const record = sandboxSettlementSchema.parse(params) + if ((disposed && record.launchState !== 'settled') || record.requestId !== input.requestId || record.executionId !== input.executionId || record.dispatchId !== input.dispatchId + || (record.launchState === 'authorized' && controller.signal.aborted)) { + throw new Error('SANDBOX_CANCELLED') + } + const result = sandboxSettlementAckSchema.parse(await peer.request('sandbox.settlement', record, 60_000)) + if (record.launchState === 'authorized' && (disposed || controller.signal.aborted)) + throw new Error('SANDBOX_CANCELLED') + return result + } + const network = async (params: unknown) => { + const target = sandboxNetworkRequestSchema.parse(params) + if (disposed || controller.signal.aborted || target.requestId !== input.requestId) + return { allowed: false } + return peer.request('sandbox.network', target, 30 * 60 * 1_000) + } controller.signal.addEventListener('abort', cancel, { once: true }) try { const windowsSandbox = isWindows(process.platform) ? await resolveWindowsSandbox(options.windowsSandbox) : undefined @@ -124,6 +152,47 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa return { ok: false, code: 'SANDBOX_UNAVAILABLE', notStarted: true } directory = await createSandboxDirectory() controller.signal.throwIfAborted() + const command = { + ...input, + home: userInfo().homedir, + sensitiveRoots: resolveSensitivePathRoots(), + path: process.env.PATH ?? '', + privateRoot: directory, + receiptDirectory: options.receiptDirectory, + protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)], + searchDirectory: options.searchDirectory, + backend: windowsSandbox + ? { kind: SHELL_SANDBOX_BACKEND.Windows, executable: windowsSandbox, shell: options.windowsShell, systemRoot, protectionScope: options.protectionScope ?? options.buddyHome } + : isLinux(process.platform) + ? { kind: SHELL_SANDBOX_BACKEND.Linux, sandboxDirectory: options.sandboxDirectory } + : { kind: SHELL_SANDBOX_BACKEND.MacOS }, + } + if (windowsSandbox) { + dispatched = true + return await runSandboxCommand(sandboxProcessInputSchema.parse(command), { + signal: controller.signal, + environment: { + ...createSandboxEnvironment(process.env, directory), + ...(options.proxyUrl ? createProxyEnvironment(options.proxyUrl) : {}), + }, + async onSettlement(record) { + await settle({ ...record, requestId: input.requestId }) + }, + onLifecycle(event) { + if (!disposed) + peer.notify('host.sandbox.lifecycle', { ...event, requestId: input.requestId }) + }, + onData(data) { + if (disposed || controller.signal.aborted) + return + for (let offset = 0; offset < data.length; offset += 64 * 1024) + peer.notify('host.sandbox.output', { requestId: input.requestId, data: data.subarray(offset, offset + 64 * 1024).toString('base64') }) + }, + async approveNetwork(target) { + return z.object({ allowed: z.boolean() }).strict().parse(await network({ ...target, requestId: input.requestId })).allowed + }, + }) + } child = utilityProcess.fork(sandboxProcessPath, [], { cwd: directory, env: { @@ -161,39 +230,10 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa if (output.requestId === input.requestId) peer.notify('host.sandbox.output', output) }) - processPeer.onRequest('sandbox.settlement', async (params) => { - const record = sandboxSettlementSchema.parse(params) - if (disposed || record.requestId !== input.requestId || record.executionId !== input.executionId || record.dispatchId !== input.dispatchId - || (record.launchState === 'authorized' && controller.signal.aborted)) { - throw new Error('SANDBOX_CANCELLED') - } - const result = sandboxSettlementAckSchema.parse(await peer.request('sandbox.settlement', record, 60_000)) - if (record.launchState === 'authorized' && (disposed || controller.signal.aborted)) - throw new Error('SANDBOX_CANCELLED') - return result - }) - processPeer.onRequest('sandbox.network', async (params) => { - const target = sandboxNetworkRequestSchema.parse(params) - if (disposed || controller.signal.aborted || target.requestId !== input.requestId) - return { allowed: false } - return peer.request('sandbox.network', target, 30 * 60 * 1_000) - }) + processPeer.onRequest('sandbox.settlement', settle) + processPeer.onRequest('sandbox.network', network) dispatched = true - const result = await processPeer.request('sandbox.exec', { - ...input, - home: userInfo().homedir, - sensitiveRoots: resolveSensitivePathRoots(), - path: process.env.PATH ?? '', - privateRoot: directory, - receiptDirectory: options.receiptDirectory, - protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)], - searchDirectory: options.searchDirectory, - backend: windowsSandbox - ? { kind: SHELL_SANDBOX_BACKEND.Windows, executable: windowsSandbox, shell: options.windowsShell, systemRoot, protectionScope: options.protectionScope ?? options.buddyHome } - : isLinux(process.platform) - ? { kind: SHELL_SANDBOX_BACKEND.Linux, sandboxDirectory: options.sandboxDirectory } - : { kind: SHELL_SANDBOX_BACKEND.MacOS }, - }, SANDBOX_RPC_TIMEOUT_MS) + const result = await processPeer.request('sandbox.exec', command, SANDBOX_RPC_TIMEOUT_MS) responded = true return result } @@ -215,6 +255,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa controller.abort() if (!active.size) changes.dispose() + await Promise.all(finishing.values()) await preparation?.dispose() } return { dispose, prepare: () => preparation?.start(), onDidChange: changes.event } diff --git a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts index 5af8f5b84..8137d6e61 100644 --- a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts +++ b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts @@ -1,10 +1,11 @@ import type { MessagePort } from 'node:worker_threads' +import type { OwnedProcessSettlement } from '../../../platform/process/supervisedShellProcess' import type { SandboxHostOptions } from './registerSandboxHostRpc' import { Buffer } from 'node:buffer' import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import process from 'node:process' import { setTimeout } from 'node:timers/promises' import { MessageChannel } from 'node:worker_threads' @@ -111,6 +112,15 @@ export async function verifySandboxInstallation(options: SandboxHostOptions, sca throw new Error(`Installed sandbox exceeded its first prepared command budget: ${firstCommandMs} ms`) if (write.exitCode !== 0 || await readFile(join(workspace, 'result'), 'utf8') !== 'ready') throw new Error('Installed sandbox could not write its authorized workspace') + const failed = await execute('exit 23') + if (failed.exitCode !== 23) + throw new Error('Installed sandbox lost the command exit code') + const descendant = powershell + ? `$child = Start-Process -FilePath ${quote(options.windowsShell!)} -ArgumentList '-NoLogo','-NoProfile','-EncodedCommand','${Buffer.from(`Start-Sleep -Seconds 2; Set-Content -LiteralPath ${quote(join(workspace, 'descendant'))} -Value changed`, 'utf16le').toString('base64')}' -NoNewWindow -PassThru; [Console]::Out.Write('DESCENDANT_PID=' + $child.Id)` + : '(sleep 2; touch descendant) >/dev/null 2>&1 &' + const descendants = await execute(descendant) + if (descendants.exitCode !== 0 || (powershell && !/DESCENDANT_PID=\d+/.test(descendants.output))) + throw new Error('Installed sandbox could not supervise a descendant') const secret = await execute(powershell ? 'Get-Content -LiteralPath .env -ErrorAction Stop' : 'cat .env') if (secret.exitCode !== 0 || !secret.output.includes('synthetic-secret')) throw new Error('Installed sandbox could not read ordinary workspace content') @@ -165,7 +175,7 @@ export async function verifySandboxInstallation(options: SandboxHostOptions, sca } const cancellation = new AbortController() try { - await execute(powershell ? '[Console]::Out.Write("CANCEL_READY"); Start-Sleep -Seconds 2; Set-Content -LiteralPath cancelled -Value changed' : 'printf CANCEL_READY; (sleep 2; touch cancelled) & wait', false, false, 10, cancellation) + await execute(powershell ? `${descendant.replaceAll('DESCENDANT_PID=', 'CANCEL_CHILD=')}; [Console]::Out.Write("CANCEL_READY"); Start-Sleep -Seconds 2; Set-Content -LiteralPath cancelled -Value changed` : 'printf CANCEL_READY; (sleep 2; touch cancelled) & wait', false, false, 10, cancellation) throw new Error('Installed sandbox ignored its cancellation') } catch (error) { @@ -175,6 +185,7 @@ export async function verifySandboxInstallation(options: SandboxHostOptions, sca await setTimeout(2_100) await absent(join(workspace, 'late')) await absent(join(workspace, 'cancelled')) + await absent(join(workspace, 'descendant')) if (scale) { for (let iteration = 0; iteration < 3; iteration++) { const start = performance.now() @@ -185,6 +196,34 @@ export async function verifySandboxInstallation(options: SandboxHostOptions, sca measurements.push(elapsed) } } + if (powershell) { + let shutdown: Promise | undefined + const output: Buffer[] = [] + const settlements = new Map() + try { + await client.exec({ command: '[Console]::Out.WriteLine(\'PRIVATE_TEMP=\' + $env:TEMP); [Console]::Out.Write(\'DISPOSE_READY\'); Start-Sleep -Seconds 60', cwd: workspace, roots: [workspace], workspaceRoots: [workspace], resourceReadRoots: [], additionalDirectories: [], readOnly: true, timeout: 10 }, { + onData(data) { + output.push(data) + if (Buffer.concat(output).includes('DISPOSE_READY')) + shutdown ??= disposeHost.dispose() + }, + }, async () => false, { + onSettlement(record) { + settlements.set(record.processId, record.settlement) + }, + }) + throw new Error('Installed sandbox ignored host disposal') + } + catch (error) { + if (!(error instanceof ShellSandboxError) || error.code !== 'SANDBOX_CANCELLED') + throw error + } + await shutdown + const temporary = Buffer.concat(output).toString('utf8').match(/PRIVATE_TEMP=([^\r\n]+)/)?.[1] + if (!shutdown || !temporary || settlements.size !== 3 || [...settlements.values()].some(state => state !== 'released')) + throw new Error('Installed sandbox lost native release receipts during host disposal') + await absent(dirname(temporary)) + } return { preparationMs, firstCommandMs, repeatedRpcMs: measurements } } catch (error) { diff --git a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts index 30d54be41..53fd18bda 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts @@ -14,6 +14,9 @@ import { ShellSandboxError } from '../../../../../shared/permissions/shellSandbo import { createWindowsSandboxPolicy } from './createPolicy' export async function runWindowsSandbox(input: SandboxBackendInput, options: SandboxExecutionOptions): Promise { + const environment = options.environment ?? process.env + const httpProxy = environment.HTTP_PROXY ?? environment.http_proxy ?? '' + const httpsProxy = environment.HTTPS_PROXY ?? environment.https_proxy ?? httpProxy const program = input.shell ?? { dialect: 'powershell' as const, executable: input.backend.shell } buildShellInvocation(program, input.command) const { grants, path } = await createWindowsSandboxPolicy(input, options.signal) @@ -24,7 +27,7 @@ export async function runWindowsSandbox(input: SandboxBackendInput options.signal.aborted ? false : options.approveNetwork({ host, port }), lookupFor: port => guard.lookupFor(port), proxyAuthToken: token, - parentProxy: resolveParentProxy(), + parentProxy: resolveParentProxy({ http: httpProxy, https: httpsProxy, noProxy: environment.NO_PROXY ?? environment.no_proxy ?? '' }), }) proxy.on('connection', (socket) => { connections.add(socket) @@ -48,7 +51,7 @@ export async function runWindowsSandbox(input: SandboxBackendInput & { onLifecycle?: (event: Readonly) => void }): Promise { @@ -38,8 +36,8 @@ export async function runSandboxCommand(input: SandboxProcessInput, options: Omi }, } const exitCode = input.backend.kind === SHELL_SANDBOX_BACKEND.Windows - ? await runWindowsSandbox({ ...input, backend: input.backend }, execution) - : await runSrtSandbox({ ...input, backend: input.backend }, execution) + ? await (await import('./backends/windows/runWindowsSandbox')).runWindowsSandbox({ ...input, backend: input.backend }, execution) + : await (await import('./backends/srt/runSrtSandbox')).runSrtSandbox({ ...input, backend: input.backend }, execution) if (lifecycle.signal.aborted) result = { ok: false, code: lifecycle.timedOut ? 'SANDBOX_TIMEOUT' : 'SANDBOX_CANCELLED' } else diff --git a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts index b4313718b..be68c6b90 100644 --- a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts +++ b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts @@ -7,6 +7,7 @@ import { Emitter } from '../../../shared/events/Emitter' import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' export interface SandboxExecutionOptions { + environment?: NodeJS.ProcessEnv signal: AbortSignal approveNetwork: (target: SandboxNetworkTarget) => Promise onData: (data: Buffer) => void