fix(ptc): preserve tool execution identity during cancellation
This commit is contained in:
1 parent
e3a92c9de5
commit
6a6105dc1b
6 files changed
+26
-21
No files matched your search
@@ -369,7 +369,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
const runtime = requireRuntime()
|
||||
const admission = stateOwners.enter(exec.agent, runtime, exec.signal, args.sandbox_permissions !== undefined,
|
||||
() => runtime.sandboxMode === undefined ? undefined : options.resolveSandboxPolicy(exec))
|
||||
exec = { ...exec, signal: admission.signal }
|
||||
const callSignal = admission.signal
|
||||
try {
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
if (args.timeoutMs !== undefined && runtime.timeout === undefined) {
|
||||
@@ -389,20 +389,20 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
subject: 'program',
|
||||
}, {
|
||||
approver: options.peekApprover(), agent: exec.agent, callId: exec.callId,
|
||||
toolName: RUN_CODE_NAME, signal: exec.signal,
|
||||
toolName: RUN_CODE_NAME, signal: callSignal,
|
||||
})
|
||||
policy = { ...standingPolicy, mode: approvedMode }
|
||||
}
|
||||
const cwd = await options.resolveWorkingDirectory(exec)
|
||||
exec.signal.throwIfAborted()
|
||||
const cwd = await options.resolveWorkingDirectory({ ...exec, signal: callSignal })
|
||||
callSignal.throwIfAborted()
|
||||
|
||||
// The run-scoped abort: follows the outer signal in, and fires when the
|
||||
// run settles for ANY reason, so an in-flight sub-dispatch is aborted
|
||||
// (its executor kills on this signal) instead of orphaned, and
|
||||
// queued-unstarted dispatches are abandoned.
|
||||
const runController = new AbortController()
|
||||
const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
|
||||
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
|
||||
const onOuterAbort = (): void => { runController.abort(callSignal.reason) }
|
||||
callSignal.addEventListener('abort', onOuterAbort, { once: true })
|
||||
|
||||
let dispatches = 0
|
||||
// The per-run scheduler uses the registry's staged interface and follows
|
||||
@@ -719,7 +719,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
...result.value !== undefined ? { result: result.value } : {},
|
||||
}
|
||||
} finally {
|
||||
exec.signal.removeEventListener('abort', onOuterAbort)
|
||||
callSignal.removeEventListener('abort', onOuterAbort)
|
||||
}
|
||||
} finally { await admission.finish() }
|
||||
},
|
||||
|
||||
@@ -26,7 +26,11 @@ async function setup() {
|
||||
await ctx.plugin(SandboxPolicy, { mode: 'workspace-write' })
|
||||
await ctx.plugin(Approval)
|
||||
ctx.provide('shell', { sandboxMode: 'workspace-write' })
|
||||
await ctx.plugin(PermissionPresets, {})
|
||||
await ctx.plugin(PermissionPresets, { presets: {
|
||||
'read-only': { sandbox: 'read-only', approval: 'ask' },
|
||||
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
|
||||
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
|
||||
} })
|
||||
await ctx.plugin(Runtime)
|
||||
const session = ctx.sessions.create(SessionId('ptc-permission-owner'))
|
||||
const agent = { ctx, session } as Agent
|
||||
|
||||
@@ -37,7 +37,7 @@ interface Evaluation {
|
||||
|
||||
/** One managed process; all mutable program state belongs to this process alone. */
|
||||
class Kernel {
|
||||
private confined?: ConfinedArgv
|
||||
private confined: ConfinedArgv | undefined
|
||||
private handle?: SubprocessHandle
|
||||
private channel?: JsonChannel
|
||||
private active: Evaluation | undefined
|
||||
|
||||
@@ -187,11 +187,11 @@ describe('persistent PTC host protocol', () => {
|
||||
h.stderr.write('stderr')
|
||||
h.emit(frame({ type: 'done' }))
|
||||
})
|
||||
expect(await h.start()).toEqual({ logs: ['stdout', 'stderr'] })
|
||||
expect(await h.start()).toMatchObject({ logs: ['stdout', 'stderr'] })
|
||||
h.stdout.write('idle stdout')
|
||||
h.stderr.write('idle stderr')
|
||||
h.onBoot(() => { h.emit(frame({ type: 'done' }, 2)) })
|
||||
expect(await h.start(request, NO_INITIAL_FRAME)).toEqual({ logs: [] })
|
||||
expect(await h.start(request, NO_INITIAL_FRAME)).toMatchObject({ logs: [] })
|
||||
expect(h.terminate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('persistent PTC host protocol', () => {
|
||||
])('bounds cleanup failure after parse diagnostics overflow a $maxOutputBytes-byte budget', async ({ maxOutputBytes, kind }) => {
|
||||
const h = await setup({ maxOutputBytes })
|
||||
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
|
||||
expect(await h.start()).toEqual({ logs: [] })
|
||||
expect(await h.start()).toMatchObject({ logs: [] })
|
||||
h.waitForExit.mockRejectedValue(new Error('x'))
|
||||
const result = await h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' }))
|
||||
expect(result.error?.kind).toBe(kind)
|
||||
@@ -293,7 +293,7 @@ describe('persistent PTC host protocol', () => {
|
||||
expect(bytes).toBeLessThanOrEqual(maxOutputBytes)
|
||||
expect(h.terminate).toHaveBeenCalledTimes(1)
|
||||
expect(h.waitForExit).toHaveBeenCalledTimes(1)
|
||||
await h.runtime.release(request.stateOwner!)
|
||||
await expect(h.runtime.release(request.stateOwner!)).rejects.toThrow('x')
|
||||
})
|
||||
|
||||
it('rejects invalid resolved inputs and deployment bounds', async () => {
|
||||
@@ -397,7 +397,7 @@ it('logs an idle process failure with its bounded stderr tail', async () => {
|
||||
} finally { reported.mockRestore() }
|
||||
})
|
||||
|
||||
it('reports a release failure once and removes the failed environment from provider cleanup', async () => {
|
||||
it('keeps an owner unavailable after release cleanup fails', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
|
||||
await h.start()
|
||||
@@ -406,9 +406,10 @@ it('reports a release failure once and removes the failed environment from provi
|
||||
await expect(h.runtime.release(request.stateOwner!)).rejects.toBe(failure)
|
||||
const reported = vi.spyOn(h.fiber.ctx.logger, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
await h.runtime.release(request.stateOwner!)
|
||||
await expect(h.runtime.release(request.stateOwner!)).rejects.toBe(failure)
|
||||
await expect(h.runtime.run(h.runtime.resolve(request))).rejects.toThrow('concurrent runs')
|
||||
await h.fiber.dispose()
|
||||
expect(reported).not.toHaveBeenCalled()
|
||||
expect(reported).toHaveBeenCalled()
|
||||
expect(h.waitForExit).toHaveBeenCalledOnce()
|
||||
} finally { reported.mockRestore() }
|
||||
})
|
||||
|
||||
@@ -20,9 +20,9 @@ async function setup(mode: 'read-only' | 'workspace-write' = 'read-only') {
|
||||
|
||||
it('retains variables under read-only confinement and denies writes across calls', async () => {
|
||||
const { cwd, run } = await setup()
|
||||
expect((await run('var fs = await import("node:fs/promises"); var count = 1;')).error).toBeUndefined()
|
||||
expect((await run('const fsp = await import("node:fs/promises"); var count = 1;')).error).toBeUndefined()
|
||||
expect((await run('++count')).value).toBe(2)
|
||||
const result = await run('await fs.writeFile("denied.txt", String(count))')
|
||||
const result = await run('await fsp.writeFile("denied.txt", String(count))')
|
||||
expect(result.sandbox).toMatchObject({ mode: 'read-only', denied: true })
|
||||
await expect(readFile(join(cwd, 'denied.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect((await run('count')).value).toBe(2)
|
||||
@@ -30,8 +30,8 @@ it('retains variables under read-only confinement and denies writes across calls
|
||||
|
||||
it('retains workspace write access while refusing a sibling path', async () => {
|
||||
const { root, cwd, run } = await setup('workspace-write')
|
||||
expect((await run('var fs = await import("node:fs/promises"); await fs.writeFile("inside.txt", "allowed")')).error).toBeUndefined()
|
||||
const result = await run(`await fs.writeFile(${JSON.stringify(join(root, 'outside.txt'))}, "denied")`)
|
||||
expect((await run('const fsp = await import("node:fs/promises"); await fsp.writeFile("inside.txt", "allowed")')).error).toBeUndefined()
|
||||
const result = await run(`await fsp.writeFile(${JSON.stringify(join(root, 'outside.txt'))}, "denied")`)
|
||||
expect(result.sandbox).toMatchObject({ mode: 'workspace-write', denied: true })
|
||||
expect(await readFile(join(cwd, 'inside.txt'), 'utf8')).toBe('allowed')
|
||||
await expect(readFile(join(root, 'outside.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => {
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBe('stub reply')
|
||||
expect(execute.mock.calls[0]?.[0].stateOwner).toBeUndefined()
|
||||
expect(execute.mock.calls[0]?.[0].sandboxPolicy).toBeUndefined()
|
||||
expect(execute.mock.calls[0]?.[0].sandboxPolicy?.mode).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
it('captures args at start and isolates subsequent caller and script mutations', async () => {
|
||||
|
||||
Reference in new issue
Block a user