Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .playwright/scripts/__tests__/sandbox.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
107 changes: 74 additions & 33 deletions apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, AbortController>()
const accepted = new Set<string>()
const finishing = new Map<string, Promise<void>>()
let disposed = false
const disposers = [
peer.onRequest('host.sandbox.prepare', async (params) => {
Expand Down Expand Up @@ -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<void>((resolve) => {
finished = resolve
}))
let dispatched = false
let responded = false
let directory: string | undefined
Expand All @@ -73,6 +80,8 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
let exited: Promise<void> | undefined
let forceKill: ReturnType<typeof setTimeout> | undefined
const cancel = () => {
if (!childPeer)
return
try {
childPeer?.notify('sandbox.cancel', {})
}
Expand Down Expand Up @@ -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
Expand All @@ -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: {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 }
Expand Down
43 changes: 41 additions & 2 deletions apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand All @@ -185,6 +196,34 @@ export async function verifySandboxInstallation(options: SandboxHostOptions, sca
measurements.push(elapsed)
}
}
if (powershell) {
let shutdown: Promise<void> | undefined
const output: Buffer[] = []
const settlements = new Map<string, OwnedProcessSettlement['settlement']>()
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { ShellSandboxError } from '../../../../../shared/permissions/shellSandbo
import { createWindowsSandboxPolicy } from './createPolicy'

export async function runWindowsSandbox(input: SandboxBackendInput<typeof SHELL_SANDBOX_BACKEND.Windows>, options: SandboxExecutionOptions): Promise<number | null> {
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)
Expand All @@ -24,7 +27,7 @@ export async function runWindowsSandbox(input: SandboxBackendInput<typeof SHELL_
filter: (port, host) => 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)
Expand All @@ -48,7 +51,7 @@ export async function runWindowsSandbox(input: SandboxBackendInput<typeof SHELL_
grants,
denyReadScope: input.backend.protectionScope,
proxyPort: address.port,
environment: createWindowsSandboxEnvironment(process.env, {
environment: createWindowsSandboxEnvironment(environment, {
...input.backend,
shell: program.executable,
dialect: program.dialect,
Expand Down
6 changes: 2 additions & 4 deletions apps/buddy/service/src/sandbox/runSandboxCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import type { SandboxNetworkTarget, SandboxProcessInput, SandboxResult } from '.
import type { SandboxExecutionOptions } from './sandboxExecutionLifecycle'
import { sandboxNetworkTargetSchema, ShellSandboxError } from '../../../shared/permissions/shellSandbox'
import { SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers'
import { runSrtSandbox } from './backends/srt/runSrtSandbox'
import { runWindowsSandbox } from './backends/windows/runWindowsSandbox'
import { SandboxExecutionLifecycle } from './sandboxExecutionLifecycle'

export async function runSandboxCommand(input: SandboxProcessInput, options: Omit<SandboxExecutionOptions, 'onStarted' | 'onProcess'> & { onLifecycle?: (event: Readonly<SandboxLifecycleEvent>) => void }): Promise<SandboxResult> {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>
onData: (data: Buffer) => void
Expand Down