diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 644a2014..53c7c931 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -6316,7 +6316,7 @@ Stable provider environment identifier used by `provider.get`. > `readonly` **idempotencyKey**: `string` -Original environment key retained for deterministic run identity and recovery records. +Original environment key. The provider must return the matching retained metadata. ##### turn diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 2624322a..744f3266 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -141,7 +141,7 @@ A general "loop" primitive is the single most common modelling error in this rep | Run **agent-eval fixture folders** through Runtime `runAgentRounds` | agent-eval fixture loading/planning, then `loopCampaignDispatch(...)`: `/kernel`; it starts the Runtime cell inside Eval's paid-call lifecycle | a one-off `runCampaign` dispatch, or attaching a completed `LoopResult` after paid work already ran | | Run a **recursive `supervise()` tree** through an agent-eval profile matrix | `superviseDispatch({ toTask, toSuperviseOptions, ... })`: `/kernel`; it admits the tree through Eval before Runtime spends, then records its receipt only when Runtime proves one model. Mixed or unknown trees fail instead of being relabelled. | a Lab receipt mapper, a second scheduler, or attaching a completed `SupervisedResult` after paid work already ran | | Run + **resume** ONE persistent box across turns | `openSandboxRun(client, opts, deliverable)`: `/kernel` | a per-domain `new Sandbox`+`box.fs.read`+delete copy | -| Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch, while only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session | +| Start a retry-safe detached run in a new environment, or a fresh harness chat in one existing environment | `startRetainedRun(...)` or `startRetainedRunInEnvironment(...)`: `/kernel`; both persist exact coordinates before and after dispatch; the existing-environment path also verifies its retained key through provider metadata; only `continueNative(...)` may claim same-chat continuity | calling `provider.create/get/dispatch` directly, reusing an environment as proof of chat continuity, or appending to an unverified native session | | Run **ONE agent turn** on any substrate: box (`streamPrompt`), cli-bridge/router `Executor`, or in-process chat backend: as ONE normalized `RuntimeStreamEvent` stream with a guaranteed terminal result+usage event; opt into in-stream `tool_call`/`tool_result` with `preserveToolParts`, or tap the raw sandbox events with `onRawEvent` | `streamAgentTurn(backend, prompt, { signal, timeoutMs, preserveToolParts?, onRawEvent? })` + `collectAgentTurn(stream)`: `/kernel` | a per-provider stream→event mapper zoo, a hand-faked box around a non-box executor, or raw fetch leaking through the turn abstraction | | Use an exact profile and Runtime executor where `runAgentTaskStream` or a conversation expects an `AgentExecutionBackend` | `createProfileExecutionBackend({ profile, executor: createExecutor(config) })`: root `.`; the adapter preserves conversation authorization, recursion-depth, and trace headers | a provider-specific backend constructor or an adapter that reads a second model/prompt configuration | | Pick the **execution transport a driven loop runs on** (`sandbox` box / cli-bridge / router) from a product flag | `resolveSandboxClient({ backend })`: `/kernel` | a per-product `if (backend === 'router') …` branch re-wiring `createExecutor` + `inlineSandboxClient` | diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index bf0cb5c1..7092acfe 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -162,6 +162,11 @@ export async function startRetainedRunInEnvironment( if (!environment.dispatch || !environment.session) { throw new Error(`provider "${options.provider.name}" does not expose detached session control`) } + await assertRetainedEnvironmentOwnership( + options.provider, + environment.id, + options.environment.idempotencyKey, + ) return dispatchRetainedRun({ provider: options.provider, @@ -175,6 +180,29 @@ export async function startRetainedRunInEnvironment( }) } +async function assertRetainedEnvironmentOwnership( + provider: AgentEnvironmentProvider, + environmentId: string, + idempotencyKey: string, +): Promise { + if (!provider.list) { + throw new Error( + `provider "${provider.name}" cannot prove retained environment ownership by metadata`, + ) + } + const summaries = await provider.list({ + metadata: { retainedIdempotencyKey: idempotencyKey }, + }) + const matches = summaries.filter( + (summary) => summary.id === environmentId && summary.provider === provider.name, + ) + if (matches.length !== 1 || matches[0]?.metadata?.retainedIdempotencyKey !== idempotencyKey) { + throw new Error( + `provider "${provider.name}" could not bind environment "${environmentId}" to its retained idempotency key`, + ) + } +} + interface DispatchRetainedRunOptions { readonly provider: AgentEnvironmentProvider readonly environment: AgentEnvironment diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 0d74d5f2..46c693c3 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -148,7 +148,7 @@ export interface StartRetainedRunInEnvironmentOptions { readonly environment: { /** Stable provider environment identifier used by `provider.get`. */ readonly id: string - /** Original environment key retained for deterministic run identity and recovery records. */ + /** Original environment key. The provider must return the matching retained metadata. */ readonly idempotencyKey: string } readonly turn: AgentTurnInput & { turnId: string } diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index d668489d..04f9851e 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -280,6 +280,7 @@ describe('retained runtime run control', () => { let createCalls = 0 let destroyCalls = 0 const getIds: string[] = [] + const listQueries: Array | undefined> = [] let dispatched: AgentTurnInput | undefined const provider = providerWithEnvironment({ async dispatch(input) { @@ -303,6 +304,16 @@ describe('retained runtime run control', () => { getIds.push(id) return get(id) } + provider.list = async (query) => { + listQueries.push(query?.metadata) + return [ + { + id: 'environment-1', + provider: 'test-provider', + metadata: { retainedIdempotencyKey: 'durable-environment-key' }, + }, + ] + } const recorder = recordedAdmissions() const run = await startRetainedRunInEnvironment({ @@ -315,6 +326,7 @@ describe('retained runtime run control', () => { expect(createCalls).toBe(0) expect(destroyCalls).toBe(0) expect(getIds).toEqual(['environment-1']) + expect(listQueries).toEqual([{ retainedIdempotencyKey: 'durable-environment-key' }]) expect(dispatched).toEqual({ prompt: 'inspect the existing workspace', turnId: 'fresh-workspace-turn', @@ -394,6 +406,46 @@ describe('retained runtime run control', () => { expect(foreignRecorder.admissions).toEqual([]) }) + it('fails before admission when retained environment ownership is not proven', async () => { + let dispatchCalls = 0 + const mismatched = providerWithEnvironment({ + async dispatch() { + dispatchCalls += 1 + throw new Error('dispatch must not run for a mismatched owner') + }, + }) + mismatched.list = async () => [ + { + id: 'environment-1', + provider: 'test-provider', + metadata: { retainedIdempotencyKey: 'owner-key' }, + }, + ] + const mismatchedRecorder = recordedAdmissions() + await expect( + startRetainedRunInEnvironment({ + provider: mismatched, + environment: { id: 'environment-1', idempotencyKey: 'attacker-key' }, + turn: { prompt: 'go', turnId: 'fresh-turn' }, + onAdmission: mismatchedRecorder.onAdmission, + }), + ).rejects.toThrow('could not bind environment "environment-1"') + expect(dispatchCalls).toBe(0) + expect(mismatchedRecorder.admissions).toEqual([]) + + const unobservable = providerWithEnvironment({}) + const unobservableRecorder = recordedAdmissions() + await expect( + startRetainedRunInEnvironment({ + provider: unobservable, + environment: { id: 'environment-1', idempotencyKey: 'owner-key' }, + turn: { prompt: 'go', turnId: 'fresh-turn' }, + onAdmission: unobservableRecorder.onAdmission, + }), + ).rejects.toThrow('cannot prove retained environment ownership') + expect(unobservableRecorder.admissions).toEqual([]) + }) + it('allowlists a fresh retained start when JavaScript supplies stale run fields', async () => { const controlRef = { runId: 'fresh-run',