diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7800023..2d95b6a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ to docs, or any other relevant information. ## [Unreleased] +### Added + +- **Experimental** The `@temporalio/openai-agents` plugin now supports running OpenAI Agents `SandboxAgent`s as Temporal Workflows; sandbox operations are dispatched as Activities. + ## [1.21.0] - 2026-07-23 ### Added diff --git a/contrib/openai-agents/package.json b/contrib/openai-agents/package.json index 365944f9b..c5b44d77f 100644 --- a/contrib/openai-agents/package.json +++ b/contrib/openai-agents/package.json @@ -80,8 +80,8 @@ "web-streams-polyfill": "^4.2.0" }, "peerDependencies": { - "@openai/agents-core": "~0.11.6", - "@openai/agents-openai": "~0.11.6", + "@openai/agents-core": "~0.13.5", + "@openai/agents-openai": "~0.13.5", "@opentelemetry/sdk-trace-base": "^1.25.1", "openai": "^6.35.0" }, @@ -91,8 +91,8 @@ } }, "devDependencies": { - "@openai/agents-core": "~0.11.6", - "@openai/agents-openai": "~0.11.6", + "@openai/agents-core": "~0.13.5", + "@openai/agents-openai": "~0.13.5", "@opentelemetry/core": "^1.25.1", "@opentelemetry/sdk-node": "^0.52.1", "@opentelemetry/sdk-trace-base": "^1.25.1", diff --git a/contrib/openai-agents/src/__tests__/helpers/openai-agents-spans.ts b/contrib/openai-agents/src/__tests__/helpers/openai-agents-spans.ts index f5037d656..ee8bb1a52 100644 --- a/contrib/openai-agents/src/__tests__/helpers/openai-agents-spans.ts +++ b/contrib/openai-agents/src/__tests__/helpers/openai-agents-spans.ts @@ -99,6 +99,7 @@ interface SpanRecord { parentId: string | undefined; name: string; traceId: string; + data?: Record; } // -------------------------------------------------------------------------- @@ -203,7 +204,7 @@ export interface AgentSdkSpan { parentId?: string; traceId: string; /** Agent SDK exposes name via `spanData.type` plus a discriminant field. */ - spanData: { type: string; name?: string; from_agent?: string; to_agent?: string }; + spanData: { type: string; name?: string; from_agent?: string; to_agent?: string; data?: Record }; } export interface AgentSdkTrace { @@ -259,17 +260,19 @@ export class AgentSdkSpanCollector { parentId, name: incomingName, traceId: span.traceId, + data: span.spanData.data, }; this.byId.set(span.spanId, record); this.records.push(record); } onSpanEnd(span: AgentSdkSpan): void { - // Re-derive the name at end. Some span types (notably `handoff`) - // populate fields like `to_agent` AFTER `start()` fires, so the - // start-time name reads `unknown`. Refresh in-place. + // Some span fields/data are populated after `start()`, so refresh name+data at end. const record = this.byId.get(span.spanId); - if (record) record.name = nameForAgentSdkSpan(span); + if (record) { + record.name = nameForAgentSdkSpan(span); + record.data = span.spanData.data; + } } forceFlush(): Promise { diff --git a/contrib/openai-agents/src/__tests__/openai-sandbox.test.ts b/contrib/openai-agents/src/__tests__/openai-sandbox.test.ts new file mode 100644 index 000000000..6ee9ea100 --- /dev/null +++ b/contrib/openai-agents/src/__tests__/openai-sandbox.test.ts @@ -0,0 +1,874 @@ +import test from 'ava'; +import { Agent, handoff } from '@openai/agents-core'; +import { + Manifest, + SandboxAgent, + SandboxError, + type SandboxExecResult, + type SandboxSession, + type SandboxSessionState, +} from '@openai/agents-core/sandbox'; +import { deserializeManifest, serializeManifestRecord } from '@openai/agents-core/sandbox/internal'; +import { ApplicationFailure } from '@temporalio/common'; +import { + SANDBOX_CLIENT_CREATE_SUFFIX, + SANDBOX_CLIENT_DELETE_SUFFIX, + SANDBOX_CLIENT_RESUME_SUFFIX, + SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX, + SANDBOX_EDITOR_CREATE_FILE_SUFFIX, + SANDBOX_EDITOR_DELETE_FILE_SUFFIX, + SANDBOX_EDITOR_UPDATE_FILE_SUFFIX, + SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX, + SANDBOX_SESSION_DELETE_SUFFIX, + SANDBOX_SESSION_EXEC_COMMAND_SUFFIX, + SANDBOX_SESSION_EXEC_SUFFIX, + SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX, + SANDBOX_SESSION_LIST_DIR_SUFFIX, + SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX, + SANDBOX_SESSION_PATH_EXISTS_SUFFIX, + SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX, + SANDBOX_SESSION_READ_FILE_SUFFIX, + SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX, + SANDBOX_SESSION_RUNNING_SUFFIX, + SANDBOX_SESSION_SHUTDOWN_SUFFIX, + SANDBOX_SESSION_START_SUFFIX, + SANDBOX_SESSION_STOP_SUFFIX, + SANDBOX_SESSION_VIEW_IMAGE_SUFFIX, + SANDBOX_SESSION_WRITE_STDIN_SUFFIX, + base64ToBytes, + bytesToBase64, + decodeManifest, + decodeToolOutputImage, + encodeManifest, + reviveWorkflowSessionState, + serializeSessionEnvelope, + type SandboxSessionResult, + type SerializedSandboxSessionState, + type SerializedToolOutputImage, +} from '../common/sandbox-activity-types'; +import { SandboxClientProvider } from '../worker/sandbox-provider'; +import { TemporalSandboxClient, temporalSandboxClient } from '../workflow/sandbox-client'; +import { TemporalSandboxSession } from '../workflow/sandbox-session'; +import { hasSandboxAgent, validateSandboxRunConfig } from '../workflow/runner'; +import { FakeSandboxClient, FakeSandboxSession } from './stubs/sandbox-fakes'; + +function activityMap(provider: SandboxClientProvider): Record Promise> { + return provider._getActivities(); +} + +async function createSession( + acts: Record Promise>, + name = 'fake' +): Promise { + return acts[`${name}${SANDBOX_CLIENT_CREATE_SUFFIX}`]!({}); +} + +// ── hasSandboxAgent ── + +test('hasSandboxAgent: regular agent', (t) => { + t.false(hasSandboxAgent(new Agent({ name: 'regular' }))); +}); + +test('hasSandboxAgent: sandbox starting agent', (t) => { + t.true(hasSandboxAgent(new SandboxAgent({ name: 'sandbox' }))); +}); + +test('hasSandboxAgent: sandbox via direct handoff', (t) => { + const sandbox = new SandboxAgent({ name: 'sandbox' }); + t.true(hasSandboxAgent(new Agent({ name: 'regular', handoffs: [sandbox] }))); +}); + +test('hasSandboxAgent: sandbox via deep handoff', (t) => { + const sandbox = new SandboxAgent({ name: 'sandbox' }); + const middle = new Agent({ name: 'middle', handoffs: [sandbox] }); + t.true(hasSandboxAgent(new Agent({ name: 'top', handoffs: [middle] }))); +}); + +test('hasSandboxAgent: sandbox wrapped in Handoff', (t) => { + const sandbox = new SandboxAgent({ name: 'sandbox' }); + t.true(hasSandboxAgent(new Agent({ name: 'regular', handoffs: [handoff(sandbox)] }))); +}); + +test('hasSandboxAgent: no sandbox in chain', (t) => { + const c = new Agent({ name: 'c' }); + const b = new Agent({ name: 'b', handoffs: [c] }); + t.false(hasSandboxAgent(new Agent({ name: 'a', handoffs: [b] }))); +}); + +test('hasSandboxAgent: circular without sandbox', (t) => { + const a = new Agent({ name: 'a' }); + const b = new Agent({ name: 'b', handoffs: [a] }); + a.handoffs = [b]; + t.false(hasSandboxAgent(a)); +}); + +test('hasSandboxAgent: circular with sandbox', (t) => { + const sandbox = new SandboxAgent({ name: 'sandbox' }); + const a = new Agent({ name: 'a', handoffs: [sandbox] }); + const b = new Agent({ name: 'b', handoffs: [a] }); + a.handoffs = [b, sandbox]; + t.true(hasSandboxAgent(b)); +}); + +// ── temporalSandboxClient factory ── + +test('temporalSandboxClient returns a TemporalSandboxClient with backendId = name', (t) => { + const client = temporalSandboxClient('my-backend'); + t.true(client instanceof TemporalSandboxClient); + t.is(client.backendId, 'my-backend'); +}); + +test('temporalSandboxClient honors activity config overrides', (t) => { + const client = temporalSandboxClient('my-backend', { startToCloseTimeout: '10 minutes' }); + t.is((client as any)._config.startToCloseTimeout, '10 minutes'); +}); + +// ── validateSandboxRunConfig ── + +test('validation: regular agent without sandbox config passes', (t) => { + t.notThrows(() => validateSandboxRunConfig(new Agent({ name: 'regular' }), undefined)); +}); + +test('validation: sandbox agent without sandbox config fails', (t) => { + const err = t.throws(() => validateSandboxRunConfig(new SandboxAgent({ name: 'sandbox' }), undefined), { + instanceOf: ApplicationFailure, + }); + t.is(err?.type, 'SandboxConfigurationError'); + t.regex(err!.message, /runConfig\.sandbox is not configured/); +}); + +test('validation: handoff-reachable sandbox agent without sandbox config fails', (t) => { + const sandbox = new SandboxAgent({ name: 'sandbox' }); + const router = new Agent({ name: 'router', handoffs: [sandbox] }); + t.throws(() => validateSandboxRunConfig(router, undefined), { instanceOf: ApplicationFailure }); +}); + +test('validation: sandbox config without client fails', (t) => { + const err = t.throws(() => validateSandboxRunConfig(new SandboxAgent({ name: 'sandbox' }), {}), { + instanceOf: ApplicationFailure, + }); + t.regex(err!.message, /runConfig\.sandbox\.client must be set/); +}); + +test('validation: raw sandbox client fails even without a sandbox agent', (t) => { + const err = t.throws( + () => validateSandboxRunConfig(new Agent({ name: 'regular' }), { client: new FakeSandboxClient() }), + { instanceOf: ApplicationFailure } + ); + t.regex(err!.message, /Do not pass a raw sandbox client directly/); +}); + +test('validation: temporal sandbox client passes', (t) => { + t.notThrows(() => + validateSandboxRunConfig(new SandboxAgent({ name: 'sandbox' }), { client: temporalSandboxClient('fake') }) + ); +}); + +// ── Provider activity registration ── + +const sandboxActivitySuffixes = [ + SANDBOX_CLIENT_CREATE_SUFFIX, + SANDBOX_CLIENT_RESUME_SUFFIX, + SANDBOX_CLIENT_DELETE_SUFFIX, + SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX, + SANDBOX_SESSION_START_SUFFIX, + SANDBOX_SESSION_RUNNING_SUFFIX, + SANDBOX_SESSION_STOP_SUFFIX, + SANDBOX_SESSION_SHUTDOWN_SUFFIX, + SANDBOX_SESSION_DELETE_SUFFIX, + SANDBOX_SESSION_EXEC_SUFFIX, + SANDBOX_SESSION_EXEC_COMMAND_SUFFIX, + SANDBOX_SESSION_WRITE_STDIN_SUFFIX, + SANDBOX_SESSION_VIEW_IMAGE_SUFFIX, + SANDBOX_SESSION_READ_FILE_SUFFIX, + SANDBOX_SESSION_LIST_DIR_SUFFIX, + SANDBOX_SESSION_PATH_EXISTS_SUFFIX, + SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX, + SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX, + SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX, + SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX, + SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX, + SANDBOX_EDITOR_CREATE_FILE_SUFFIX, + SANDBOX_EDITOR_UPDATE_FILE_SUFFIX, + SANDBOX_EDITOR_DELETE_FILE_SUFFIX, +]; + +test('provider registers all prefixed activities', (t) => { + const provider = new SandboxClientProvider('fake', new FakeSandboxClient()); + const names = Object.keys(provider._getActivities()); + t.deepEqual(new Set(names), new Set(sandboxActivitySuffixes.map((suffix) => `fake${suffix}`))); +}); + +test('multiple providers register disjoint activity sets', (t) => { + const names1 = new Set(Object.keys(new SandboxClientProvider('daytona', new FakeSandboxClient())._getActivities())); + const names2 = new Set(Object.keys(new SandboxClientProvider('local', new FakeSandboxClient())._getActivities())); + t.is(names1.size, sandboxActivitySuffixes.length); + t.is(names2.size, sandboxActivitySuffixes.length); + for (const name of names1) { + t.false(names2.has(name)); + t.true(name.startsWith('daytona-sandbox-')); + } +}); + +// ── Provider delegation ── + +test('create delegates to the real client and returns a serializable state handle', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + + const manifest = new Manifest({ environment: { FOO: 'bar' } }); + const result: SandboxSessionResult = await acts[`fake${SANDBOX_CLIENT_CREATE_SUFFIX}`]!({ + manifest: encodeManifest(manifest), + }); + + t.is(client.createCalls.length, 1); + t.true(client.createCalls[0]!.manifest instanceof Manifest); + t.is(typeof result.state.sessionId, 'string'); + t.is(decodeManifest(result.state.manifest).environment.FOO!.value, 'bar'); + t.deepEqual(result.state.providerState, { workspacePath: '/tmp/fake-workspace', environment: { FOO: 'bar' } }); + t.false(result.supportsPty); +}); + +test('resume delegates to the real client', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const result: SandboxSessionResult = await acts[`fake-sandbox-client-resume`]!({ state }); + t.is(client.resumeCalls, 1); + t.is(result.state.sessionId, state.sessionId); +}); + +test('ephemeral mount and env reach the backend on create; only the mount survives resume', async (t) => { + const manifest = new Manifest({ + entries: { 'secret-mount': { type: 'mount', source: '/host/secret', mountPath: '/secret' } }, + environment: { SECRET: { value: 'topsecret', ephemeral: true } }, + }); + + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await acts[`fake${SANDBOX_CLIENT_CREATE_SUFFIX}`]!({ manifest: encodeManifest(manifest) }); + + // Both reach the backend on create; the ephemeral env is resolved into the session. + t.true('secret-mount' in (client.createCalls[0]!.manifest as Manifest).entries); + t.is(client.session.state.environment!.SECRET, 'topsecret'); + + // Fresh provider (empty cache, new backend instance) resuming from the handle. + const freshClient = new FakeSandboxClient(); + const freshActs = activityMap(new SandboxClientProvider('fake', freshClient)); + await freshActs[`fake${SANDBOX_CLIENT_RESUME_SUFFIX}`]!({ state }); + + // The mount is re-materialized from the manifest; ephemeral env is create-only because the SDK does not persist ephemeral env. + t.true('secret-mount' in freshClient.session.state.manifest.entries); + t.is(freshClient.session.state.environment?.SECRET, undefined); +}); + +test('exec delegates to the real session', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const result: SandboxExecResult = await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ + state, + args: { cmd: 'echo hello' }, + }); + t.is(result.stdout, 'ok'); + t.is(result.exitCode, 0); + t.is(client.session.execCalls.length, 1); + t.is(client.session.execCalls[0]!.cmd, 'echo hello'); +}); + +test('execCommand, writeStdin, readFile, listDir, and pathExists delegate', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + t.is(await acts[`fake${SANDBOX_SESSION_EXEC_COMMAND_SUFFIX}`]!({ state, args: { cmd: 'ls' } }), 'ran:ls'); + t.is( + await acts[`fake${SANDBOX_SESSION_WRITE_STDIN_SUFFIX}`]!({ state, args: { sessionId: 1, chars: 'y' } }), + 'stdin:y' + ); + t.deepEqual( + await acts[`fake${SANDBOX_SESSION_READ_FILE_SUFFIX}`]!({ state, args: { path: '/workspace/f' } }), + new TextEncoder().encode('file-content') + ); + const entries = await acts[`fake${SANDBOX_SESSION_LIST_DIR_SUFFIX}`]!({ state, args: { path: '/workspace' } }); + t.is(entries[0].name, 'file.txt'); + t.true(await acts[`fake${SANDBOX_SESSION_PATH_EXISTS_SUFFIX}`]!({ state, path: '/workspace/f' })); +}); + +test('materializeEntry decodes binary file content back to bytes', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + const content = new Uint8Array([0, 1, 254, 255]); + + await acts[`fake${SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX}`]!({ + state, + path: 'bin/blob', + entry: { type: 'file', content: { type: 'base64', data: bytesToBase64(content) } }, + }); + + const materialized = client.session.materializedEntries[0]!; + t.is(materialized.path, 'bin/blob'); + t.deepEqual((materialized.entry as { content: Uint8Array }).content, content); +}); + +test('applyManifest delegates with a decoded Manifest', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + await acts[`fake${SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX}`]!({ + state, + manifest: encodeManifest(new Manifest({ entries: { 'a.txt': { type: 'file', content: 'hi' } } })), + }); + + t.is(client.session.appliedManifests.length, 1); + t.true(client.session.appliedManifests[0] instanceof Manifest); + t.deepEqual(Object.keys(client.session.appliedManifests[0]!.entries), ['a.txt']); +}); + +test('applyManifest falls back to materializeEntry when the real session lacks it', async (t) => { + const session = new FakeSandboxSession(); + (session as { applyManifest?: unknown }).applyManifest = undefined; + const client = new FakeSandboxClient(session); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + await acts[`fake${SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX}`]!({ + state, + manifest: encodeManifest(new Manifest({ entries: { 'a.txt': { type: 'file', content: 'hi' } } })), + }); + + t.is(session.materializedEntries.length, 1); + t.is(session.materializedEntries[0]!.path, 'a.txt'); +}); + +test('applyManifest returns the backend-merged manifest', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const updated = await acts[`fake${SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX}`]!({ + state, + manifest: encodeManifest(new Manifest({ entries: { 'added.txt': { type: 'file', content: 'added' } } })), + }); + + t.true('added.txt' in decodeManifest(updated).entries); +}); + +test('materializeEntry returns the backend-merged manifest', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const updated = await acts[`fake${SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX}`]!({ + state, + path: 'made.txt', + entry: { type: 'file', content: { type: 'base64', data: bytesToBase64(new TextEncoder().encode('x')) } }, + }); + + t.true('made.txt' in decodeManifest(updated).entries); +}); + +test('persistWorkspace applies archive limits and returns bytes', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const data = await acts[`fake${SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX}`]!({ + state, + archiveLimits: { maxInputBytes: 1024 }, + }); + t.deepEqual(data, new Uint8Array([10, 20, 30])); + t.deepEqual(client.session.archiveLimits, { maxInputBytes: 1024 }); +}); + +test('hydrateWorkspace forwards archive bytes and options', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + const archive = new Uint8Array([9, 8, 7]); + + await acts[`fake${SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX}`]!({ state, archiveLimits: { maxMembers: 5 } }, archive); + + t.deepEqual(client.session.hydrateCalls[0]!.data, archive); + t.deepEqual(client.session.hydrateCalls[0]!.archiveLimits, { maxMembers: 5 }); +}); + +test('viewImage base64-encodes binary image data for transport', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const serialized: SerializedToolOutputImage = await acts[`fake${SANDBOX_SESSION_VIEW_IMAGE_SUFFIX}`]!({ + state, + args: { path: '/workspace/img.png' }, + }); + t.true(serialized.imageDataBase64); + t.is(typeof (serialized.output.image as { data: string }).data, 'string'); + + const decoded = decodeToolOutputImage(serialized); + t.deepEqual((decoded.image as { data: Uint8Array }).data, new Uint8Array([0x89, 0x50, 0x4e, 0x47])); + t.is((decoded.image as { mediaType: string }).mediaType, 'image/png'); +}); + +test('editor activities delegate to the real session editor', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const result = await acts[`fake${SANDBOX_EDITOR_UPDATE_FILE_SUFFIX}`]!({ + state, + operation: { type: 'update_file', path: 'a.txt', diff: '' }, + }); + t.deepEqual(result, { status: 'completed', output: 'update' }); + t.deepEqual(client.session.editorOperations, ['update:a.txt']); +}); + +test('lifecycle activities delegate; session delete evicts the cache', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + await acts[`fake${SANDBOX_SESSION_START_SUFFIX}`]!({ state }); + t.true(await acts[`fake${SANDBOX_SESSION_RUNNING_SUFFIX}`]!({ state })); + await acts[`fake${SANDBOX_SESSION_STOP_SUFFIX}`]!({ state }); + await acts[`fake${SANDBOX_SESSION_SHUTDOWN_SUFFIX}`]!({ state }); + await acts[`fake${SANDBOX_SESSION_DELETE_SUFFIX}`]!({ state }); + + t.is(client.session.startCalls, 1); + t.is(client.session.stopCalls, 1); + t.is(client.session.shutdownCalls, 1); + t.is(client.session.deleteCalls, 1); + + // Cache was evicted at delete — the next operation self-heals via resume. + t.is(client.resumeCalls, 0); + await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'x' } }); + t.is(client.resumeCalls, 1); +}); + +test('client delete delegates and evicts the cache', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + await acts[`fake${SANDBOX_CLIENT_DELETE_SUFFIX}`]!({ state }); + t.is(client.deleteCalls, 1); + + t.is(client.resumeCalls, 0); + await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'x' } }); + t.is(client.resumeCalls, 1); +}); + +test('serializeSessionState round-trips through deserializeSessionState', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + const record = await acts[`fake${SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX}`]!({ state }); + t.is(record.sessionId, state.sessionId); + t.deepEqual(record.providerState, { workspacePath: '/tmp/fake-workspace' }); + + // The runtime merges envelope fields (manifest etc.) over this record before + // handing it to the workflow client's pure deserializeSessionState. + const restored = await temporalSandboxClient('fake').deserializeSessionState({ + ...record, + manifest: state.manifest, + workspaceReady: true, + }); + t.is(restored.sessionId, state.sessionId); + t.true(restored.manifest instanceof Manifest); + t.deepEqual(restored.providerState, { workspacePath: '/tmp/fake-workspace' }); +}); + +test('providerState fallback (no serializeSessionState) preserves environment, dropping only duplicated envelope fields', async (t) => { + class NoSerializeClient extends FakeSandboxClient { + // Inherits create/resume; deliberately drops the custom (de)serializers so + // the provider falls back to its own state handling. + serializeSessionState = undefined as any; + deserializeSessionState = undefined as any; + } + + const session = new FakeSandboxSession(); + session.state = { + manifest: new Manifest(), + environment: { SECRET: 'topsecret' }, + snapshot: { id: 'snap', type: 'local' }, + workspaceReady: true, + exposedPorts: { 'port:8080': { host: 'h', port: 8080 } }, + // The provider-specific key that must survive. + workspaceId: 'abc-123', + } as unknown as SandboxSessionState; + const client = new NoSerializeClient(session); + const acts = activityMap(new SandboxClientProvider('fake', client)); + + const result = await createSession(acts); + const ps = result.state.providerState; + + // The resolved environment and provider-specific keys are preserved; only the + // envelope-owned fields (duplicated at the handle's top level) are dropped. + t.deepEqual(ps.environment, { SECRET: 'topsecret' }); + t.is(ps.workspaceId, 'abc-123'); + t.is(ps.manifest, undefined); + t.is(ps.snapshot, undefined); + t.is(ps.snapshotFingerprint, undefined); + t.is(ps.snapshotFingerprintVersion, undefined); + t.is(ps.workspaceReady, undefined); + t.is(ps.exposedPorts, undefined); + t.true(JSON.stringify(result.state).includes('topsecret')); + + // The matching rehydrate fallback reconstructs the session with its environment intact. + const fresh = activityMap(new SandboxClientProvider('fake', client)); + await fresh[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state: result.state, args: { cmd: 'x' } }); + t.is(client.resumeCalls, 1); + t.true(client.session.state.manifest instanceof Manifest); + t.is((client.session.state as { workspaceId?: string }).workspaceId, 'abc-123'); + t.deepEqual(client.session.state.environment, { SECRET: 'topsecret' }); +}); + +test('deserializeSessionState rejects records without a sessionId', async (t) => { + const client = temporalSandboxClient('fake'); + const err = await t.throwsAsync( + client.deserializeSessionState({ manifest: encodeManifest(new Manifest()), providerState: {} }), + { instanceOf: ApplicationFailure } + ); + t.is(err?.type, 'SandboxSessionStateInvalid'); +}); + +test('deserializeSessionState rejects records without a manifest', async (t) => { + const client = temporalSandboxClient('fake'); + const err = await t.throwsAsync(client.deserializeSessionState({ sessionId: 'abc', providerState: {} }), { + instanceOf: ApplicationFailure, + }); + t.is(err?.type, 'SandboxSessionStateInvalid'); +}); + +test("deserializeSessionState decodes the SDK's real serialized manifest record", async (t) => { + const content = new Uint8Array([0, 1, 2, 253, 254, 255]); + const manifest = new Manifest({ + root: '/workspace', + entries: { + 'bin/blob': { type: 'file', content }, + 'tmp/scratch': { type: 'file', content: 'scratch', ephemeral: true }, + }, + environment: { API_KEY: { value: 'secret', ephemeral: true }, PLAIN: 'value' }, + }); + + // `manifest` here is the SDK's own serializeManifestRecord output. + const record = { + sessionId: 'sess-1', + providerState: { workspacePath: '/tmp/w' }, + manifest: JSON.parse(JSON.stringify(serializeManifestRecord(manifest))), + workspaceReady: true, + }; + + const restored = await temporalSandboxClient('fake').deserializeSessionState(record); + t.is(restored.sessionId, 'sess-1'); + t.true(restored.manifest instanceof Manifest); + t.deepEqual((restored.manifest.entries['bin/blob'] as { content: Uint8Array }).content, content); + t.is(restored.manifest.entries['tmp/scratch'], undefined); + t.is(restored.manifest.environment.API_KEY, undefined); + t.is(restored.manifest.environment.PLAIN!.value, 'value'); + t.deepEqual(restored.providerState, { workspacePath: '/tmp/w' }); +}); + +// ── Session caching and resume-based self-heal ── + +test('repeated operations reuse the cached session without resuming', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'one' } }); + await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'two' } }); + + t.is(client.resumeCalls, 0); + t.is(client.session.execCalls.length, 2); +}); + +test('a fresh provider (worker restart) self-heals via client.resume', async (t) => { + const client = new FakeSandboxClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + + // Simulate the activity retrying on a different worker: same real backend, + // new provider instance with an empty session cache. + const freshActs = activityMap(new SandboxClientProvider('fake', client)); + const result: SandboxExecResult = await freshActs[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ + state, + args: { cmd: 'after-restart' }, + }); + + t.is(result.exitCode, 0); + t.is(client.resumeCalls, 1); + t.true(client.session.state.manifest instanceof Manifest); + + await freshActs[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'again' } }); + t.is(client.resumeCalls, 1); +}); + +test('operations on an unknown session fail non-retryably when the client cannot resume', async (t) => { + const client = new FakeSandboxClient(); + (client as { resume?: unknown }).resume = undefined; + const acts = activityMap(new SandboxClientProvider('fake', client)); + const state: SerializedSandboxSessionState = { + sessionId: 'missing', + manifest: encodeManifest(new Manifest()), + workspaceReady: true, + providerState: {}, + }; + + const err = await t.throwsAsync(acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'x' } }), { + instanceOf: ApplicationFailure, + }); + t.is(err?.type, 'SandboxOperationUnsupported'); + t.true(err?.nonRetryable); +}); + +test('concurrent cache-miss operations on one session share a single resume', async (t) => { + let releaseResume!: () => void; + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + + class GatedResumeClient extends FakeSandboxClient { + override async resume(state: SandboxSessionState): Promise { + this.resumeCalls += 1; + await resumeGate; + this.session.state = state; + return this.session; + } + } + + const client = new GatedResumeClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const state: SerializedSandboxSessionState = { + sessionId: 'shared', + manifest: encodeManifest(new Manifest()), + workspaceReady: true, + providerState: {}, + }; + + // The agents run loop executes tools in parallel, so two Activities can + // cache-miss the same session before either resume completes. + const first = acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'a' } }); + const second = acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'b' } }); + releaseResume(); + await Promise.all([first, second]); + + t.is(client.resumeCalls, 1); + t.is(client.session.execCalls.length, 2); +}); + +test('a failed resume is not memoized, so a later operation retries', async (t) => { + let attempt = 0; + class FlakyResumeClient extends FakeSandboxClient { + override async resume(state: SandboxSessionState): Promise { + this.resumeCalls += 1; + attempt += 1; + if (attempt === 1) throw new Error('resume failed'); + this.session.state = state; + return this.session; + } + } + + const client = new FlakyResumeClient(); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const state: SerializedSandboxSessionState = { + sessionId: 'retry', + manifest: encodeManifest(new Manifest()), + workspaceReady: true, + providerState: {}, + }; + + await t.throwsAsync(acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'a' } }), { + message: 'resume failed', + }); + await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'b' } }); + t.is(client.resumeCalls, 2); +}); + +test('session delete evicts the cache even when delete fails after shutdown', async (t) => { + class DeleteRaisingSession extends FakeSandboxSession { + override async delete(): Promise { + throw new Error('delete failed'); + } + } + + const session = new DeleteRaisingSession(); + const client = new FakeSandboxClient(session); + const provider = new SandboxClientProvider('fake', client); + const acts = provider._getActivities(); + const { state } = await createSession(acts); + + await acts[`fake${SANDBOX_SESSION_SHUTDOWN_SUFFIX}`]!({ state }); + t.true((provider as any)._sessions.has(state.sessionId)); + + await t.throwsAsync(acts[`fake${SANDBOX_SESSION_DELETE_SUFFIX}`]!({ state }), { message: 'delete failed' }); + t.is(session.shutdownCalls, 1); + t.false((provider as any)._sessions.has(state.sessionId)); +}); + +// ── SandboxError → ApplicationFailure translation ── + +class ExecRaisingSession extends FakeSandboxSession { + constructor(private readonly error: SandboxError) { + super(); + } + + override async exec(): Promise { + throw this.error; + } +} + +async function execWithError(error: SandboxError): Promise { + const client = new FakeSandboxClient(new ExecRaisingSession(error)); + const acts = activityMap(new SandboxClientProvider('fake', client)); + const { state } = await createSession(acts); + await acts[`fake${SANDBOX_SESSION_EXEC_SUFFIX}`]!({ state, args: { cmd: 'boom' } }); +} + +test('terminal SandboxError (retryable false) becomes a non-retryable ApplicationFailure', async (t) => { + const err = await t.throwsAsync( + execWithError(new SandboxError('transport down', 'exec_transport_error', { retryable: false })), + { instanceOf: ApplicationFailure } + ); + t.true(err?.nonRetryable); + t.is(err?.type, 'exec_transport_error'); +}); + +test('transient SandboxError (retryable true) propagates unchanged', async (t) => { + await t.throwsAsync(execWithError(new SandboxError('flaky', 'exec_transport_error', { retryable: true })), { + instanceOf: SandboxError, + }); +}); + +test('unclassified SandboxError (retryable null) propagates unchanged', async (t) => { + const error = new SandboxError('unknown', 'exec_transport_error'); + t.is(error.retryable, null); + await t.throwsAsync(execWithError(error), { instanceOf: SandboxError }); +}); + +// ── Workflow-side sync touch-points ── + +function makeWorkflowSession(supportsPty = true): TemporalSandboxSession { + return new TemporalSandboxSession( + 'fake', + { startToCloseTimeout: '5 minutes' }, + { + sessionId: 'session-1', + manifest: new Manifest({ environment: { FOO: 'bar' } }), + workspaceReady: true, + providerState: { workspacePath: '/tmp/fake-workspace' }, + }, + supportsPty + ); +} + +test('session state (including manifest) is served synchronously from cached state', (t) => { + const session = makeWorkflowSession(); + t.true(session.state.manifest instanceof Manifest); + t.is(session.state.manifest.environment.FOO!.value, 'bar'); + t.is(session.state.sessionId, 'session-1'); +}); + +test('createEditor returns an editor synchronously', (t) => { + const editor = makeWorkflowSession().createEditor('someone'); + t.is(typeof editor.createFile, 'function'); + t.is(typeof editor.updateFile, 'function'); + t.is(typeof editor.deleteFile, 'function'); +}); + +test('supportsPty is served from the cached flag', (t) => { + t.true(makeWorkflowSession(true).supportsPty()); + t.false(makeWorkflowSession(false).supportsPty()); +}); + +test('setArchiveLimits mutates local cached state only', (t) => { + const session = makeWorkflowSession(); + session.setArchiveLimits({ maxInputBytes: 42 }); + t.deepEqual((session as any)._archiveLimits, { maxInputBytes: 42 }); + session.setArchiveLimits(null); + t.is((session as any)._archiveLimits, null); +}); + +test('registerPreStopHook is not implemented on the proxy session', (t) => { + const session: SandboxSession = makeWorkflowSession(); + t.is(session.registerPreStopHook, undefined); +}); + +// ── Serialization round-trips ── + +const BYTE_PAYLOADS: Array<[string, Uint8Array]> = [ + ['empty', new Uint8Array(0)], + ['ascii', new TextEncoder().encode('hello world')], + ['all-byte-values', new Uint8Array(Array.from({ length: 256 }, (_, i) => i))], + ['non-utf8-binary', new Uint8Array([0xff, 0xfe, 0x80, 0x90, 0x00, 0x01])], + ['one-byte', new Uint8Array([7])], + ['two-bytes', new Uint8Array([7, 8])], +]; + +test('base64 helpers round-trip binary payloads and match Buffer', (t) => { + for (const [label, payload] of BYTE_PAYLOADS) { + const encoded = bytesToBase64(payload); + t.is(encoded, Buffer.from(payload).toString('base64'), label); + t.deepEqual(base64ToBytes(encoded), payload, label); + } +}); + +test('encodeManifest keeps binary content and all ephemeral entries/env, and is SDK-decodable', (t) => { + const content = new Uint8Array([0, 1, 2, 253, 254, 255]); + const manifest = new Manifest({ + root: '/workspace', + entries: { + 'bin/blob': { type: 'file', content }, + 'tmp/scratch': { type: 'file', content: 'scratch', ephemeral: true }, + nested: { type: 'dir', children: { 'inner.bin': { type: 'file', content } } }, + }, + environment: { API_KEY: { value: 'secret', ephemeral: true }, PLAIN: 'value' }, + users: ['alice'], + }); + + const record = JSON.parse(JSON.stringify(encodeManifest(manifest))); + // Our own decoder and the SDK's own deserializer both accept the encoded form. + t.true(decodeManifest(record) instanceof Manifest); + const decoded = deserializeManifest(record); + + t.is(decoded.root, '/workspace'); + // Non-ephemeral binary content survives the JSON round-trip. + t.deepEqual((decoded.entries['bin/blob'] as { content: Uint8Array }).content, content); + const nested = decoded.entries['nested'] as { children: Record }; + t.deepEqual(nested.children['inner.bin']!.content, content); + // Ephemeral entries and env values are preserved, not stripped. + const scratch = decoded.entries['tmp/scratch'] as { content: string; ephemeral?: boolean }; + t.is(scratch.content, 'scratch'); + t.is(scratch.ephemeral, true); + t.is(decoded.environment.API_KEY!.value, 'secret'); + t.is(decoded.environment.API_KEY!.ephemeral, true); + t.is(decoded.environment.PLAIN!.value, 'value'); + t.deepEqual(decoded.users, [{ name: 'alice' }]); +}); + +test('session envelope survives a JSON round-trip and revives a live Manifest', (t) => { + const state = { + sessionId: 'abc', + manifest: new Manifest({ environment: { FOO: 'bar' } }), + snapshot: { id: 'snap-1', type: 'local' }, + workspaceReady: false, + exposedPorts: { 'port:8080': { host: 'localhost', port: 8080 } }, + providerState: { workspacePath: '/tmp/w' }, + }; + + const payload = JSON.parse(JSON.stringify(serializeSessionEnvelope(state.sessionId, state, state.providerState))); + const decoded = reviveWorkflowSessionState(payload); + + t.is(decoded.sessionId, 'abc'); + t.true(decoded.manifest instanceof Manifest); + t.is(decoded.manifest.environment.FOO!.value, 'bar'); + t.deepEqual(decoded.snapshot, { id: 'snap-1', type: 'local' }); + t.false(decoded.workspaceReady); + t.deepEqual(decoded.exposedPorts, { 'port:8080': { host: 'localhost', port: 8080 } }); + t.deepEqual(decoded.providerState, { workspacePath: '/tmp/w' }); +}); diff --git a/contrib/openai-agents/src/__tests__/stubs/sandbox-fakes.ts b/contrib/openai-agents/src/__tests__/stubs/sandbox-fakes.ts new file mode 100644 index 000000000..0a09901ea --- /dev/null +++ b/contrib/openai-agents/src/__tests__/stubs/sandbox-fakes.ts @@ -0,0 +1,188 @@ +import type { ToolOutputImage } from '@openai/agents-core'; +import { + Manifest, + type ExecCommandArgs, + type ListDirectoryArgs, + type MaterializeEntryArgs, + type ReadFileArgs, + type SandboxArchiveLimits, + type SandboxClient, + type SandboxClientCreateArgs, + type SandboxDirectoryEntry, + type SandboxExecResult, + type SandboxSession, + type SandboxSessionState, + type ViewImageArgs, + type WriteStdinArgs, +} from '@openai/agents-core/sandbox'; +import { deserializeManifest, mergeManifestDelta, mergeManifestEntryDelta } from '@openai/agents-core/sandbox/internal'; + +export class FakeSandboxSession implements SandboxSession { + state: SandboxSessionState; + execCalls: ExecCommandArgs[] = []; + readFileCalls: ReadFileArgs[] = []; + materializedEntries: MaterializeEntryArgs[] = []; + appliedManifests: Manifest[] = []; + hydrateCalls: Array<{ data: string | ArrayBuffer | Uint8Array; archiveLimits?: SandboxArchiveLimits | null }> = []; + editorOperations: string[] = []; + archiveLimits: SandboxArchiveLimits | null | undefined; + startCalls = 0; + stopCalls = 0; + shutdownCalls = 0; + deleteCalls = 0; + persistCalls = 0; + + constructor(manifest?: Manifest) { + this.state = { + manifest: manifest ?? new Manifest(), + workspaceReady: true, + workspacePath: '/tmp/fake-workspace', + }; + } + + async start(): Promise { + this.startCalls += 1; + } + + async running(): Promise { + return this.startCalls > 0; + } + + async stop(): Promise { + this.stopCalls += 1; + } + + async shutdown(): Promise { + this.shutdownCalls += 1; + } + + async delete(): Promise { + this.deleteCalls += 1; + } + + createEditor(): { + createFile: (op: any) => Promise<{ status: 'completed'; output: string }>; + updateFile: (op: any) => Promise<{ status: 'completed'; output: string }>; + deleteFile: (op: any) => Promise<{ status: 'completed'; output: string }>; + } { + const record = (kind: string) => async (op: { path: string }) => { + this.editorOperations.push(`${kind}:${op.path}`); + return { status: 'completed' as const, output: kind }; + }; + return { createFile: record('create'), updateFile: record('update'), deleteFile: record('delete') }; + } + + async exec(args: ExecCommandArgs): Promise { + this.execCalls.push(args); + return { output: 'ok', stdout: 'ok', stderr: '', wallTimeSeconds: 0.1, exitCode: 0 }; + } + + async execCommand(args: ExecCommandArgs): Promise { + this.execCalls.push(args); + return `ran:${args.cmd}`; + } + + async writeStdin(args: WriteStdinArgs): Promise { + return `stdin:${args.chars ?? ''}`; + } + + async viewImage(_args: ViewImageArgs): Promise { + return { type: 'image', image: { data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), mediaType: 'image/png' } }; + } + + async readFile(args: ReadFileArgs): Promise { + this.readFileCalls.push(args); + return new TextEncoder().encode('file-content'); + } + + async listDir(_args: ListDirectoryArgs): Promise { + return [{ name: 'file.txt', path: '/workspace/file.txt', type: 'file' }]; + } + + async pathExists(_path: string): Promise { + return true; + } + + async materializeEntry(args: MaterializeEntryArgs): Promise { + this.materializedEntries.push(args); + this.state.manifest = mergeManifestEntryDelta(this.state.manifest, args.path, args.entry); + } + + async applyManifest(manifest: Manifest): Promise { + this.appliedManifests.push(manifest); + this.state.manifest = mergeManifestDelta(this.state.manifest, manifest); + } + + async persistWorkspace(): Promise { + this.persistCalls += 1; + return new Uint8Array([10, 20, 30]); + } + + async hydrateWorkspace( + data: string | ArrayBuffer | Uint8Array, + options?: { archiveLimits?: SandboxArchiveLimits | null } + ): Promise { + this.hydrateCalls.push({ data, archiveLimits: options?.archiveLimits }); + } + + setArchiveLimits(limits?: SandboxArchiveLimits | null): void { + this.archiveLimits = limits; + } + + supportsPty(): boolean { + return false; + } +} + +export class FakeSandboxClient implements SandboxClient { + readonly backendId = 'fake'; + session: FakeSandboxSession; + createCalls: SandboxClientCreateArgs[] = []; + resumeCalls = 0; + deleteCalls = 0; + + constructor(session?: FakeSandboxSession) { + this.session = session ?? new FakeSandboxSession(); + } + + async create(args?: SandboxClientCreateArgs | Manifest): Promise { + const createArgs = args instanceof Manifest ? { manifest: args } : args ?? {}; + this.createCalls.push(createArgs); + if (createArgs.manifest instanceof Manifest) { + this.session.state.manifest = createArgs.manifest; + // create resolves the manifest's environment, ephemeral values included. + this.session.state.environment = Object.fromEntries( + Object.entries(createArgs.manifest.environment).map(([key, env]) => [key, env.value]) + ); + } + return this.session; + } + + async resume(state: SandboxSessionState): Promise { + this.resumeCalls += 1; + this.session.state = state; + return this.session; + } + + async delete(_state: SandboxSessionState): Promise { + this.deleteCalls += 1; + } + + async serializeSessionState(state: SandboxSessionState): Promise> { + // Persist non-ephemeral env only. + const environment = Object.fromEntries( + Object.entries(state.environment ?? {}).filter(([key]) => !state.manifest.environment[key]?.ephemeral) + ); + return Object.keys(environment).length > 0 + ? { workspacePath: state.workspacePath, environment } + : { workspacePath: state.workspacePath }; + } + + async deserializeSessionState(state: Record): Promise { + // Env comes from the persisted (stripped) state — never re-resolved from the manifest. + return { + ...state, + manifest: deserializeManifest(state.manifest as Record), + } as SandboxSessionState; + } +} diff --git a/contrib/openai-agents/src/__tests__/test-openai-agents-comprehensive.ts b/contrib/openai-agents/src/__tests__/test-openai-agents-comprehensive.ts index 00e8e84ce..27ec0026e 100644 --- a/contrib/openai-agents/src/__tests__/test-openai-agents-comprehensive.ts +++ b/contrib/openai-agents/src/__tests__/test-openai-agents-comprehensive.ts @@ -18,9 +18,11 @@ import { OpenAIAgentsPlugin, StatelessMCPServerProvider, StatefulMCPServerProvider, + SandboxClientProvider, DEDICATED_WORKER_FAILURE_TYPE, } from '..'; import { FakeModelProvider, textResponse, toolCallResponse, handoffResponse } from './stubs/openai-agents-fakes'; +import { FakeSandboxClient } from './stubs/sandbox-fakes'; import { installOtelCollector, installAgentSdkCollector } from './helpers/openai-agents-spans'; import * as activities from './activities/openai-agents-comprehensive'; import { @@ -84,6 +86,13 @@ function* singleTurnScript(text: string): Generator> { + yield toolCallResponse('run_command', { cmd: 'echo hello' }); + yield toolCallResponse('read_file', { path: '/workspace/test.txt' }); + yield toolCallResponse('write_file', { path: '/workspace/out.txt', diff: 'hello' }); + yield textResponse('sandbox done'); +} + /** * Composes the full FakeModelProvider script for one Test 1 / Test 2 run: * preamble + child + child wrapped + sessions x 2 + BIG (initial + resume) + small wrapped + finalRound. @@ -94,6 +103,7 @@ function* comprehensiveScript(): Generator> { yield* singleTurnScript('child-2 done'); yield* singleTurnScript('teal noted'); yield* singleTurnScript('your favorite color is teal'); + yield* sandboxAgentScript(); yield* bigAgentInitialScript(); yield* bigAgentResumeScript(); yield* singleTurnScript('small done'); @@ -113,6 +123,7 @@ function buildPlugin(opts: BuildPluginOptions): OpenAIAgentsPlugin { return new OpenAIAgentsPlugin({ modelProvider: fakeProvider, mcpServerProviders: [stateless, statelessBOnly, stateful], + sandboxClientProviders: [new SandboxClientProvider('fake', new FakeSandboxClient())], interceptorOptions: { addTemporalSpans: opts.addTemporalSpans, useOtelInstrumentation: true, @@ -328,6 +339,69 @@ const EXPECTED_OTEL_TEST_1: string[][] = [ ' generation', ' temporal:startActivity:invokeModelActivity', ' temporal:executeActivity:invokeModelActivity', + ' agent:SandboxAgent', + ' sandbox.prepare_agent', + ' sandbox.create_session', + ' temporal:startActivity:fake-sandbox-client-create', + ' temporal:executeActivity:fake-sandbox-client-create', + ' sandbox:session-running', + ' temporal:startActivity:fake-sandbox-session-running', + ' temporal:executeActivity:fake-sandbox-session-running', + ' sandbox:session-running:result', + ' sandbox.start', + ' sandbox:session-start', + ' temporal:startActivity:fake-sandbox-session-start', + ' temporal:executeActivity:fake-sandbox-session-start', + ' sandbox:session-start:result', + ' generation', + ' temporal:startActivity:invokeModelActivity', + ' temporal:executeActivity:invokeModelActivity', + ' function:run_command', + ' sandbox:session-exec-command', + ' temporal:startActivity:fake-sandbox-session-exec-command', + ' temporal:executeActivity:fake-sandbox-session-exec-command', + ' sandbox:session-exec-command:result', + ' sandbox.prepare_agent', + ' generation', + ' temporal:startActivity:invokeModelActivity', + ' temporal:executeActivity:invokeModelActivity', + ' function:read_file', + ' sandbox:session-read-file', + ' temporal:startActivity:fake-sandbox-session-read-file', + ' temporal:executeActivity:fake-sandbox-session-read-file', + ' sandbox:session-read-file:result', + ' sandbox.prepare_agent', + ' generation', + ' temporal:startActivity:invokeModelActivity', + ' temporal:executeActivity:invokeModelActivity', + ' function:write_file', + ' sandbox:editor-create-file', + ' temporal:startActivity:fake-sandbox-editor-create-file', + ' temporal:executeActivity:fake-sandbox-editor-create-file', + ' sandbox:editor-create-file:result', + ' sandbox.prepare_agent', + ' generation', + ' temporal:startActivity:invokeModelActivity', + ' temporal:executeActivity:invokeModelActivity', + ' sandbox.cleanup', + ' sandbox:client-serialize-session-state', + ' temporal:startActivity:fake-sandbox-client-serialize-session-state', + ' temporal:executeActivity:fake-sandbox-client-serialize-session-state', + ' sandbox:client-serialize-session-state:result', + ' sandbox.cleanup_sessions', + ' sandbox.shutdown', + ' sandbox:session-stop', + ' temporal:startActivity:fake-sandbox-session-stop', + ' temporal:executeActivity:fake-sandbox-session-stop', + ' sandbox:session-stop:result', + ' sandbox:session-shutdown', + ' temporal:startActivity:fake-sandbox-session-shutdown', + ' temporal:executeActivity:fake-sandbox-session-shutdown', + ' sandbox:session-shutdown:result', + ' sandbox:session-delete', + ' temporal:startActivity:fake-sandbox-session-delete', + ' temporal:executeActivity:fake-sandbox-session-delete', + ' sandbox:session-delete:result', ' agent:AgentA', ' mcp_tools', ' temporal:startActivity:mockStatelessMcp-stateless-list-tools', @@ -508,6 +582,23 @@ const EXPECTED_OTEL_TEST_2: string[][] = [ ' generation', ' agent:MemoryAgent', ' generation', + ' agent:SandboxAgent', + ' sandbox.prepare_agent', + ' sandbox.create_session', + ' sandbox.start', + ' generation', + ' function:run_command', + ' sandbox.prepare_agent', + ' generation', + ' function:read_file', + ' sandbox.prepare_agent', + ' generation', + ' function:write_file', + ' sandbox.prepare_agent', + ' generation', + ' sandbox.cleanup', + ' sandbox.cleanup_sessions', + ' sandbox.shutdown', ' agent:AgentA', ' mcp_tools', ' mcp_tools', diff --git a/contrib/openai-agents/src/__tests__/test-openai-sandbox.ts b/contrib/openai-agents/src/__tests__/test-openai-sandbox.ts new file mode 100644 index 000000000..b0bd6a2da --- /dev/null +++ b/contrib/openai-agents/src/__tests__/test-openai-sandbox.ts @@ -0,0 +1,189 @@ +import { setTracingDisabled, withTrace } from '@openai/agents-core'; +import { WorkflowClient } from '@temporalio/client'; +import { helpers } from '@temporalio/test-helpers'; +import { OpenAIAgentsPlugin, OpenAIAgentsTraceClientInterceptor, SandboxClientProvider } from '..'; +import { installAgentSdkCollector } from './helpers/openai-agents-spans'; +import { makeTestFunction } from './helpers/test-fn'; +import { FakeModelProvider, textResponse, toolCallResponse } from './stubs/openai-agents'; +import { FakeSandboxClient, FakeSandboxSession } from './stubs/sandbox-fakes'; +import { + sandboxAgentWorkflow, + sandboxArchiveLimitsWorkflow, + sandboxExecWorkflow, + sandboxManifestResumeWorkflow, + sandboxValidationWorkflow, +} from './workflows/openai-sandbox'; + +setTracingDisabled(false); + +const test = makeTestFunction({ + workflowsPath: require.resolve('./workflows/openai-sandbox'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: new FakeModelProvider([]) })], +}); + +const PERMITTED_SPAN_DATA_KEYS = new Set(['sessionId', 'port', 'exitCode', 'byteLength', 'length', 'count']); + +test('SandboxAgent run exercises the full sandbox lifecycle through Activities', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + const session = new FakeSandboxSession(); + const client = new FakeSandboxClient(session); + + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([ + toolCallResponse('run_command', { cmd: 'echo hello' }), + toolCallResponse('read_file', { path: '/workspace/test.txt' }), + toolCallResponse('write_file', { path: '/workspace/out.txt', diff: 'hello' }), + textResponse('Done.'), + ]), + modelParams: { startToCloseTimeout: '30s' }, + sandboxClientProviders: [new SandboxClientProvider('fake', client)], + }), + ], + }); + + await worker.runUntil(async () => { + const result = await executeWorkflow(sandboxAgentWorkflow); + t.is(result, 'Done.'); + }); + + t.is(client.createCalls.length, 1, 'client.create() not called'); + t.is(session.startCalls, 1, 'session.start() not called'); + t.true(session.execCalls.length >= 1, 'session.execCommand() not called'); + t.is(session.execCalls[0]!.cmd, 'echo hello'); + t.true(session.readFileCalls.length >= 1, 'session.readFile() not called'); + t.deepEqual(session.editorOperations, ['create:/workspace/out.txt'], 'editor createFile not called'); + t.true(session.stopCalls >= 1, 'session.stop() not called'); + t.true(session.shutdownCalls >= 1, 'session.shutdown() not called'); + t.true(session.deleteCalls >= 1, 'session.delete() not called'); +}); + +test('applyManifest updates the Workflow-side manifest and survives resume', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([]), + sandboxClientProviders: [new SandboxClientProvider('fake', new FakeSandboxClient())], + }), + ], + }); + + await worker.runUntil(async () => { + const result = await executeWorkflow(sandboxManifestResumeWorkflow); + t.is(result, 'live=true persisted=true'); + }); +}); + +test('setArchiveLimits is forwarded to the persistWorkspace and hydrateWorkspace Activities', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + const client = new FakeSandboxClient(); + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([]), + sandboxClientProviders: [new SandboxClientProvider('fake', client)], + }), + ], + }); + + await worker.runUntil(async () => { + t.is(await executeWorkflow(sandboxArchiveLimitsWorkflow), 'ok'); + }); + + t.deepEqual(client.session.archiveLimits, { maxInputBytes: 42 }); + t.deepEqual(client.session.hydrateCalls[0]!.archiveLimits, { maxInputBytes: 42 }); +}); + +test('Sandbox configuration errors are raised inside the Workflow', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([]), + sandboxClientProviders: [new SandboxClientProvider('fake', new FakeSandboxClient())], + }), + ], + }); + + await worker.runUntil(async () => { + const result = await executeWorkflow(sandboxValidationWorkflow); + t.is(result, 'OK'); + }); +}); + +test('addTemporalSpans: sandbox exec emits sandbox:* spans carrying only safe metadata', async (t) => { + const { createWorker, taskQueue } = helpers(t); + + const agentSdk = installAgentSdkCollector(); + const worker = await createWorker({ + maxCachedWorkflows: 0, + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([]), + sandboxClientProviders: [new SandboxClientProvider('fake', new FakeSandboxClient())], + interceptorOptions: { addTemporalSpans: true, useOtelInstrumentation: false }, + }), + ], + }); + + const wfClient = new WorkflowClient({ + connection: (t.context as any).env.connection, + interceptors: [new OpenAIAgentsTraceClientInterceptor({ addTemporalSpans: true })], + }); + + try { + await worker.runUntil(async () => { + await withTrace('sandbox-tracing-root', async () => { + const handle = await wfClient.start(sandboxExecWorkflow, { + taskQueue, + workflowId: `sandbox-tracing-${Date.now()}`, + workflowExecutionTimeout: '30 seconds', + }); + t.is(await handle.result(), 'exit=0'); + }); + }); + + const sandboxRecords = agentSdk.collector.records_().filter((r) => r.name.startsWith('sandbox:')); + const named = (n: string) => sandboxRecords.filter((r) => r.name === n); + + t.is(named('sandbox:client-create').length, 0, 'client.create must not emit a workflow-side span'); + t.is(named('sandbox:client-create:result').length, 0, 'client.create must not emit a worker-side span'); + + const wfExec = named('sandbox:session-exec'); + t.true(wfExec.length > 0, 'expected a workflow-side sandbox:session-exec span'); + t.true(wfExec.every((r) => typeof r.data?.sessionId === 'string')); + + const workerExec = named('sandbox:session-exec:result'); + t.true(workerExec.length > 0, 'expected a worker-side sandbox:session-exec:result span'); + t.true(workerExec.every((r) => typeof r.data?.sessionId === 'string')); + t.true( + workerExec.some((r) => r.data?.exitCode === 0), + 'worker exec span must record exitCode' + ); + + t.true(named('sandbox:session-read-file').length > 0, 'expected a workflow-side read span'); + const workerRead = named('sandbox:session-read-file:result'); + t.true(workerRead.length > 0, 'expected a worker-side read span'); + t.true( + workerRead.some((r) => typeof r.data?.byteLength === 'number'), + 'worker read span must record byteLength' + ); + + t.true(named('sandbox:editor-create-file').length > 0, 'expected a workflow-side editor span'); + t.true(named('sandbox:editor-create-file:result').length > 0, 'expected a worker-side editor span'); + + for (const r of sandboxRecords) { + for (const key of Object.keys(r.data ?? {})) { + t.true(PERMITTED_SPAN_DATA_KEYS.has(key), `sandbox span ${r.name} carries disallowed attribute '${key}'`); + } + } + } finally { + await agentSdk.teardown(); + } +}); diff --git a/contrib/openai-agents/src/__tests__/workflows/openai-agents-comprehensive.ts b/contrib/openai-agents/src/__tests__/workflows/openai-agents-comprehensive.ts index 1955a8df8..4845fcfba 100644 --- a/contrib/openai-agents/src/__tests__/workflows/openai-agents-comprehensive.ts +++ b/contrib/openai-agents/src/__tests__/workflows/openai-agents-comprehensive.ts @@ -5,7 +5,8 @@ * appears direct and user-span-wrapped, handlers cover signal/query/update * paths, and the crash boundary is the resume execution's `condition()`. */ -import { Agent, RunState, tool, withCustomSpan, createCustomSpan } from '@openai/agents-core'; +import { Agent, RunState, tool, withCustomSpan, createCustomSpan, type Tool } from '@openai/agents-core'; +import { Capability, SandboxAgent, type SandboxSessionLike } from '@openai/agents-core/sandbox'; import * as nexus from 'nexus-rpc'; import { condition, @@ -27,6 +28,7 @@ import { WorkflowSafeMemorySession, statelessMcpServer, statefulMcpServer, + temporalSandboxClient, } from '../../workflow'; import type * as activities from '../activities/openai-agents-comprehensive'; @@ -94,6 +96,77 @@ export interface ComprehensiveInput { nexusEndpoint?: string; } +class ComprehensiveSandboxCapability extends Capability { + readonly type = 'comprehensive_sandbox'; + + private session(): SandboxSessionLike { + if (!this._session) throw new Error('comprehensive_sandbox capability is not bound to a session'); + return this._session; + } + + override tools(): Tool[] { + return [ + tool({ + name: 'run_command', + description: 'run_command', + parameters: { + type: 'object' as const, + properties: { cmd: { type: 'string' } }, + required: ['cmd'] as const, + additionalProperties: false as const, + }, + execute: async (args, _ctx) => { + const { cmd } = args as { cmd: string }; + return this.session().execCommand!({ cmd }); + }, + }), + tool({ + name: 'read_file', + description: 'read_file', + parameters: { + type: 'object' as const, + properties: { path: { type: 'string' } }, + required: ['path'] as const, + additionalProperties: false as const, + }, + execute: async (args, _ctx) => { + const { path } = args as { path: string }; + const data = await this.session().readFile!({ path }); + return typeof data === 'string' ? data : new TextDecoder().decode(data); + }, + }), + tool({ + name: 'write_file', + description: 'write_file', + parameters: { + type: 'object' as const, + properties: { path: { type: 'string' }, diff: { type: 'string' } }, + required: ['path', 'diff'] as const, + additionalProperties: false as const, + }, + execute: async (args, _ctx) => { + const { path, diff } = args as { path: string; diff: string }; + const editor = this.session().createEditor!(); + const result = await editor.createFile({ type: 'create_file', path, diff }); + return result?.output ?? 'ok'; + }, + }), + ]; + } +} + +async function runSandboxAgent(): Promise { + const agent = new SandboxAgent({ + name: 'SandboxAgent', + model: 'gpt-4o-mini', + capabilities: [new ComprehensiveSandboxCapability()], + }); + const runner = new TemporalOpenAIRunner(); + await runner.run(agent, 'exercise the sandbox', { + runConfig: { sandbox: { client: temporalSandboxClient('fake') } }, + }); +} + export async function comprehensiveAgentWorkflow(input: ComprehensiveInput = {}): Promise { if (input.finalRound) { const finalAgent = new Agent({ @@ -235,6 +308,8 @@ export async function comprehensiveAgentWorkflow(input: ComprehensiveInput = {}) await sessionRunner.run(memAgent, 'My favorite color is teal.', { session }); await sessionRunner.run(memAgent, 'What did I just tell you?', { session }); + await runSandboxAgent(); + const bigRunner = new TemporalOpenAIRunner(); const result = await bigRunner.run(setup.agentA, 'Triage me', { maxTurns: 12 }); if (result.interruptions.length === 0) { diff --git a/contrib/openai-agents/src/__tests__/workflows/openai-sandbox.ts b/contrib/openai-agents/src/__tests__/workflows/openai-sandbox.ts new file mode 100644 index 000000000..7ad3d9983 --- /dev/null +++ b/contrib/openai-agents/src/__tests__/workflows/openai-sandbox.ts @@ -0,0 +1,145 @@ +import { Agent, type FunctionTool, type RunContext, type Tool } from '@openai/agents-core'; +import { Capability, Manifest, SandboxAgent, type SandboxSessionLike } from '@openai/agents-core/sandbox'; +import { TemporalOpenAIRunner, temporalSandboxClient } from '../../workflow'; + +class TestSandboxCapability extends Capability { + readonly type = 'test_sandbox'; + + private session(): SandboxSessionLike { + if (!this._session) throw new Error('test_sandbox capability is not bound to a session'); + return this._session; + } + + override tools(): Tool[] { + const makeTool = ( + name: string, + properties: Record, + required: string[], + execute: (args: any) => Promise + ): FunctionTool => + ({ + type: 'function', + name, + description: name, + parameters: { type: 'object', properties, required, additionalProperties: false } as any, + strict: true, + invoke: async (_ctx: RunContext, input: string): Promise => execute(JSON.parse(input)), + needsApproval: async () => false, + isEnabled: async () => true, + }) as FunctionTool; + + return [ + makeTool('run_command', { cmd: { type: 'string' } }, ['cmd'], async ({ cmd }) => { + return this.session().execCommand!({ cmd }); + }), + makeTool('read_file', { path: { type: 'string' } }, ['path'], async ({ path }) => { + const data = await this.session().readFile!({ path }); + return typeof data === 'string' ? data : new TextDecoder().decode(data); + }), + makeTool( + 'write_file', + { path: { type: 'string' }, diff: { type: 'string' } }, + ['path', 'diff'], + async ({ path, diff }) => { + const editor = this.session().createEditor!(); + const result = await editor.createFile({ type: 'create_file', path, diff }); + return result?.output ?? 'ok'; + } + ), + ]; + } +} + +export async function sandboxAgentWorkflow(): Promise { + const agent = new SandboxAgent({ + name: 'sandbox-e2e', + model: 'gpt-4o-mini', + capabilities: [new TestSandboxCapability()], + defaultManifest: new Manifest({ + entries: { 'data.bin': { type: 'file', content: new Uint8Array([0, 1, 2, 253, 254, 255]) } }, + }), + }); + const runner = new TemporalOpenAIRunner(); + const result = await runner.run(agent, 'run a command', { + runConfig: { sandbox: { client: temporalSandboxClient('fake') } }, + }); + return `${result.finalOutput}`; +} + +export async function sandboxManifestResumeWorkflow(): Promise { + const client = temporalSandboxClient('fake'); + const session = await client.create(new Manifest({ entries: { 'base.txt': { type: 'file', content: 'base' } } })); + await session.applyManifest!(new Manifest({ entries: { 'added.txt': { type: 'file', content: 'added' } } })); + const live = 'added.txt' in session.state.manifest.entries; + const resumed = await client.resume(session.state); + const persisted = 'added.txt' in resumed.state.manifest.entries; + return `live=${live} persisted=${persisted}`; +} + +export async function sandboxExecWorkflow(): Promise { + const client = temporalSandboxClient('fake'); + const session = await client.create(); + await session.start!(); + const result = await session.exec!({ cmd: 'echo hello' }); + await session.readFile!({ path: '/workspace/out.txt' }); + const editor = session.createEditor!(); + await editor.createFile({ type: 'create_file', path: '/workspace/out.txt', diff: 'secret-content' }); + return `exit=${result.exitCode}`; +} + +export async function sandboxArchiveLimitsWorkflow(): Promise { + const client = temporalSandboxClient('fake'); + const session = await client.create(); + session.setArchiveLimits!({ maxInputBytes: 42 }); + await session.persistWorkspace!(); + await session.hydrateWorkspace!(new Uint8Array([1, 2, 3])); + return 'ok'; +} + +export async function sandboxValidationWorkflow(): Promise { + const runner = new TemporalOpenAIRunner(); + + try { + await runner.run(new SandboxAgent({ name: 'sandbox', model: 'gpt-4o-mini' }), 'hello'); + return 'FAIL: no-config should have thrown'; + } catch (e) { + if (!/runConfig\.sandbox is not configured/.test((e as Error).message)) { + return `FAIL: unexpected no-config error: ${(e as Error).message}`; + } + } + + try { + const sandbox = new SandboxAgent({ name: 'sandbox_target', model: 'gpt-4o-mini' }); + const router = new Agent({ name: 'router', model: 'gpt-4o-mini', handoffs: [sandbox] }); + await runner.run(router, 'hello'); + return 'FAIL: handoff-no-config should have thrown'; + } catch (e) { + if (!/runConfig\.sandbox is not configured/.test((e as Error).message)) { + return `FAIL: unexpected handoff-no-config error: ${(e as Error).message}`; + } + } + + try { + await runner.run(new SandboxAgent({ name: 'sandbox', model: 'gpt-4o-mini' }), 'hello', { + runConfig: { sandbox: {} }, + }); + return 'FAIL: null-client should have thrown'; + } catch (e) { + if (!/runConfig\.sandbox\.client must be set/.test((e as Error).message)) { + return `FAIL: unexpected null-client error: ${(e as Error).message}`; + } + } + + try { + await runner.run(new SandboxAgent({ name: 'sandbox', model: 'gpt-4o-mini' }), 'hello', { + runConfig: { sandbox: { client: { backendId: 'raw' } as any } }, + }); + return 'FAIL: raw-client should have thrown'; + } catch (e) { + if (!/Do not pass a raw sandbox client directly/.test((e as Error).message)) { + return `FAIL: unexpected raw-client error: ${(e as Error).message}`; + } + } + + return 'OK'; +} diff --git a/contrib/openai-agents/src/common/sandbox-activity-types.ts b/contrib/openai-agents/src/common/sandbox-activity-types.ts new file mode 100644 index 000000000..2a8b4bbb0 --- /dev/null +++ b/contrib/openai-agents/src/common/sandbox-activity-types.ts @@ -0,0 +1,357 @@ +import type { ApplyPatchOperation, ToolOutputImage } from '@openai/agents-core'; +import type { + Entry, + ExecCommandArgs, + ExposedPortEndpoint, + ListDirectoryArgs, + ReadFileArgs, + SandboxArchiveLimits, + SandboxConcurrencyLimits, + SandboxGroup, + SandboxPathGrant, + SandboxSessionLifecycleOptions, + SandboxSessionSerializationOptions, + SandboxSessionState, + SandboxUser, + Snapshot, + SnapshotSpec, + ViewImageArgs, + WriteStdinArgs, +} from '@openai/agents-core/sandbox'; +import { Manifest } from '@openai/agents-core/sandbox'; + +export const SANDBOX_CLIENT_CREATE_SUFFIX = '-sandbox-client-create'; +export const SANDBOX_CLIENT_RESUME_SUFFIX = '-sandbox-client-resume'; +export const SANDBOX_CLIENT_DELETE_SUFFIX = '-sandbox-client-delete'; +export const SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX = '-sandbox-client-serialize-session-state'; +export const SANDBOX_SESSION_START_SUFFIX = '-sandbox-session-start'; +export const SANDBOX_SESSION_RUNNING_SUFFIX = '-sandbox-session-running'; +export const SANDBOX_SESSION_STOP_SUFFIX = '-sandbox-session-stop'; +export const SANDBOX_SESSION_SHUTDOWN_SUFFIX = '-sandbox-session-shutdown'; +export const SANDBOX_SESSION_DELETE_SUFFIX = '-sandbox-session-delete'; +export const SANDBOX_SESSION_EXEC_SUFFIX = '-sandbox-session-exec'; +export const SANDBOX_SESSION_EXEC_COMMAND_SUFFIX = '-sandbox-session-exec-command'; +export const SANDBOX_SESSION_WRITE_STDIN_SUFFIX = '-sandbox-session-write-stdin'; +export const SANDBOX_SESSION_VIEW_IMAGE_SUFFIX = '-sandbox-session-view-image'; +export const SANDBOX_SESSION_READ_FILE_SUFFIX = '-sandbox-session-read-file'; +export const SANDBOX_SESSION_LIST_DIR_SUFFIX = '-sandbox-session-list-dir'; +export const SANDBOX_SESSION_PATH_EXISTS_SUFFIX = '-sandbox-session-path-exists'; +export const SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX = '-sandbox-session-materialize-entry'; +export const SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX = '-sandbox-session-apply-manifest'; +export const SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX = '-sandbox-session-persist-workspace'; +export const SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX = '-sandbox-session-hydrate-workspace'; +export const SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX = '-sandbox-session-resolve-exposed-port'; +export const SANDBOX_EDITOR_CREATE_FILE_SUFFIX = '-sandbox-editor-create-file'; +export const SANDBOX_EDITOR_UPDATE_FILE_SUFFIX = '-sandbox-editor-update-file'; +export const SANDBOX_EDITOR_DELETE_FILE_SUFFIX = '-sandbox-editor-delete-file'; + +/** Span label for a sandbox Activity, e.g. `-sandbox-session-exec` -> `sandbox:session-exec`. */ +export function sandboxSpanName(activitySuffix: string): string { + return `sandbox:${activitySuffix.replace(/^-sandbox-/, '')}`; +} + +/** + * JSON-safe representation of the full manifest: binary file contents are + * base64-encoded with the `{ type: 'base64', data }` marker. The entire manifest + * is preserved — including ephemeral files, dirs, mounts, and ephemeral + * environment values — so it reaches the backend unchanged and appears in + * Temporal history. + */ +export interface EncodedManifest { + version: number; + root: string; + entries: Record; + environment: Record; + users: SandboxUser[]; + groups: SandboxGroup[]; + extraPathGrants: SandboxPathGrant[]; + remoteMountCommandAllowlist: string[]; +} + +export interface EncodedEnvValue { + value: string; + ephemeral?: boolean; + description?: string; +} + +/** + * The serializable sandbox-session handle that crosses the Workflow/Activity + * boundary. Holds only identifiers, the encoded manifest, and the real client's + * own serialized state — never workspace bytes. + */ +export interface SerializedSandboxSessionState { + /** Worker-side session-cache key, assigned when the session is created. */ + sessionId: string; + manifest: EncodedManifest; + snapshot?: Snapshot | null; + snapshotFingerprint?: string | null; + snapshotFingerprintVersion?: string | null; + workspaceReady?: boolean; + exposedPorts?: Record; + /** Output of the real client's `serializeSessionState`. */ + providerState: Record; +} + +/** Workflow-side session state: the serialized handle with a live `Manifest`. */ +export interface TemporalSandboxSessionState extends SandboxSessionState { + sessionId: string; + providerState: Record; +} + +export interface SandboxSessionResult { + state: SerializedSandboxSessionState; + supportsPty: boolean; +} + +export interface SandboxCreateSessionInput { + manifest?: EncodedManifest; + snapshot?: SnapshotSpec; + options?: Record; + concurrencyLimits?: SandboxConcurrencyLimits; + archiveLimits?: SandboxArchiveLimits | null; +} + +export interface SandboxResumeSessionInput { + state: SerializedSandboxSessionState; + archiveLimits?: SandboxArchiveLimits | null; +} + +export interface SandboxSessionStateInput { + state: SerializedSandboxSessionState; +} + +export interface SandboxSerializeSessionStateInput extends SandboxSessionStateInput { + options?: SandboxSessionSerializationOptions; +} + +export interface SandboxLifecycleInput extends SandboxSessionStateInput { + options?: SandboxSessionLifecycleOptions; +} + +export interface SandboxExecInput extends SandboxSessionStateInput { + args: ExecCommandArgs; +} + +export interface SandboxWriteStdinInput extends SandboxSessionStateInput { + args: WriteStdinArgs; +} + +export interface SandboxViewImageInput extends SandboxSessionStateInput { + args: ViewImageArgs; +} + +export interface SandboxReadFileInput extends SandboxSessionStateInput { + args: ReadFileArgs; +} + +export interface SandboxListDirInput extends SandboxSessionStateInput { + args: ListDirectoryArgs; +} + +export interface SandboxPathExistsInput extends SandboxSessionStateInput { + path: string; + runAs?: string; +} + +export interface SandboxMaterializeEntryInput extends SandboxSessionStateInput { + path: string; + entry: unknown; + runAs?: string; +} + +export interface SandboxApplyManifestInput extends SandboxSessionStateInput { + manifest: EncodedManifest; + runAs?: string; +} + +export interface SandboxPersistWorkspaceInput extends SandboxSessionStateInput { + archiveLimits?: SandboxArchiveLimits | null; +} + +/** Archive bytes travel as a separate top-level Activity argument. */ +export interface SandboxHydrateWorkspaceInput extends SandboxSessionStateInput { + archiveLimits?: SandboxArchiveLimits | null; +} + +export interface SandboxResolveExposedPortInput extends SandboxSessionStateInput { + port: number; +} + +export interface SandboxEditorInput extends SandboxSessionStateInput { + operation: ApplyPatchOperation; + runAs?: string; +} + +export interface SerializedToolOutputImage { + output: ToolOutputImage; + /** Set when the original `image.data` was binary and base64-encoded for transport. */ + imageDataBase64?: boolean; +} + +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export function bytesToBase64(bytes: Uint8Array): string { + let result = ''; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]!; + const b1 = bytes[i + 1]; + const b2 = bytes[i + 2]; + result += BASE64_ALPHABET[b0 >> 2]!; + result += BASE64_ALPHABET[((b0 & 0x3) << 4) | ((b1 ?? 0) >> 4)]!; + result += b1 === undefined ? '=' : BASE64_ALPHABET[((b1 & 0xf) << 2) | ((b2 ?? 0) >> 6)]!; + result += b2 === undefined ? '=' : BASE64_ALPHABET[b2 & 0x3f]!; + } + return result; +} + +export function base64ToBytes(value: string): Uint8Array { + const stripped = value.replace(/=+$/, ''); + const bytes = new Uint8Array(Math.floor((stripped.length * 3) / 4)); + let byteIndex = 0; + for (let i = 0; i < stripped.length; i += 4) { + const c0 = BASE64_ALPHABET.indexOf(stripped[i]!); + const c1 = BASE64_ALPHABET.indexOf(stripped[i + 1] ?? 'A'); + const c2 = stripped[i + 2] === undefined ? -1 : BASE64_ALPHABET.indexOf(stripped[i + 2]!); + const c3 = stripped[i + 3] === undefined ? -1 : BASE64_ALPHABET.indexOf(stripped[i + 3]!); + bytes[byteIndex++] = (c0 << 2) | (c1 >> 4); + if (c2 >= 0) bytes[byteIndex++] = ((c1 & 0xf) << 4) | (c2 >> 2); + if (c3 >= 0) bytes[byteIndex++] = ((c2 & 0x3) << 6) | c3; + } + return bytes; +} + +interface SerializedFileContent { + type: 'base64'; + data: string; +} + +function isSerializedFileContent(value: unknown): value is SerializedFileContent { + return ( + typeof value === 'object' && + value !== null && + (value as SerializedFileContent).type === 'base64' && + typeof (value as SerializedFileContent).data === 'string' + ); +} + +export function encodeEntry(entry: Entry): unknown { + if (entry.type === 'file' && entry.content instanceof Uint8Array) { + return { ...entry, content: { type: 'base64', data: bytesToBase64(entry.content) } }; + } + if (entry.type === 'dir' && entry.children) { + return { ...entry, children: encodeEntries(entry.children) }; + } + return entry; +} + +export function decodeEntry(entry: unknown): Entry { + const record = entry as { type?: string; content?: unknown; children?: Record }; + if (record.type === 'file' && isSerializedFileContent(record.content)) { + return { ...record, content: base64ToBytes(record.content.data) } as Entry; + } + if (record.type === 'dir' && record.children) { + return { ...record, children: decodeEntries(record.children) } as Entry; + } + return entry as Entry; +} + +function encodeEntries(entries: Record): Record { + return Object.fromEntries(Object.entries(entries).map(([path, entry]) => [path, encodeEntry(entry)])); +} + +function decodeEntries(entries: Record): Record { + return Object.fromEntries(Object.entries(entries).map(([path, entry]) => [path, decodeEntry(entry)])); +} + +/** + * Used instead of the SDK's own serializer because the SDK's + * `@openai/agents-core/sandbox/internal` barrel imports Node built-ins and + * cannot be bundled into the Workflow isolate. + */ +export function encodeManifest(manifest: Manifest): EncodedManifest { + const environment: Record = {}; + for (const [key, env] of Object.entries(manifest.environment)) { + const normalized = env.normalized(); + environment[key] = { + value: normalized.value, + ...(normalized.ephemeral ? { ephemeral: true } : {}), + ...(normalized.description !== undefined ? { description: normalized.description } : {}), + }; + } + return { + version: manifest.version, + root: manifest.root, + entries: encodeEntries(manifest.entries), + environment, + users: manifest.users, + groups: manifest.groups, + extraPathGrants: manifest.extraPathGrants, + remoteMountCommandAllowlist: manifest.remoteMountCommandAllowlist, + }; +} + +export function decodeManifest(encoded: EncodedManifest): Manifest { + return new Manifest({ + version: encoded.version, + root: encoded.root, + entries: decodeEntries(encoded.entries), + environment: encoded.environment, + users: encoded.users, + groups: encoded.groups, + extraPathGrants: encoded.extraPathGrants, + remoteMountCommandAllowlist: encoded.remoteMountCommandAllowlist, + }); +} + +/** Builds the transported session handle, with the manifest encoded via {@link encodeManifest}. */ +export function serializeSessionEnvelope( + sessionId: string, + state: SandboxSessionState, + providerState: Record +): SerializedSandboxSessionState { + return { + sessionId, + manifest: encodeManifest(state.manifest), + ...(state.snapshot !== undefined ? { snapshot: state.snapshot } : {}), + ...(state.snapshotFingerprint !== undefined ? { snapshotFingerprint: state.snapshotFingerprint } : {}), + ...(state.snapshotFingerprintVersion !== undefined + ? { snapshotFingerprintVersion: state.snapshotFingerprintVersion } + : {}), + workspaceReady: state.workspaceReady ?? true, + ...(state.exposedPorts ? { exposedPorts: state.exposedPorts } : {}), + providerState, + }; +} + +/** Revives the Workflow-side session state, with a live `Manifest` for the run loop. */ +export function reviveWorkflowSessionState(payload: SerializedSandboxSessionState): TemporalSandboxSessionState { + const { sessionId, manifest, providerState, ...rest } = payload; + return { ...rest, sessionId, providerState: providerState ?? {}, manifest: decodeManifest(manifest) }; +} + +/** + * Builds the record a real client's `deserializeSessionState`/`resume` expects: + * the provider state merged under the SDK envelope fields, matching the SDK's + * own resume path. + */ +export function toSdkStateRecord(payload: SerializedSandboxSessionState): Record { + const { sessionId: _sessionId, manifest, providerState, ...rest } = payload; + return { ...(providerState ?? {}), ...rest, manifest }; +} + +export function encodeToolOutputImage(output: ToolOutputImage): SerializedToolOutputImage { + const image = output.image; + if (typeof image === 'object' && image !== null && 'data' in image && image.data instanceof Uint8Array) { + return { + output: { ...output, image: { ...image, data: bytesToBase64(image.data) } }, + imageDataBase64: true, + }; + } + return { output }; +} + +export function decodeToolOutputImage(serialized: SerializedToolOutputImage): ToolOutputImage { + if (!serialized.imageDataBase64) return serialized.output; + const image = serialized.output.image as { data: string }; + return { ...serialized.output, image: { ...image, data: base64ToBytes(image.data) } }; +} diff --git a/contrib/openai-agents/src/index.ts b/contrib/openai-agents/src/index.ts index 76779bb1e..1bbca3307 100644 --- a/contrib/openai-agents/src/index.ts +++ b/contrib/openai-agents/src/index.ts @@ -6,6 +6,7 @@ export type { } from './worker/plugin'; export { StatelessMCPServerProvider } from './worker/mcp-provider'; export { StatefulMCPServerProvider } from './worker/stateful-mcp-provider'; +export { SandboxClientProvider } from './worker/sandbox-provider'; export type { ModelActivityOptions, ModelSummaryProvider, diff --git a/contrib/openai-agents/src/worker/plugin.ts b/contrib/openai-agents/src/worker/plugin.ts index 87921932b..601897ce3 100644 --- a/contrib/openai-agents/src/worker/plugin.ts +++ b/contrib/openai-agents/src/worker/plugin.ts @@ -14,6 +14,7 @@ import { createModelActivity } from './activities'; import { ensureActivityTracingProcessorRegistered } from './activity-tracing'; import { makeAgentTracingSink } from './agent-sink-bridge'; import type { StatelessMCPServerProvider } from './mcp-provider'; +import type { SandboxClientProvider } from './sandbox-provider'; import type { StatefulMCPServerProvider } from './stateful-mcp-provider'; import { OpenAIAgentsTraceActivityInboundInterceptor, @@ -42,6 +43,13 @@ export interface OpenAIAgentsPluginOptions { modelProvider: ModelProvider; /** MCP server providers whose Activities will be auto-registered. */ mcpServerProviders?: MCPServerProvider[]; + /** + * Sandbox client providers whose Activities will be auto-registered. + * Reference them from Workflows via `temporalSandboxClient(name)`. + * + * @experimental Sandbox support is experimental and may change without notice. + */ + sandboxClientProviders?: SandboxClientProvider[]; /** * Default Model Activity options (timeouts, retry, Task Queue, etc.). * Propagated to the Workflow via the `__openai_agents_config` header. @@ -67,21 +75,26 @@ export class OpenAIAgentsPlugin extends SimplePlugin { let allActivities: Record Promise> = { ...modelActivities }; - if (options.mcpServerProviders) { - const seenNames = new Set(); - for (const provider of options.mcpServerProviders) { - if (seenNames.has(provider.name)) { - throw new Error( - `Duplicate MCP server provider name: '${provider.name}'. Each provider must have a unique name.` - ); - } - seenNames.add(provider.name); - const providerActivities = provider._getActivities(); - allActivities = { ...allActivities, ...providerActivities }; + const interceptorOpts = options.interceptorOptions; + for (const provider of options.sandboxClientProviders ?? []) { + provider._addTemporalSpans = interceptorOpts?.addTemporalSpans === true; + } + + const providers: Array<{ name: string; _getActivities(): Record Promise> }> = [ + ...(options.mcpServerProviders ?? []), + ...(options.sandboxClientProviders ?? []), + ]; + const seenNames = new Set(); + for (const provider of providers) { + if (seenNames.has(provider.name)) { + throw new Error( + `Duplicate provider name: '${provider.name}'. Each MCP server and sandbox client provider must have a unique name.` + ); } + seenNames.add(provider.name); + allActivities = { ...allActivities, ...provider._getActivities() }; } - const interceptorOpts = options.interceptorOptions; const activityInterceptorOptions: OpenAIAgentsTraceInterceptorOptions = { addTemporalSpans: interceptorOpts?.addTemporalSpans, }; diff --git a/contrib/openai-agents/src/worker/sandbox-provider.ts b/contrib/openai-agents/src/worker/sandbox-provider.ts new file mode 100644 index 000000000..2d0299354 --- /dev/null +++ b/contrib/openai-agents/src/worker/sandbox-provider.ts @@ -0,0 +1,452 @@ +import { randomUUID } from 'node:crypto'; +import { + getCurrentTrace, + withCustomSpan, + type ApplyPatchOperation, + type ApplyPatchResult, + type Editor, +} from '@openai/agents-core'; +import type { + SandboxClient, + SandboxClientOptions, + SandboxSession, + SandboxSessionState, +} from '@openai/agents-core/sandbox'; +import { SandboxError } from '@openai/agents-core/sandbox'; +import { ApplicationFailure } from '@temporalio/common'; +import { + SANDBOX_CLIENT_CREATE_SUFFIX, + SANDBOX_CLIENT_DELETE_SUFFIX, + SANDBOX_CLIENT_RESUME_SUFFIX, + SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX, + SANDBOX_EDITOR_CREATE_FILE_SUFFIX, + SANDBOX_EDITOR_DELETE_FILE_SUFFIX, + SANDBOX_EDITOR_UPDATE_FILE_SUFFIX, + SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX, + SANDBOX_SESSION_DELETE_SUFFIX, + SANDBOX_SESSION_EXEC_COMMAND_SUFFIX, + SANDBOX_SESSION_EXEC_SUFFIX, + SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX, + SANDBOX_SESSION_LIST_DIR_SUFFIX, + SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX, + SANDBOX_SESSION_PATH_EXISTS_SUFFIX, + SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX, + SANDBOX_SESSION_READ_FILE_SUFFIX, + SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX, + SANDBOX_SESSION_RUNNING_SUFFIX, + SANDBOX_SESSION_SHUTDOWN_SUFFIX, + SANDBOX_SESSION_START_SUFFIX, + SANDBOX_SESSION_STOP_SUFFIX, + SANDBOX_SESSION_VIEW_IMAGE_SUFFIX, + SANDBOX_SESSION_WRITE_STDIN_SUFFIX, + decodeEntry, + decodeManifest, + encodeManifest, + encodeToolOutputImage, + sandboxSpanName, + serializeSessionEnvelope, + toSdkStateRecord, + type EncodedManifest, + type SandboxApplyManifestInput, + type SandboxCreateSessionInput, + type SandboxEditorInput, + type SandboxExecInput, + type SandboxHydrateWorkspaceInput, + type SandboxLifecycleInput, + type SandboxListDirInput, + type SandboxMaterializeEntryInput, + type SandboxPathExistsInput, + type SandboxPersistWorkspaceInput, + type SandboxReadFileInput, + type SandboxResolveExposedPortInput, + type SandboxResumeSessionInput, + type SandboxSerializeSessionStateInput, + type SandboxSessionResult, + type SandboxSessionStateInput, + type SandboxViewImageInput, + type SandboxWriteStdinInput, + type SerializedSandboxSessionState, +} from '../common/sandbox-activity-types'; + +type ActivityFunction = (...args: any[]) => Promise; + +/** + * Temporal retries every Activity exception by default, so only a + * SandboxError the agents library has classified as terminal + * (`retryable === false`) is turned into a non-retryable ApplicationFailure. + */ +function translateSandboxErrors(fn: ActivityFunction): ActivityFunction { + return async (...args: unknown[]) => { + try { + return await fn(...args); + } catch (err: unknown) { + if (err instanceof SandboxError && err.retryable === false) { + throw ApplicationFailure.create({ + message: err.message, + type: err.code, + nonRetryable: true, + cause: err, + }); + } + throw err; + } + }; +} + +function sessionIdOf(input: unknown): string | undefined { + const sessionId = (input as { state?: { sessionId?: unknown } } | undefined)?.state?.sessionId; + return typeof sessionId === 'string' ? sessionId : undefined; +} + +function summarizeResult(result: unknown): Record { + if (result && typeof result === 'object' && typeof (result as { exitCode?: unknown }).exitCode === 'number') { + return { exitCode: (result as { exitCode: number }).exitCode }; + } + if (result instanceof Uint8Array) return { byteLength: result.byteLength }; + if (typeof result === 'string') return { length: result.length }; + if (Array.isArray(result)) return { count: result.length }; + return {}; +} + +function unsupportedOperation(name: string, operation: string): never { + throw ApplicationFailure.create({ + message: `Sandbox backend '${name}' does not support ${operation}().`, + type: 'SandboxOperationUnsupported', + nonRetryable: true, + }); +} + +/** + * A named sandbox client provider for Temporal Workflows. + * + * Wraps a real `SandboxClient` with a unique name so multiple sandbox + * backends can be registered on a single Temporal Worker. Each provider gets + * its own set of Activities whose names are prefixed with the provider name. + * Live sessions are cached by session id; any Worker can serve any session's + * Activities because a cache miss is self-healed via `client.resume()`. + * + * On the Worker side, pass providers to the plugin: + * + * ```ts + * const plugin = new OpenAIAgentsPlugin({ + * modelProvider, + * sandboxClientProviders: [new SandboxClientProvider('local', new UnixLocalSandboxClient())], + * }); + * ``` + * + * On the Workflow side, reference a provider by name via + * `temporalSandboxClient` from `@temporalio/openai-agents/workflow`: + * + * ```ts + * runConfig: { sandbox: { client: temporalSandboxClient('local') } } + * ``` + * + * @experimental Sandbox support is experimental and may change without notice. + */ +export class SandboxClientProvider { + private readonly _sessions = new Map(); + private readonly _resuming = new Map>(); + /** @internal */ + _addTemporalSpans = false; + + constructor( + public readonly name: string, + private readonly client: SandboxClient + ) {} + + private async rehydrateState(payload: SerializedSandboxSessionState): Promise { + const record = toSdkStateRecord(payload); + if (this.client.deserializeSessionState) { + return await this.client.deserializeSessionState(record); + } + return { ...record, manifest: decodeManifest(payload.manifest) } as SandboxSessionState; + } + + private async session(payload: SerializedSandboxSessionState): Promise { + const existing = this._sessions.get(payload.sessionId); + if (existing) return existing; + // The agents run loop executes tools in parallel, so several Activities can + // cache-miss the same session at once; share one resume and clear the entry + // in `finally` so a failed resume isn't memoized and a later call can retry. + const inFlight = this._resuming.get(payload.sessionId); + if (inFlight) return inFlight; + if (!this.client.resume) { + throw ApplicationFailure.create({ + message: `Sandbox backend '${this.name}' does not support resume(); session '${payload.sessionId}' cannot be recovered on this Worker.`, + type: 'SandboxOperationUnsupported', + nonRetryable: true, + }); + } + const resume = this.client.resume.bind(this.client); + const resumePromise = (async (): Promise => { + const session = await resume(await this.rehydrateState(payload)); + this._sessions.set(payload.sessionId, session); + return session; + })(); + this._resuming.set(payload.sessionId, resumePromise); + try { + return await resumePromise; + } finally { + this._resuming.delete(payload.sessionId); + } + } + + private async providerState(state: SandboxSessionState): Promise> { + if (this.client.serializeSessionState) { + return await this.client.serializeSessionState(state); + } + // Without a real serializer, drop only the envelope-owned fields already + // serialized at the session handle's top level; keep everything else — + // including the resolved `environment` — so a resumed session is complete. + const { + manifest: _manifest, + snapshot: _snapshot, + snapshotFingerprint: _snapshotFingerprint, + snapshotFingerprintVersion: _snapshotFingerprintVersion, + workspaceReady: _workspaceReady, + exposedPorts: _exposedPorts, + ...rest + } = state; + return rest; + } + + private async sessionResult(sessionId: string, session: SandboxSession): Promise { + return { + state: serializeSessionEnvelope(sessionId, session.state, await this.providerState(session.state)), + supportsPty: session.supportsPty?.() ?? false, + }; + } + + private async editor(input: SandboxEditorInput): Promise { + const session = await this.session(input.state); + if (!session.createEditor) unsupportedOperation(this.name, 'createEditor'); + return session.createEditor(input.runAs); + } + + _getActivities(): Record { + const n = this.name; + + const activities: Record = { + [`${n}${SANDBOX_CLIENT_CREATE_SUFFIX}`]: async ( + input: SandboxCreateSessionInput + ): Promise => { + if (!this.client.create) unsupportedOperation(n, 'create'); + const session = await this.client.create({ + manifest: input.manifest && decodeManifest(input.manifest), + snapshot: input.snapshot, + options: input.options, + concurrencyLimits: input.concurrencyLimits, + archiveLimits: input.archiveLimits, + }); + // At-least-once Activity execution: `SandboxClient.create` takes no + // idempotency key, so a retry after the backend session is created but + // before this result is recorded leaks that first session. + const sessionId = randomUUID(); + this._sessions.set(sessionId, session); + return this.sessionResult(sessionId, session); + }, + + [`${n}${SANDBOX_CLIENT_RESUME_SUFFIX}`]: async ( + input: SandboxResumeSessionInput + ): Promise => { + if (!this.client.resume) unsupportedOperation(n, 'resume'); + const session = await this.client.resume( + await this.rehydrateState(input.state), + input.archiveLimits !== undefined ? { archiveLimits: input.archiveLimits } : undefined + ); + this._sessions.set(input.state.sessionId, session); + return this.sessionResult(input.state.sessionId, session); + }, + + [`${n}${SANDBOX_CLIENT_DELETE_SUFFIX}`]: async (input: SandboxSessionStateInput): Promise => { + const state = this._sessions.get(input.state.sessionId)?.state ?? (await this.rehydrateState(input.state)); + try { + await this.client.delete?.(state); + } finally { + this._sessions.delete(input.state.sessionId); + } + }, + + [`${n}${SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX}`]: async ( + input: SandboxSerializeSessionStateInput + ): Promise> => { + const session = await this.session(input.state); + const providerState = this.client.serializeSessionState + ? await this.client.serializeSessionState(session.state, input.options) + : await this.providerState(session.state); + return { sessionId: input.state.sessionId, providerState }; + }, + + [`${n}${SANDBOX_SESSION_START_SUFFIX}`]: async (input: SandboxLifecycleInput): Promise => { + const session = await this.session(input.state); + await session.start?.(input.options); + }, + + [`${n}${SANDBOX_SESSION_RUNNING_SUFFIX}`]: async (input: SandboxSessionStateInput): Promise => { + const session = await this.session(input.state); + return session.running ? await session.running() : false; + }, + + [`${n}${SANDBOX_SESSION_STOP_SUFFIX}`]: async (input: SandboxLifecycleInput): Promise => { + const session = await this.session(input.state); + await session.stop?.(input.options); + }, + + [`${n}${SANDBOX_SESSION_SHUTDOWN_SUFFIX}`]: async (input: SandboxLifecycleInput): Promise => { + const session = await this.session(input.state); + await session.shutdown?.(input.options); + }, + + [`${n}${SANDBOX_SESSION_DELETE_SUFFIX}`]: async (input: SandboxLifecycleInput): Promise => { + const session = await this.session(input.state); + try { + if (session.delete) { + await session.delete(input.options); + } else if (!session.stop && !session.shutdown) { + await session.close?.(); + } + } finally { + this._sessions.delete(input.state.sessionId); + } + }, + + [`${n}${SANDBOX_SESSION_EXEC_SUFFIX}`]: async (input: SandboxExecInput) => { + const session = await this.session(input.state); + if (!session.exec) unsupportedOperation(n, 'exec'); + return session.exec(input.args); + }, + + [`${n}${SANDBOX_SESSION_EXEC_COMMAND_SUFFIX}`]: async (input: SandboxExecInput): Promise => { + const session = await this.session(input.state); + if (!session.execCommand) unsupportedOperation(n, 'execCommand'); + return session.execCommand(input.args); + }, + + [`${n}${SANDBOX_SESSION_WRITE_STDIN_SUFFIX}`]: async (input: SandboxWriteStdinInput): Promise => { + const session = await this.session(input.state); + if (!session.writeStdin) unsupportedOperation(n, 'writeStdin'); + return session.writeStdin(input.args); + }, + + [`${n}${SANDBOX_SESSION_VIEW_IMAGE_SUFFIX}`]: async (input: SandboxViewImageInput) => { + const session = await this.session(input.state); + if (!session.viewImage) unsupportedOperation(n, 'viewImage'); + return encodeToolOutputImage(await session.viewImage(input.args)); + }, + + [`${n}${SANDBOX_SESSION_READ_FILE_SUFFIX}`]: async ( + input: SandboxReadFileInput + ): Promise => { + const session = await this.session(input.state); + if (!session.readFile) unsupportedOperation(n, 'readFile'); + return session.readFile(input.args); + }, + + [`${n}${SANDBOX_SESSION_LIST_DIR_SUFFIX}`]: async (input: SandboxListDirInput) => { + const session = await this.session(input.state); + if (!session.listDir) unsupportedOperation(n, 'listDir'); + return session.listDir(input.args); + }, + + [`${n}${SANDBOX_SESSION_PATH_EXISTS_SUFFIX}`]: async (input: SandboxPathExistsInput): Promise => { + const session = await this.session(input.state); + if (!session.pathExists) unsupportedOperation(n, 'pathExists'); + return session.pathExists(input.path, input.runAs); + }, + + [`${n}${SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX}`]: async ( + input: SandboxMaterializeEntryInput + ): Promise => { + const session = await this.session(input.state); + if (!session.materializeEntry) unsupportedOperation(n, 'materializeEntry'); + await session.materializeEntry({ path: input.path, entry: decodeEntry(input.entry), runAs: input.runAs }); + return encodeManifest(session.state.manifest); + }, + + [`${n}${SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX}`]: async ( + input: SandboxApplyManifestInput + ): Promise => { + const session = await this.session(input.state); + const manifest = decodeManifest(input.manifest); + if (session.applyManifest) { + await session.applyManifest(manifest, input.runAs); + } else if (session.materializeEntry) { + for (const [path, entry] of Object.entries(manifest.entries)) { + await session.materializeEntry({ path, entry, runAs: input.runAs }); + } + } else { + unsupportedOperation(n, 'applyManifest'); + } + return encodeManifest(session.state.manifest); + }, + + [`${n}${SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX}`]: async ( + input: SandboxPersistWorkspaceInput + ): Promise => { + const session = await this.session(input.state); + if (!session.persistWorkspace) unsupportedOperation(n, 'persistWorkspace'); + if (input.archiveLimits !== undefined) session.setArchiveLimits?.(input.archiveLimits); + return session.persistWorkspace(); + }, + + [`${n}${SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX}`]: async ( + input: SandboxHydrateWorkspaceInput, + data: string | Uint8Array + ): Promise => { + const session = await this.session(input.state); + if (!session.hydrateWorkspace) unsupportedOperation(n, 'hydrateWorkspace'); + await session.hydrateWorkspace( + data, + input.archiveLimits !== undefined ? { archiveLimits: input.archiveLimits } : undefined + ); + }, + + [`${n}${SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX}`]: async (input: SandboxResolveExposedPortInput) => { + const session = await this.session(input.state); + if (!session.resolveExposedPort) unsupportedOperation(n, 'resolveExposedPort'); + return session.resolveExposedPort(input.port); + }, + + [`${n}${SANDBOX_EDITOR_CREATE_FILE_SUFFIX}`]: async ( + input: SandboxEditorInput + ): Promise => { + const editor = await this.editor(input); + return editor.createFile(input.operation as Extract); + }, + + [`${n}${SANDBOX_EDITOR_UPDATE_FILE_SUFFIX}`]: async ( + input: SandboxEditorInput + ): Promise => { + const editor = await this.editor(input); + return editor.updateFile(input.operation as Extract); + }, + + [`${n}${SANDBOX_EDITOR_DELETE_FILE_SUFFIX}`]: async ( + input: SandboxEditorInput + ): Promise => { + const editor = await this.editor(input); + return editor.deleteFile(input.operation as Extract); + }, + }; + + return Object.fromEntries( + Object.entries(activities).map(([name, fn]) => [name, this.withSpan(name, translateSandboxErrors(fn))]) + ); + } + + private withSpan(name: string, fn: ActivityFunction): ActivityFunction { + const spanName = `${sandboxSpanName(name.slice(this.name.length))}:result`; + return async (...args: unknown[]) => { + const sessionId = sessionIdOf(args[0]); + if (!this._addTemporalSpans || !sessionId || !getCurrentTrace()) return fn(...args); + return withCustomSpan( + async (span) => { + const result = await fn(...args); + Object.assign(span.spanData.data, { sessionId, ...summarizeResult(result) }); + return result; + }, + { data: { name: spanName, data: {} } } + ); + }; + } +} diff --git a/contrib/openai-agents/src/workflow.ts b/contrib/openai-agents/src/workflow.ts index 0d159df62..bf4ea30de 100644 --- a/contrib/openai-agents/src/workflow.ts +++ b/contrib/openai-agents/src/workflow.ts @@ -15,3 +15,5 @@ export type { StatefulTemporalMCPServer } from './common/mcp-types'; export { DEDICATED_WORKER_FAILURE_TYPE } from './common/mcp-types'; export { statefulMcpServer } from './workflow/stateful-mcp-client'; export type { StatefulMcpServerOptions } from './workflow/stateful-mcp-client'; +export { temporalSandboxClient, TemporalSandboxClient } from './workflow/sandbox-client'; +export type { TemporalSandboxClientOptions } from './workflow/sandbox-client'; diff --git a/contrib/openai-agents/src/workflow/runner.ts b/contrib/openai-agents/src/workflow/runner.ts index 6a50dc9f7..75c302d03 100644 --- a/contrib/openai-agents/src/workflow/runner.ts +++ b/contrib/openai-agents/src/workflow/runner.ts @@ -1,6 +1,7 @@ import { type Agent, type AgentInputItem, + Handoff, MemorySession, Runner, RunState, @@ -16,6 +17,7 @@ import { type StreamedRunResult, type TracingConfig, } from '@openai/agents-core'; +import { SandboxAgent, type SandboxRunConfig } from '@openai/agents-core/sandbox'; import { ApplicationFailure } from '@temporalio/common'; import { DEFAULT_MODEL_ACTIVITY_OPTIONS, @@ -26,6 +28,7 @@ import { } from '../common/model-activity-options'; import { unwrapTemporalFailure } from '../common/errors'; import { convertAgent } from './convert-agent'; +import { TemporalSandboxClient } from './sandbox-client'; import { ensureTracingProcessorRegistered } from './tracing'; import { flushOpenSpans } from './agent-sink-processor'; import { getCurrentPluginConfig } from './plugin-config-store'; @@ -81,6 +84,13 @@ export interface TemporalRunOptions { groupId?: string; /** Additional metadata attached to the trace */ traceMetadata?: Record; + /** + * Sandbox runtime configuration used when execution reaches a `SandboxAgent`. + * `client` must be created via `temporalSandboxClient(name)`. + * + * @experimental Sandbox support is experimental and may change without notice. + */ + sandbox?: SandboxRunConfig; }; } @@ -121,6 +131,54 @@ function definedFields(obj: T | undefined): Partial { return result; } +/** Whether a `SandboxAgent` is reachable from `agent` through its handoff graph. */ +export function hasSandboxAgent(agent: Agent, seen: Set> = new Set()): boolean { + if (agent instanceof SandboxAgent) return true; + if (seen.has(agent)) return false; + seen.add(agent); + for (const handoff of agent.handoffs ?? []) { + const target = handoff instanceof Handoff ? handoff.agent : handoff; + if (hasSandboxAgent(target, seen)) return true; + } + return false; +} + +/** + * `runConfig.sandbox.client` must be a `TemporalSandboxClient` so every sandbox + * operation is dispatched as an Activity rather than run inline in the Workflow. + */ +export function validateSandboxRunConfig(agent: Agent, sandbox: SandboxRunConfig | undefined): void { + if (!hasSandboxAgent(agent) && sandbox === undefined) return; + if (sandbox === undefined) { + throw ApplicationFailure.create({ + message: + 'A SandboxAgent was provided but runConfig.sandbox is not configured. ' + + 'Set runConfig.sandbox with a client created via temporalSandboxClient(name) ' + + 'from @temporalio/openai-agents/workflow.', + type: 'SandboxConfigurationError', + nonRetryable: true, + }); + } + if (sandbox.client == null) { + throw ApplicationFailure.create({ + message: + 'runConfig.sandbox.client must be set to a Temporal sandbox client. ' + + 'Use temporalSandboxClient(name) from @temporalio/openai-agents/workflow.', + type: 'SandboxConfigurationError', + nonRetryable: true, + }); + } + if (!(sandbox.client instanceof TemporalSandboxClient)) { + throw ApplicationFailure.create({ + message: + 'runConfig.sandbox.client must be created via temporalSandboxClient(name) ' + + 'from @temporalio/openai-agents/workflow. Do not pass a raw sandbox client directly.', + type: 'SandboxConfigurationError', + nonRetryable: true, + }); + } +} + /** * A Temporal-aware agent runner that delegates model calls to Activities. * @@ -187,6 +245,8 @@ export class TemporalOpenAIRunner { }); } + validateSandboxRunConfig(agent, options?.runConfig?.sandbox); + const { model: modelOverride, ...runnerConfigOverrides } = options?.runConfig ?? {}; const converted = convertAgent(agent, this.modelParams, undefined, modelOverride); diff --git a/contrib/openai-agents/src/workflow/sandbox-client.ts b/contrib/openai-agents/src/workflow/sandbox-client.ts new file mode 100644 index 000000000..a7dcd3c01 --- /dev/null +++ b/contrib/openai-agents/src/workflow/sandbox-client.ts @@ -0,0 +1,187 @@ +import type { + SandboxClient, + SandboxClientCreateArgs, + SandboxClientOptions, + SandboxClientResumeOptions, + SandboxSessionLike, + SandboxSessionSerializationOptions, +} from '@openai/agents-core/sandbox'; +import { Manifest } from '@openai/agents-core/sandbox'; +import { ApplicationFailure, type ActivityOptions, type Duration, type RetryPolicy } from '@temporalio/common'; +import { scheduleActivity } from '@temporalio/workflow'; +import { + SANDBOX_CLIENT_CREATE_SUFFIX, + SANDBOX_CLIENT_DELETE_SUFFIX, + SANDBOX_CLIENT_RESUME_SUFFIX, + SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX, + decodeManifest, + encodeManifest, + reviveWorkflowSessionState, + sandboxSpanName, + serializeSessionEnvelope, + type EncodedManifest, + type SandboxSessionResult, + type TemporalSandboxSessionState, +} from '../common/sandbox-activity-types'; +import { TemporalSandboxSession } from './sandbox-session'; +import { maybeTemporalSpan } from './span-helpers'; + +/** Activity options for sandbox operations dispatched by this client's sessions. */ +export interface TemporalSandboxClientOptions { + startToCloseTimeout?: Duration; + scheduleToStartTimeout?: Duration; + heartbeatTimeout?: Duration; + taskQueue?: string; + retryPolicy?: RetryPolicy; +} + +/** + * Workflow-side sandbox client. Holds no connection to any sandbox backend — + * session creation, resumption, and every session operation are dispatched as + * Activities to the `SandboxClientProvider` registered under the same name on + * the Worker. + * + * Create instances via {@link temporalSandboxClient}. + */ +export class TemporalSandboxClient implements SandboxClient { + readonly backendId: string; + private readonly _name: string; + private readonly _config: ActivityOptions; + + constructor(name: string, options?: TemporalSandboxClientOptions) { + this._name = name; + this.backendId = name; + this._config = { + startToCloseTimeout: options?.startToCloseTimeout ?? '5 minutes', + scheduleToStartTimeout: options?.scheduleToStartTimeout, + heartbeatTimeout: options?.heartbeatTimeout, + taskQueue: options?.taskQueue, + retry: options?.retryPolicy, + }; + } + + async create( + argsOrManifest?: SandboxClientCreateArgs | Manifest, + manifestOptions?: SandboxClientOptions + ): Promise> { + const args: SandboxClientCreateArgs = + argsOrManifest instanceof Manifest + ? { manifest: argsOrManifest, options: manifestOptions } + : argsOrManifest ?? {}; + let manifest: EncodedManifest | undefined; + if (args.manifest !== undefined) { + manifest = encodeManifest(args.manifest instanceof Manifest ? args.manifest : new Manifest(args.manifest)); + } + const result = await scheduleActivity( + `${this._name}${SANDBOX_CLIENT_CREATE_SUFFIX}`, + [ + { + manifest, + snapshot: args.snapshot, + options: args.options, + concurrencyLimits: args.concurrencyLimits, + archiveLimits: args.archiveLimits, + }, + ], + this._config + ); + return this.wrapSession(result); + } + + async resume( + state: TemporalSandboxSessionState, + options?: SandboxClientResumeOptions + ): Promise> { + const result = await maybeTemporalSpan( + sandboxSpanName(SANDBOX_CLIENT_RESUME_SUFFIX), + () => + scheduleActivity( + `${this._name}${SANDBOX_CLIENT_RESUME_SUFFIX}`, + [ + { + state: serializeSessionEnvelope(state.sessionId, state, state.providerState), + archiveLimits: options?.archiveLimits, + }, + ], + this._config + ), + { sessionId: state.sessionId } + ); + return this.wrapSession(result); + } + + async delete(state: TemporalSandboxSessionState): Promise { + await maybeTemporalSpan( + sandboxSpanName(SANDBOX_CLIENT_DELETE_SUFFIX), + () => + scheduleActivity( + `${this._name}${SANDBOX_CLIENT_DELETE_SUFFIX}`, + [{ state: serializeSessionEnvelope(state.sessionId, state, state.providerState) }], + this._config + ), + { sessionId: state.sessionId } + ); + } + + async serializeSessionState( + state: TemporalSandboxSessionState, + options?: SandboxSessionSerializationOptions + ): Promise> { + return maybeTemporalSpan( + sandboxSpanName(SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX), + () => + scheduleActivity>( + `${this._name}${SANDBOX_CLIENT_SERIALIZE_SESSION_STATE_SUFFIX}`, + [{ state: serializeSessionEnvelope(state.sessionId, state, state.providerState), options }], + this._config + ), + { sessionId: state.sessionId } + ); + } + + async deserializeSessionState(state: Record): Promise { + const { sessionId, providerState, manifest, ...rest } = state; + if (typeof sessionId !== 'string') { + throw ApplicationFailure.create({ + message: + 'Serialized sandbox session state is missing a sessionId — it was not produced by a Temporal sandbox client.', + type: 'SandboxSessionStateInvalid', + nonRetryable: true, + }); + } + if (manifest == null) { + throw ApplicationFailure.create({ + message: + 'Serialized sandbox session state is missing a manifest — it was not produced by a Temporal sandbox client.', + type: 'SandboxSessionStateInvalid', + nonRetryable: true, + }); + } + return { + ...rest, + sessionId, + providerState: (providerState ?? {}) as Record, + manifest: decodeManifest(manifest as EncodedManifest), + }; + } + + private wrapSession(result: SandboxSessionResult): TemporalSandboxSession { + return new TemporalSandboxSession( + this._name, + this._config, + reviveWorkflowSessionState(result.state), + result.supportsPty + ); + } +} + +/** + * Creates a Workflow-side sandbox client for `RunConfig.sandbox`. All sandbox + * operations are dispatched as Activities to the `SandboxClientProvider` + * registered under the same name on the Worker. + * + * @param name - Provider name; must match the `SandboxClientProvider` registered on the Worker side. + */ +export function temporalSandboxClient(name: string, options?: TemporalSandboxClientOptions): TemporalSandboxClient { + return new TemporalSandboxClient(name, options); +} diff --git a/contrib/openai-agents/src/workflow/sandbox-session.ts b/contrib/openai-agents/src/workflow/sandbox-session.ts new file mode 100644 index 000000000..6460e4a05 --- /dev/null +++ b/contrib/openai-agents/src/workflow/sandbox-session.ts @@ -0,0 +1,243 @@ +import type { + ApplyPatchOperation, + ApplyPatchResult, + Editor, + EditorInvocationContext, + ToolOutputImage, +} from '@openai/agents-core'; +import type { + ExecCommandArgs, + ExposedPortEndpoint, + ListDirectoryArgs, + Manifest, + MaterializeEntryArgs, + ReadFileArgs, + SandboxArchiveLimits, + SandboxDirectoryEntry, + SandboxExecResult, + SandboxSession, + SandboxSessionLifecycleOptions, + ViewImageArgs, + WorkspaceArchiveData, + WorkspaceArchiveOptions, + WriteStdinArgs, +} from '@openai/agents-core/sandbox'; +import { recordExposedPortEndpoint } from '@openai/agents-core/sandbox'; +import type { ActivityOptions } from '@temporalio/common'; +import { scheduleActivity } from '@temporalio/workflow'; +import { + SANDBOX_EDITOR_CREATE_FILE_SUFFIX, + SANDBOX_EDITOR_DELETE_FILE_SUFFIX, + SANDBOX_EDITOR_UPDATE_FILE_SUFFIX, + SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX, + SANDBOX_SESSION_DELETE_SUFFIX, + SANDBOX_SESSION_EXEC_COMMAND_SUFFIX, + SANDBOX_SESSION_EXEC_SUFFIX, + SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX, + SANDBOX_SESSION_LIST_DIR_SUFFIX, + SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX, + SANDBOX_SESSION_PATH_EXISTS_SUFFIX, + SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX, + SANDBOX_SESSION_READ_FILE_SUFFIX, + SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX, + SANDBOX_SESSION_RUNNING_SUFFIX, + SANDBOX_SESSION_SHUTDOWN_SUFFIX, + SANDBOX_SESSION_START_SUFFIX, + SANDBOX_SESSION_STOP_SUFFIX, + SANDBOX_SESSION_VIEW_IMAGE_SUFFIX, + SANDBOX_SESSION_WRITE_STDIN_SUFFIX, + decodeManifest, + decodeToolOutputImage, + encodeEntry, + encodeManifest, + sandboxSpanName, + serializeSessionEnvelope, + type EncodedManifest, + type SerializedSandboxSessionState, + type SerializedToolOutputImage, + type TemporalSandboxSessionState, +} from '../common/sandbox-activity-types'; +import { maybeTemporalSpan } from './span-helpers'; + +/** + * Workflow-side handle for a sandbox session. Holds only serializable state; + * every real sandbox operation is dispatched as an Activity to the + * `SandboxClientProvider` registered under the same name on the Worker. + * + * `registerPreStopHook` is intentionally not implemented so the SDK runs its + * managed pre-stop-hook fallback instead. + */ +export class TemporalSandboxSession implements SandboxSession { + state: TemporalSandboxSessionState; + private readonly _name: string; + private readonly _config: ActivityOptions; + private readonly _supportsPty: boolean; + private _archiveLimits: SandboxArchiveLimits | null | undefined; + + constructor(name: string, config: ActivityOptions, state: TemporalSandboxSessionState, supportsPty: boolean) { + this._name = name; + this._config = config; + this.state = state; + this._supportsPty = supportsPty; + } + + private stateInput(): SerializedSandboxSessionState { + return serializeSessionEnvelope(this.state.sessionId, this.state, this.state.providerState); + } + + private dispatch(suffix: string, input: Record, extraArgs: unknown[] = []): Promise { + const data: Record = { sessionId: this.state.sessionId }; + if (typeof input.port === 'number') data.port = input.port; + return maybeTemporalSpan( + sandboxSpanName(suffix), + () => + scheduleActivity( + `${this._name}${suffix}`, + [{ state: this.stateInput(), ...input }, ...extraArgs], + this._config + ), + data + ); + } + + async start(options?: SandboxSessionLifecycleOptions): Promise { + await this.dispatch(SANDBOX_SESSION_START_SUFFIX, { options }); + } + + async running(): Promise { + return this.dispatch(SANDBOX_SESSION_RUNNING_SUFFIX, {}); + } + + async stop(options?: SandboxSessionLifecycleOptions): Promise { + await this.dispatch(SANDBOX_SESSION_STOP_SUFFIX, { options }); + } + + async shutdown(options?: SandboxSessionLifecycleOptions): Promise { + await this.dispatch(SANDBOX_SESSION_SHUTDOWN_SUFFIX, { options }); + } + + async delete(options?: SandboxSessionLifecycleOptions): Promise { + await this.dispatch(SANDBOX_SESSION_DELETE_SUFFIX, { options }); + } + + createEditor(runAs?: string): Editor { + return new TemporalEditor(this._name, this._config, () => this.stateInput(), runAs); + } + + async exec(args: ExecCommandArgs): Promise { + return this.dispatch(SANDBOX_SESSION_EXEC_SUFFIX, { args }); + } + + async execCommand(args: ExecCommandArgs): Promise { + return this.dispatch(SANDBOX_SESSION_EXEC_COMMAND_SUFFIX, { args }); + } + + async writeStdin(args: WriteStdinArgs): Promise { + return this.dispatch(SANDBOX_SESSION_WRITE_STDIN_SUFFIX, { args }); + } + + async viewImage(args: ViewImageArgs): Promise { + const serialized = await this.dispatch(SANDBOX_SESSION_VIEW_IMAGE_SUFFIX, { args }); + return decodeToolOutputImage(serialized); + } + + async readFile(args: ReadFileArgs): Promise { + return this.dispatch(SANDBOX_SESSION_READ_FILE_SUFFIX, { args }); + } + + async listDir(args: ListDirectoryArgs): Promise { + return this.dispatch(SANDBOX_SESSION_LIST_DIR_SUFFIX, { args }); + } + + async pathExists(path: string, runAs?: string): Promise { + return this.dispatch(SANDBOX_SESSION_PATH_EXISTS_SUFFIX, { path, runAs }); + } + + async materializeEntry(args: MaterializeEntryArgs): Promise { + const updated = await this.dispatch(SANDBOX_SESSION_MATERIALIZE_ENTRY_SUFFIX, { + path: args.path, + entry: encodeEntry(args.entry), + runAs: args.runAs, + }); + this.state.manifest = decodeManifest(updated); + } + + async applyManifest(manifest: Manifest, runAs?: string): Promise { + const updated = await this.dispatch(SANDBOX_SESSION_APPLY_MANIFEST_SUFFIX, { + manifest: encodeManifest(manifest), + runAs, + }); + this.state.manifest = decodeManifest(updated); + } + + async persistWorkspace(): Promise { + return this.dispatch(SANDBOX_SESSION_PERSIST_WORKSPACE_SUFFIX, { archiveLimits: this._archiveLimits }); + } + + async hydrateWorkspace(data: WorkspaceArchiveData, options?: WorkspaceArchiveOptions): Promise { + const payload = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + await this.dispatch( + SANDBOX_SESSION_HYDRATE_WORKSPACE_SUFFIX, + { archiveLimits: options?.archiveLimits ?? this._archiveLimits }, + [payload] + ); + } + + setArchiveLimits(limits?: SandboxArchiveLimits | null): void { + this._archiveLimits = limits; + } + + async resolveExposedPort(port: number): Promise { + const endpoint = await this.dispatch(SANDBOX_SESSION_RESOLVE_EXPOSED_PORT_SUFFIX, { port }); + recordExposedPortEndpoint(this.state, endpoint, port); + return endpoint; + } + + supportsPty(): boolean { + return this._supportsPty; + } +} + +class TemporalEditor implements Editor { + constructor( + private readonly name: string, + private readonly config: ActivityOptions, + private readonly stateInput: () => SerializedSandboxSessionState, + private readonly runAs?: string + ) {} + + private apply(suffix: string, operation: ApplyPatchOperation): Promise { + const state = this.stateInput(); + return maybeTemporalSpan( + sandboxSpanName(suffix), + () => + scheduleActivity( + `${this.name}${suffix}`, + [{ state, operation, runAs: this.runAs }], + this.config + ), + { sessionId: state.sessionId } + ); + } + + async createFile( + operation: Extract, + _context?: EditorInvocationContext + ): Promise { + return this.apply(SANDBOX_EDITOR_CREATE_FILE_SUFFIX, operation); + } + + async updateFile( + operation: Extract, + _context?: EditorInvocationContext + ): Promise { + return this.apply(SANDBOX_EDITOR_UPDATE_FILE_SUFFIX, operation); + } + + async deleteFile( + operation: Extract, + _context?: EditorInvocationContext + ): Promise { + return this.apply(SANDBOX_EDITOR_DELETE_FILE_SUFFIX, operation); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 752b42dfd..bdf1ffe3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,7 +373,7 @@ importers: version: 5.3.1 langsmith: specifier: ^0.7.9 - version: 0.7.10(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.36.0(zod@4.4.3)) + version: 0.7.10(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3)) nexus-rpc: specifier: ^0.0.2 version: 0.0.2 @@ -412,11 +412,11 @@ importers: version: 4.2.0 devDependencies: '@openai/agents-core': - specifier: ~0.11.6 - version: 0.11.6(zod@4.4.3) + specifier: ~0.13.5 + version: 0.13.5(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) '@openai/agents-openai': - specifier: ~0.11.6 - version: 0.11.6(zod@4.4.3) + specifier: ~0.13.5 + version: 0.13.5(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) '@opentelemetry/core': specifier: ^1.25.1 version: 1.25.1(@opentelemetry/api@1.9.1) @@ -483,7 +483,7 @@ importers: version: 1.26.0(zod@4.4.3) '@strands-agents/sdk': specifier: ^1.3.0 - version: 1.6.0(@ai-sdk/provider@3.0.0)(@aws-sdk/client-s3@3.1060.0)(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.1))(@smithy/types@4.14.3)(express@5.2.1)(openai@6.36.0(zod@4.4.3))(zod@4.4.3) + version: 1.6.0(@ai-sdk/provider@3.0.0)(@aws-sdk/client-s3@3.1060.0)(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.1))(@smithy/types@4.14.3)(express@5.2.1)(openai@6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3))(zod@4.4.3) '@temporalio/client': specifier: workspace:* version: link:../../packages/client @@ -1926,16 +1926,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openai/agents-core@0.11.6': - resolution: {integrity: sha512-jWeo4mF+zjp9R80OPg+9prAnwF53dIpok2ymp9OkC3DpK2qcqBO8CfoEgocNp+E5HXXFW4uvCaY0olNCCcmTFw==} + '@openai/agents-core@0.13.5': + resolution: {integrity: sha512-RI9OwHG94c6ZTLNeEB7mfIpHbncgVxu4YElAmCAhq04EqLPzsLyouWhDgli8wZL/IhN/cYTaBwHvxktFzEbeyQ==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: zod: optional: true - '@openai/agents-openai@0.11.6': - resolution: {integrity: sha512-63zXy+EPmg3WErLO12jLEjCTqGBZ/f0ApsW/t5wpccqUYCtImIT6IYZDgGM+zSEafHNGJmNOwGtEqHjz/Pyl+A==} + '@openai/agents-openai@0.13.5': + resolution: {integrity: sha512-DfItyOZxE7znrJMj8V8zV7qg9Ig1Q3u6/GfpkG0aTyj3NCAY8BbaxzLz0Qynlon9K4XBvo7LL8Nvxb5/FMeQcA==} peerDependencies: zod: ^4.0.0 @@ -5040,6 +5040,26 @@ packages: zod: optional: true + openai@6.48.0: + resolution: {integrity: sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -7203,26 +7223,32 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.16.0 - '@openai/agents-core@0.11.6(zod@4.4.3)': + '@openai/agents-core@0.13.5(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3)': dependencies: debug: 4.4.3 - openai: 6.36.0(zod@4.4.3) + openai: 6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) optionalDependencies: '@modelcontextprotocol/sdk': 1.26.0(zod@4.4.3) zod: 4.4.3 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@cfworker/json-schema' + - '@smithy/hash-node' + - '@smithy/signature-v4' - supports-color - ws - '@openai/agents-openai@0.11.6(zod@4.4.3)': + '@openai/agents-openai@0.13.5(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3)': dependencies: - '@openai/agents-core': 0.11.6(zod@4.4.3) + '@openai/agents-core': 0.13.5(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) debug: 4.4.3 - openai: 6.36.0(zod@4.4.3) + openai: 6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) zod: 4.4.3 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@cfworker/json-schema' + - '@smithy/hash-node' + - '@smithy/signature-v4' - supports-color - ws @@ -7725,7 +7751,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@strands-agents/sdk@1.6.0(@ai-sdk/provider@3.0.0)(@aws-sdk/client-s3@3.1060.0)(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.1))(@smithy/types@4.14.3)(express@5.2.1)(openai@6.36.0(zod@4.4.3))(zod@4.4.3)': + '@strands-agents/sdk@1.6.0(@ai-sdk/provider@3.0.0)(@aws-sdk/client-s3@3.1060.0)(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.1))(@smithy/types@4.14.3)(express@5.2.1)(openai@6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3))(zod@4.4.3)': dependencies: '@aws-sdk/client-bedrock-runtime': 3.1075.0 '@modelcontextprotocol/sdk': 1.26.0(zod@4.4.3) @@ -7743,7 +7769,7 @@ snapshots: '@opentelemetry/sdk-trace-node': 2.7.1(@opentelemetry/api@1.9.1) '@smithy/types': 4.14.3 express: 5.2.1 - openai: 6.36.0(zod@4.4.3) + openai: 6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) '@swc/core-darwin-arm64@1.3.102': optional: true @@ -10343,14 +10369,14 @@ snapshots: kleur@3.0.3: {} - langsmith@0.7.10(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.36.0(zod@4.4.3)): + langsmith@0.7.10(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3)): dependencies: p-queue: 6.6.2 optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/exporter-trace-otlp-proto': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - openai: 6.36.0(zod@4.4.3) + openai: 6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3) levn@0.3.0: dependencies: @@ -10673,6 +10699,12 @@ snapshots: optionalDependencies: zod: 4.4.3 + openai@6.48.0(@aws-sdk/credential-provider-node@3.972.58)(@smithy/signature-v4@5.4.6)(zod@4.4.3): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.58 + '@smithy/signature-v4': 5.4.6 + zod: 4.4.3 + optionator@0.8.3: dependencies: deep-is: 0.1.4