From bbc2f8d27e0d443279437fc9e1d58bacd514f34d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 15:36:57 -0400 Subject: [PATCH] fix: isolate native PTC readiness and watchdog phases --- packages/code/src/native-process-child.ts | 89 ++++++-- packages/code/src/native-process.test.ts | 234 +++++++++++++++++++++- packages/code/src/native-process.ts | 140 ++++++++++--- packages/code/src/native-programmatic.ts | 15 +- packages/code/src/native-sandbox.ts | 17 +- 5 files changed, 444 insertions(+), 51 deletions(-) diff --git a/packages/code/src/native-process-child.ts b/packages/code/src/native-process-child.ts index 2e8beb38..c3b9614b 100644 --- a/packages/code/src/native-process-child.ts +++ b/packages/code/src/native-process-child.ts @@ -11,7 +11,12 @@ import type { // argv credentials, bridge token, or persisted pairing material is required. let sandbox: NativeSrtWorkspaceCommandSandbox | undefined; let programmaticExecutor: NativeWorkspaceProgrammaticExecutor | undefined; +let programmaticReady: Promise | undefined; +let programmaticFileUpstream: string | undefined; let active: { id: string; controller: AbortController } | undefined; +let commitAcknowledgement: + | { id: string; acknowledge(): void } + | undefined; let busy = false; let credentials: Record = {}; let wrappedCommand: string | undefined; @@ -25,6 +30,33 @@ function reply(message: object): void { /* Parent was lost. */ } } +async function awaitCommitAcknowledgement( + id: string, + signal: AbortSignal, +): Promise { + await new Promise((resolve, reject) => { + const abort = () => { + commitAcknowledgement = undefined; + reject( + new WorkspaceToolError( + 'Programmatic execution aborted before commit', + 'EXECUTION_ABORTED', + ), + ); + }; + commitAcknowledgement = { + id, + acknowledge() { + signal.removeEventListener('abort', abort); + commitAcknowledgement = undefined; + resolve(); + }, + }; + signal.addEventListener('abort', abort, { once: true }); + reply({ id, phase: 'commit' }); + if (signal.aborted) abort(); + }); +} let shuttingDown = false; const shutdown = () => { if (shuttingDown) return; @@ -59,19 +91,29 @@ process.on('message', async (raw: unknown) => { workspaceId?: string; credentials?: Record; wrappedCommand?: string; + programmaticShellPath?: string; + programmaticJqPath?: string; }; if (!message || typeof message.id !== 'string') return; if (message.type === 'cancel') { if (active?.id === message.id) active.controller.abort(); return; } + if (message.type === 'commit-ack') { + if (commitAcknowledgement?.id === message.id) { + commitAcknowledgement.acknowledge(); + } + return; + } if (busy) return; busy = true; + let mutationStarted = false; try { let result: unknown; if (message.type === 'prepare' && !sandbox) { - const { variables, programmaticFileUpstream, ...options } = + const { variables, programmaticFileUpstream: upstream, ...options } = message.options; + programmaticFileUpstream = upstream; sandbox = new NativeSrtWorkspaceCommandSandbox({ ...options, ...(variables @@ -89,32 +131,55 @@ process.on('message', async (raw: unknown) => { : {}), }); await sandbox.prepare(); - programmaticExecutor = programmaticFileUpstream - ? new NativeWorkspaceProgrammaticExecutor({ - sandbox, - upstreamUrl: programmaticFileUpstream, - }) - : undefined; - await programmaticExecutor?.prepare(); } else if (message.type === 'execute' && sandbox) { active = { id: message.id, controller: new AbortController() }; credentials = message.credentials ?? {}; wrappedCommand = message.wrappedCommand; + mutationStarted = true; result = await sandbox.execute(message.request, active.controller.signal); } else if ( message.type === 'programmatic' && sandbox && - programmaticExecutor && + programmaticFileUpstream && message.programmaticRequest && - typeof message.workspaceId === 'string' + typeof message.workspaceId === 'string' && + typeof message.programmaticShellPath === 'string' && + typeof message.programmaticJqPath === 'string' ) { active = { id: message.id, controller: new AbortController() }; credentials = message.credentials ?? {}; wrappedCommand = message.wrappedCommand; + if (!programmaticExecutor) { + programmaticExecutor = new NativeWorkspaceProgrammaticExecutor({ + sandbox, + upstreamUrl: programmaticFileUpstream, + shellPath: message.programmaticShellPath, + jqPath: message.programmaticJqPath, + }); + programmaticReady = programmaticExecutor.prepare( + active.controller.signal, + ); + } + try { + await programmaticReady; + } catch (error) { + programmaticExecutor = undefined; + programmaticReady = undefined; + throw error; + } result = await programmaticExecutor.execute( message.programmaticRequest, message.workspaceId, active.controller.signal, + { + async beforeCommit() { + await awaitCommitAcknowledgement( + message.id, + active!.controller.signal, + ); + mutationStarted = true; + }, + }, ); } else if (message.type === 'close' && sandbox) { await sandbox.close(); @@ -134,11 +199,11 @@ process.on('message', async (raw: unknown) => { mutation: error instanceof WorkspaceToolError ? error.mutationMayHaveCommitted - : true, + : mutationStarted, requiresQuarantine: error instanceof WorkspaceToolError ? error.requiresQuarantine - : true, + : mutationStarted, }); } finally { active = undefined; diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index 2f38d642..89651845 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -11,6 +11,7 @@ import { trustedProgrammaticExecutable, } from './native-process.js'; import { WorkspaceToolError } from './workspace.js'; +import { BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS } from './protocol.js'; test('preflight rejects relative and workspace-controlled executables including symlinks', async t => { const root = await mkdtemp(join(tmpdir(), 'native-ptc-path-')); @@ -99,6 +100,24 @@ function fixture( }; } +class ObservedWatchdogSandbox extends NativeProcessWorkspaceCommandSandbox { + readonly watchdogTimeouts: number[] = []; + readonly watchdogCallbacks: Array<() => void> = []; + + protected override scheduleRpcTimeout( + callback: () => void, + timeoutMs: number, + ): ReturnType { + this.watchdogTimeouts.push(timeoutMs); + this.watchdogCallbacks.push(callback); + return super.scheduleRpcTimeout(callback, timeoutMs); + } + + fireLatestWatchdog(): void { + this.watchdogCallbacks.at(-1)?.(); + } +} + test('executor bootstrap excludes bridge credentials and Node injection variables', async () => { assert.deepEqual( nativeExecutorEnvironment({ @@ -229,13 +248,12 @@ test('programmatic executor resolves and scopes credentials to its command', asy const message = fake.messages.find( candidate => candidate.type === 'programmatic', )!; - const prepareMessage = fake.messages.find( - candidate => candidate.type === 'prepare', - )!; - assert.equal(typeof prepareMessage.options.jqPath, 'string'); - assert.equal(prepareMessage.options.jqPath.startsWith('/'), true); + assert.equal(typeof message.programmaticShellPath, 'string'); + assert.equal(message.programmaticShellPath.startsWith('/'), true); + assert.equal(typeof message.programmaticJqPath, 'string'); + assert.equal(message.programmaticJqPath.startsWith('/'), true); assert.equal( - '/sandbox-only'.split(':').includes(dirname(prepareMessage.options.jqPath)), + '/sandbox-only'.split(':').includes(dirname(message.programmaticJqPath)), false, ); assert.deepEqual(message.credentials, { TOKEN: 'per-programmatic-secret' }); @@ -246,8 +264,109 @@ test('programmatic executor resolves and scopes credentials to its command', asy await sandbox.close(); }); +test('omitted PTC timeout gives the commit watchdog the protocol execution default', async () => { + const fake = fixture((child, message) => { + if (message.type !== 'programmatic') return; + child.emit('message', { id: message.id, phase: 'commit' }); + child.emit('message', { id: message.id, ok: true, result: {} }); + }); + const sandbox = new ObservedWatchdogSandbox( + { + workspaceRoot: tmpdir(), + programmaticFileUpstream: 'http://127.0.0.1:3190', + }, + fake.fork, + ); + + await sandbox.executeProgrammatic('primary', { + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + replay_tool_count: 0, + max_output_files: 0, + files: [{ name: 'main.sh', content: 'sleep 45' }], + }, + }); + assert.ok( + sandbox.watchdogTimeouts.at(-1)! > + BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, + ); + await sandbox.close(); +}); + +test('PTC watchdog budgets staging separately and resets when commit begins', async () => { + const fake = fixture((child, message) => { + if (message.type !== 'programmatic') return; + child.emit('message', { id: message.id, phase: 'commit' }); + child.emit('message', { id: message.id, ok: true, result: {} }); + }); + const sandbox = new ObservedWatchdogSandbox( + { + workspaceRoot: tmpdir(), + programmaticFileUpstream: 'http://127.0.0.1:3190', + }, + fake.fork, + ); + + await sandbox.executeProgrammatic('primary', { + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + run_timeout: 1_000, + replay_tool_count: 0, + max_output_files: 0, + files: [{ name: 'main.sh', content: 'echo ready' }], + }, + }); + assert.deepEqual(sandbox.watchdogTimeouts.slice(-2), [65_000, 6_000]); + assert.ok( + fake.messages.some(message => message.type === 'commit-ack'), + 'the child must not enter the mutating phase before the parent arms it', + ); + await sandbox.close(); +}); + +test('PTC-only preflight failures do not disable ordinary native commands', async () => { + const fake = fixture(); + const sandbox = new NativeProcessWorkspaceCommandSandbox( + { + workspaceRoot: '/workspace', + shellPath: '/definitely/missing/bash', + programmaticFileUpstream: 'http://127.0.0.1:3190', + }, + fake.fork, + ); + + assert.deepEqual(await sandbox.execute(request), result); + await assert.rejects( + sandbox.executeProgrammatic('primary', { + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + files: [{ name: 'main.sh', content: 'echo ready' }], + }, + }), + (error: unknown) => + error instanceof WorkspaceToolError && + error.code === 'COMMAND_UNAVAILABLE' && + !error.mutationMayHaveCommitted, + ); + assert.deepEqual(await sandbox.execute(request), result); + await sandbox.close(); +}); + test('programmatic executor preserves a child-reported pre-dispatch failure', async () => { - const fake = fixture((child, message) => + const fake = fixture((child, message) => { + if (message.type !== 'programmatic') { + child.emit('message', { id: message.id, ok: true, result }); + return; + } child.emit('message', { id: message.id, ok: false, @@ -255,8 +374,39 @@ test('programmatic executor preserves a child-reported pre-dispatch failure', as errorMessage: 'Programmatic input download failed', mutation: false, requiresQuarantine: false, + }); + }); + const sandbox = new NativeProcessWorkspaceCommandSandbox( + { + workspaceRoot: tmpdir(), + programmaticFileUpstream: 'http://127.0.0.1:3190', + }, + fake.fork, + ); + + await assert.rejects( + sandbox.executeProgrammatic('primary', { + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + files: [{ name: 'main.sh', content: 'echo ready' }], + }, }), + (error: unknown) => + error instanceof WorkspaceToolError && + !error.mutationMayHaveCommitted && + !error.requiresQuarantine, ); + assert.deepEqual(await sandbox.execute(request), result); + await sandbox.close(); +}); + +test('executor loss during programmatic staging is not an uncertain workspace mutation', async () => { + const fake = fixture((child, message) => { + if (message.type === 'programmatic') child.emit('exit', 1); + }); const sandbox = new NativeProcessWorkspaceCommandSandbox( { workspaceRoot: tmpdir(), @@ -283,6 +433,76 @@ test('programmatic executor preserves a child-reported pre-dispatch failure', as await sandbox.close(); }); +test('programmatic staging watchdog expires without claiming a workspace mutation', async () => { + let staged!: () => void; + const staging = new Promise(resolve => { + staged = resolve; + }); + const fake = fixture((_child, message) => { + if (message.type === 'programmatic') staged(); + }); + const sandbox = new ObservedWatchdogSandbox( + { + workspaceRoot: tmpdir(), + programmaticFileUpstream: 'http://127.0.0.1:3190', + }, + fake.fork, + ); + const execution = sandbox.executeProgrammatic('primary', { + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + files: [{ name: 'main.sh', content: 'echo ready' }], + }, + }); + await staging; + sandbox.fireLatestWatchdog(); + + await assert.rejects( + execution, + (error: unknown) => + error instanceof WorkspaceToolError && + !error.mutationMayHaveCommitted && + !error.requiresQuarantine, + ); + assert.equal(fake.killCalls, 1); + await sandbox.close(); +}); + +test('executor loss after programmatic commit starts remains an uncertain mutation', async () => { + const fake = fixture((child, message) => { + if (message.type !== 'programmatic') return; + child.emit('message', { id: message.id, phase: 'commit' }); + child.emit('exit', 1); + }); + const sandbox = new NativeProcessWorkspaceCommandSandbox( + { + workspaceRoot: tmpdir(), + programmaticFileUpstream: 'http://127.0.0.1:3190', + }, + fake.fork, + ); + + await assert.rejects( + sandbox.executeProgrammatic('primary', { + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + files: [{ name: 'main.sh', content: 'echo ready' }], + }, + }), + (error: unknown) => + error instanceof WorkspaceToolError && + error.mutationMayHaveCommitted && + error.requiresQuarantine, + ); + await sandbox.close(); +}); + test('executor loss after dispatch is an uncertain mutation and is never replayed', async () => { const fake = fixture(child => child.emit('exit', 1)); const sandbox = new NativeProcessWorkspaceCommandSandbox( diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 4ed3ffec..81bbd9c4 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { WorkspaceToolError } from './workspace.js'; import { NATIVE_PROGRAMMATIC_COMMAND } from './native-programmatic.js'; import { + BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_FILES, isWorkspaceToolRequest, isWorkspaceToolResult, @@ -29,6 +30,13 @@ export type NativeProcessSandboxOptions = Omit< }; const execFileAsync = promisify(execFile); +const PROGRAMMATIC_STAGING_TIMEOUT_MS = 60_000; +const PROGRAMMATIC_TRANSFER_TIMEOUT_MS = 30_000; +const RPC_SETTLEMENT_SLACK_MS = 5_000; + +type RpcTimeoutBudget = + | number + | { stagingMs: number; commitMs: number }; async function systemProgrammaticExecutable( name: string, @@ -183,11 +191,16 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan private closing?: Promise; private failed = false; private terminationTimer?: ReturnType; + private programmaticExecutables?: Promise<{ + shellPath: string; + jqPath: string; + }>; private pending?: { id: string; resolve(value: unknown): void; reject(error: Error): void; mutation: boolean; + commit?(): void; }; constructor( @@ -199,6 +212,14 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan ) => ChildProcess = fork, ) {} + /** Overridable only for deterministic watchdog tests. */ + protected scheduleRpcTimeout( + callback: () => void, + timeoutMs: number, + ): ReturnType { + return setTimeout(callback, timeoutMs); + } + async prepare(): Promise { if (this.failed || this.closing) throw this.unavailable(false); if (this.ready) return this.ready; @@ -210,10 +231,22 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan return new NativeExecutorUnavailableError(mutation); } + private async resolveProgrammaticExecutables(): Promise<{ + shellPath: string; + jqPath: string; + }> { + this.programmaticExecutables ??= resolveProgrammaticShell(this.options); + try { + return await this.programmaticExecutables; + } catch (error) { + // An operator may install or repair this optional dependency while the + // worker stays online. Keep ordinary execution live and let PTC retry. + this.programmaticExecutables = undefined; + throw error; + } + } + private async start(): Promise { - const programmaticExecutables = this.options.programmaticFileUpstream - ? await resolveProgrammaticShell(this.options) - : undefined; const child = this.forkExecutor( new URL('./native-process-child.js', import.meta.url), [], @@ -237,6 +270,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan code?: unknown; errorMessage?: unknown; fatal?: unknown; + phase?: unknown; }; if ( !message || @@ -246,6 +280,25 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan return; const pending = this.pending; if (!pending) return; + if (message.phase === 'commit') { + pending.mutation = true; + const commit = pending.commit; + pending.commit = undefined; + commit?.(); + try { + child.send({ type: 'commit-ack', id: pending.id }, error => { + if (!error) return; + this.failed = true; + this.terminate(); + pending.reject(this.unavailable(true)); + }); + } catch { + this.failed = true; + this.terminate(); + pending.reject(this.unavailable(true)); + } + return; + } if (message.fatal === true) this.failed = true; if (message.ok === true) pending.resolve(message.result); else { @@ -299,8 +352,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan protectedPaths, allowedDomains, homeDirectory, - shellPath: programmaticExecutables?.shellPath ?? shellPath, - jqPath: programmaticExecutables?.jqPath, + shellPath, programmaticFileUpstream, variables: this.options.maskedEnvironment?.variables, }, @@ -376,9 +428,12 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan ); let credentials: Record | undefined; let wrappedCommand: string | undefined; + let programmaticExecutables: { shellPath: string; jqPath: string }; try { await this.prepare(); if (signal?.aborted) throw new Error('aborted'); + programmaticExecutables = await this.resolveProgrammaticExecutables(); + if (signal?.aborted) throw new Error('aborted'); credentials = await this.options.maskedEnvironment?.resolve(signal); if (signal?.aborted) throw new Error('aborted'); wrappedCommand = this.options.maskedEnvironment?.wrapCommand?.( @@ -407,19 +462,11 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan workspaceId, credentials, wrappedCommand, + programmaticShellPath: programmaticExecutables.shellPath, + programmaticJqPath: programmaticExecutables.jqPath, }, - (request.body.run_timeout ?? 30_000) * - ((request.body.replay_tool_count ?? 0) > 0 ? 2 : 1) + - (Math.ceil( - request.body.files.filter(file => 'id' in file).length / 4, - ) + - Math.ceil( - (request.body.max_output_files ?? - BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_FILES) / 4, - )) * - (request.body.transfer_timeout_ms ?? 30_000) + - 5_000, - true, + this.programmaticWatchdogBudget(request), + false, signal, ); if (signal?.aborted) { @@ -437,6 +484,35 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan return result; } + private programmaticWatchdogBudget( + request: BridgeWorkspaceProgrammaticRequest, + ): Exclude { + const runTimeoutMs = Math.min( + request.body.run_timeout ?? BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, + BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, + ); + const transferTimeoutMs = + request.body.transfer_timeout_ms ?? PROGRAMMATIC_TRANSFER_TIMEOUT_MS; + const inputBatches = Math.ceil( + request.body.files.filter(file => 'id' in file).length / 4, + ); + const outputBatches = Math.ceil( + (request.body.max_output_files ?? + BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_FILES) / 4, + ); + return { + stagingMs: + PROGRAMMATIC_STAGING_TIMEOUT_MS + + inputBatches * transferTimeoutMs + + ((request.body.replay_tool_count ?? 0) > 0 ? runTimeoutMs : 0) + + RPC_SETTLEMENT_SLACK_MS, + commitMs: + runTimeoutMs + + outputBatches * transferTimeoutMs + + RPC_SETTLEMENT_SLACK_MS, + }; + } + private async executeOnce( request: WorkspaceExecuteCommandRequest, signal?: AbortSignal, @@ -498,7 +574,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan private async rpc( type: string, payload: object, - timeoutMs: number, + timeout: RpcTimeoutBudget, mutation: boolean, signal?: AbortSignal, ): Promise { @@ -506,7 +582,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan throw this.unavailable(false); const id = randomUUID(); const child = this.child; - let timer: ReturnType; + let timer: ReturnType | undefined; const abort = () => { try { if (child.connected) @@ -518,12 +594,24 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan }; try { return await new Promise((resolve, reject) => { - this.pending = { id, resolve, reject, mutation }; - timer = setTimeout(() => { - this.failed = true; - this.terminate(); - reject(this.unavailable(mutation)); - }, timeoutMs); + const schedule = (timeoutMs: number): void => { + if (timer) clearTimeout(timer); + timer = this.scheduleRpcTimeout(() => { + this.failed = true; + this.terminate(); + reject(this.unavailable(this.pending?.mutation ?? mutation)); + }, timeoutMs); + }; + this.pending = { + id, + resolve, + reject, + mutation, + ...(typeof timeout === 'number' + ? {} + : { commit: () => schedule(timeout.commitMs) }), + }; + schedule(typeof timeout === 'number' ? timeout : timeout.stagingMs); signal?.addEventListener('abort', abort, { once: true }); const sendFailed = () => { this.failed = true; @@ -540,7 +628,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan if (signal?.aborted) abort(); }); } finally { - clearTimeout(timer!); + if (timer) clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending = undefined; } diff --git a/packages/code/src/native-programmatic.ts b/packages/code/src/native-programmatic.ts index bd2700f2..9b6eea9e 100644 --- a/packages/code/src/native-programmatic.ts +++ b/packages/code/src/native-programmatic.ts @@ -216,6 +216,8 @@ export interface NativeWorkspaceProgrammaticOptions { Pick >; upstreamUrl: string; + shellPath?: string; + jqPath?: string; fetchImpl?: typeof fetch; } @@ -362,6 +364,7 @@ export class NativeWorkspaceProgrammaticExecutor { request: BridgeWorkspaceProgrammaticRequest, workspaceId: string, signal?: AbortSignal, + lifecycle?: { beforeCommit?(): Promise | void }, ): Promise { if (!isBridgeWorkspaceProgrammaticRequest(request)) { throw new WorkspaceToolError( @@ -456,7 +459,10 @@ export class NativeWorkspaceProgrammaticExecutor { errorOnExist: true, mode: constants.COPYFILE_FICLONE, }); - if (!probe) commandDispatched = true; + if (!probe) { + await lifecycle?.beforeCommit?.(); + commandDispatched = true; + } return await this.options.sandbox.executeProgrammatic( { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -473,7 +479,12 @@ export class NativeWorkspaceProgrammaticExecutor { }, directory, signal, - { probe, workspaceRoot }, + { + probe, + workspaceRoot, + shellPath: this.options.shellPath, + jqPath: this.options.jqPath, + }, ); }; diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 91e9832d..550d0d52 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -661,7 +661,12 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox request: WorkspaceExecuteCommandRequest, dataDirectory: string, signal?: AbortSignal, - options?: { probe?: boolean; workspaceRoot?: string }, + options?: { + probe?: boolean; + workspaceRoot?: string; + shellPath?: string; + jqPath?: string; + }, ): Promise { if (this.execution || this.closing) { throw new WorkspaceToolError( @@ -703,9 +708,13 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox canonicalDataDirectory, '_ptc_pending_result.json', ), - LIBRECHAT_CODE_BASH_PATH: this.options.shellPath ?? '/bin/bash', - ...(this.options.jqPath - ? { LIBRECHAT_CODE_JQ_PATH: this.options.jqPath } + LIBRECHAT_CODE_BASH_PATH: + options?.shellPath ?? this.options.shellPath ?? '/bin/bash', + ...((options?.jqPath ?? this.options.jqPath) + ? { + LIBRECHAT_CODE_JQ_PATH: + options?.jqPath ?? this.options.jqPath, + } : {}), PTC_HISTORY_PATH: join( canonicalDataDirectory,