feat: record subagent execution lineage (#122015)

* feat(audit): record subagent execution lineage

* fix(audit): type-check spawn lineage validation

* docs: preserve spawned-run lineage invariants

* fix(audit): preserve lineage for worker spawns

* fix(audit): bind worker lineage to live authority

* fix(audit): keep lineage carrier private

* fix(sessions): preserve ACP participant recording

* fix(audit): keep lineage out of runtime bearer

* fix(audit): keep lineage type private

* test(audit): match current worker claim shape

* test(audit): preserve readonly lineage result

* fix(audit): redeem spawn lineage privately

* fix(audit): preserve lineage redemption on copy

* test(audit): prove nested worker spawn lineage

* fix(audit): restore execution identity CI gates
This commit is contained in:
Josh Avant
2026-08-17 23:41:30 -05:00
committed by GitHub
parent 603b0ec7f5
commit 916aca13f3
55 changed files with 4006 additions and 427 deletions
+1
View File
@@ -148,6 +148,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m
Review invariants; full doctrine: `docs/gateway/audit.md`.
- Execution identity is opt-in diagnostic provenance, never authorization or enforcement. Unknown facts stay unknown; record ingress or invoker facts only at their authoritative producer. Never infer identity from session keys, `runId`, or routing metadata.
- Spawned-run lineage is immutable diagnostic provenance: carry the exact parent admission identity only through private spawn context and consume it when creating a fresh child context. Report narrowing inputs, never authorization; missing external-runtime callbacks remain `unsupported`, never inferred.
- Approval-linked execution identity: the parent approval row remains the sole authorization owner. Persist identity only as an exact host-validated source-run binding behind its explicit collection opt-in; disabled and unbound paths leave the lazy companion table absent. Missing, deleted, or corrupt provenance must not grant, deny, consume, or otherwise change approval decisions — no eager table creation, late binding, dual write, fallback reader, sidecar, or schema-version workaround. Changes require older-reader open/use plus candidate-reopen proof.
- Frozen ingress identity facts are diagnostic audit input, not session-ownership state. Session provenance uses the current canonical authenticated profile ID, never a profile display label; only explicitly enabled audit storage may retain its bounded, redacted form.
- Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence.
+1 -1
View File
@@ -2194,7 +2194,7 @@ src/agents/worktrees/git.ts 2
src/agents/worktrees/provisioned-files.ts 3
src/agents/worktrees/registry.ts 12
src/agents/worktrees/run-lease.ts 1
src/audit/execution-identity-admission.ts 5
src/audit/execution-identity-admission.ts 2
src/audit/execution-identity-context-build.ts 3
src/auto-reply/chunk.ts 4
src/auto-reply/command-auth.ts 5
+19 -3
View File
@@ -91,6 +91,21 @@ state for these fields:
- applicable grants and assurance evidence;
- parent or child lineage when available.
For a child started through `sessions_spawn`, the child owns a new context; it
never reuses or mutates the parent context. The lineage projection links the
parent context, execution, run, and agent when the exact private parent token
was available. Its delegation reference covers the spawn relation plus the
requester/controller and evaluated local/target policy inputs. Applicable
grants and runtime assurance remain separate evidence categories. This reports
the inputs that could narrow child authority; it does not claim that identity
changed an allow or deny decision.
If the private parent token was unavailable, the child remains inspectable but
the missing parent context, execution, and run evidence is explicit. ACP spawn
itself is observable. Actions performed wholly inside an external ACP runtime
without a callback are reported as unsupported evidence, never inferred from
task or transcript text.
The foundation records direct local CLI ingress, Gateway boot-system ingress,
and admitted channel participants at their authoritative producers. For a
channel run, the trusted active registered native plugin produces the remote
@@ -296,9 +311,10 @@ canonical session keys can themselves contain platform account or peer ids.
Message records intentionally omit both.
Execution identity contexts use the same installation-local key owner with a
separate HMAC domain. Raw runtime, invoker, ingress-source, assurance, and grant
references exist only in a deeply frozen, in-process worker message capped at
16 KiB and 16 entries in each grant/assurance array. The worker replaces them with keyed
separate HMAC domain. Raw runtime, invoker, ingress-source, assurance, grant,
and child-delegation references exist only in bounded private admission
carriers. The deeply frozen worker message is capped at 16 KiB and 16 entries
in each bounded evidence array. The worker replaces raw references with keyed
pseudonyms before persistence; they are never stored, exported, inspected, or
logged. Configured agent ids plus context, execution, and run ids remain
operator-visible.
+9
View File
@@ -71,6 +71,15 @@ Agents start background sub-agents with the `sessions_spawn` tool.
Completions return as internal parent-session events; the parent/requester
agent decides whether a user-facing update is needed.
When [execution identity auditing](/gateway/audit#run-identity-inspection) is
enabled, each native or ACP child receives a new immutable identity context.
Its lineage links the exact parent context/run when available and records
bounded references for the parent grant, local policy, runtime assurance, and
target policy that constrained the spawn. Neither the private identity token
nor task text appears in the tool schema, result, transcript-derived evidence,
or public plugin API. External ACP-native actions without a callback remain
explicitly unsupported even though the ACP spawn and child are observable.
<AccordionGroup>
<Accordion title="Non-blocking, push-based completion">
- `sessions_spawn` is non-blocking; it returns a run id immediately.
@@ -2447,7 +2447,7 @@ async function buildResponsesPayload(
return buildToolCallEventsWithArgs("sessions_spawn", {
task: subagentHandoffTaskForProvider(providerVariant),
label: "qa-sidecar",
thread: false,
...(!/nested worker lineage handoff/i.test(allInputText) ? { thread: false } : {}),
});
}
if (
@@ -194,7 +194,7 @@ describe("qa scenario catalog", () => {
it("keeps the audited parallel script allowlist exact", () => {
const expected =
"active-talk-agent-run-status agent-run-identity-inspection cached-health-snapshot-boundaries channel-health-monitor-lifecycle diagnostic-events-boundary gateway-loopback-lan-access gateway-rpc-account-health gateway-smoke gateway-ssh-tunnels gateway-stability-runtime gateway-support-export gateway-tls-pinning gateway-websocket-protocol-contracts logging-file-boundary mcp-gateway-connect-startup-retry mcp-plugin-tools-call otel-generation-config-watcher qa-otel-smoke remote-log-tailing tui-command-surfaces-pty tui-editor-input-pty tui-entrypoints-pty tui-gateway-boundary-pty tui-local-runtime-recovery-pty tui-local-shell-pty tui-pty-evidence-producer-contract tui-session-management-pty tui-streaming-tool-cards-pty tui-terminal-safety-pty voice-call-cli-rpc-agent-tool webchat-auto-tts".split(
"active-talk-agent-run-status agent-run-identity-inspection cached-health-snapshot-boundaries channel-health-monitor-lifecycle diagnostic-events-boundary gateway-loopback-lan-access gateway-rpc-account-health gateway-smoke gateway-ssh-tunnels gateway-stability-runtime gateway-support-export gateway-tls-pinning gateway-websocket-protocol-contracts logging-file-boundary mcp-gateway-connect-startup-retry mcp-plugin-tools-call otel-generation-config-watcher qa-otel-smoke remote-log-tailing subagent-lineage-inspection tui-command-surfaces-pty tui-editor-input-pty tui-entrypoints-pty tui-gateway-boundary-pty tui-local-runtime-recovery-pty tui-local-shell-pty tui-pty-evidence-producer-contract tui-session-management-pty tui-streaming-tool-cards-pty tui-terminal-safety-pty voice-call-cli-rpc-agent-tool webchat-auto-tts".split(
" ",
);
const marked = readQaScenarioPack().scenarios.filter(
@@ -0,0 +1,39 @@
title: Subagent execution lineage inspection
scenario:
id: subagent-lineage-inspection
surface: gateway
coverage:
secondary:
- gateway.identity-and-presence-apis
objective: Verify actual worker-hosted and nested-worker sessions_spawn turns record one exact bounded lineage chain that survives Gateway restart and remains inspectable through the audit CLI.
successCriteria:
- A deterministic mock-provider parent runs in an active paired-node worker placement and invokes the real sessions_spawn tool through the private worker RPC.
- The resulting worker child runs its initial model turn in an active worker placement and invokes the real sessions_spawn tool for a grandchild.
- The child and grandchild identity contexts link to the exact parent context, execution, run, and agent principal at depths one and two.
- Existing V1 lineage, grant, assurance, coverage, and missing-evidence fields describe the spawn without exposing prompt, task, session, or raw principal references.
- Audit CLI text and JSON select the exact parent and child before and after Gateway restart.
- Persisted child context bytes remain stable across restart.
docsRefs:
- docs/gateway/audit.md
- docs/tools/subagents.md
- docs/cli/audit.md
codeRefs:
- src/agents/tools/sessions-spawn-tool.ts
- src/gateway/worker-environments/worker-session-tool-executor.ts
- src/gateway/worker-environments/worker-turn-rpc.ts
- src/gateway/agent-turn/agent-run-execution-lineage.ts
- src/audit/execution-identity-context-build.ts
- src/commands/audit.ts
- test/e2e/qa-lab/runtime/subagent-lineage-inspection.ts
execution:
kind: script
parallelSafe: true
path: test/e2e/qa-lab/runtime/subagent-lineage-inspection.ts
config:
requiredProviderMode: mock-openai
args:
- --output-dir
- ${outputDir}
timeoutMs: 1800000
summary: Runs actual worker and nested-worker sessions_spawn turns through a paired-node worker and ephemeral mock-provider Gateway, inspects the three-level identity chain, restarts the Gateway, and verifies stable CLI, persistence, default-off behavior, privacy, and cleanup.
+7 -1
View File
@@ -7,6 +7,7 @@ import {
type ExecutionIdentityAdmissionFacts,
type ExecutionIdentityAdmissionToken,
} from "../audit/execution-identity-admission.js";
import { executionIdentitySpawnAdmission } from "../audit/execution-identity-spawn-admission.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
claimAgentRunDelegatedAuthority,
@@ -214,9 +215,14 @@ export function prepareAgentRunAdmission(params: {
const fixedRuntimeKind = (admittedRuntimeKind ??= runtimeKind);
admittedRuntimeInstanceId ??= runtimeInstanceId?.trim() || undefined;
admitted ??= (async () => {
const facts = executionIdentitySpawnAdmission({
operation: "attach",
value: { ...params.facts, runtime: { kind: fixedRuntimeKind } },
extra: executionIdentitySpawnAdmission({ operation: "read", value: params.facts }),
});
const context = admitPreparedAgentRun({
cfg: params.cfg,
facts: { ...params.facts, runtime: { kind: fixedRuntimeKind } },
facts,
operationalRunInstance,
runtimeInstanceId: admittedRuntimeInstanceId,
...(params.recovery ? { recovery: params.recovery } : {}),
@@ -0,0 +1,39 @@
import type { ExecutionIdentityAdmissionFacts } from "../audit/execution-identity-admission.js";
import type { AgentCommandOpts } from "./command/types.js";
export type AgentCommandExecutionIdentitySpawnFacts = Pick<
ExecutionIdentityAdmissionFacts,
"applicableGrants" | "assurance" | "ingress" | "invoker"
> & {
spawnAdmission: string;
};
const EXECUTION_IDENTITY_SPAWN_FACTS = Symbol("executionIdentitySpawnFacts");
type AgentCommandExecutionIdentityCarrier = {
[EXECUTION_IDENTITY_SPAWN_FACTS]?: AgentCommandExecutionIdentitySpawnFacts;
};
/** Attach authenticated spawn facts without widening the public AgentCommandOpts contract. */
export function withAgentCommandExecutionIdentitySpawnFacts<T extends AgentCommandOpts>(
opts: T,
facts: AgentCommandExecutionIdentitySpawnFacts | undefined,
): T {
if (!facts) {
return opts;
}
const carried = { ...opts, [EXECUTION_IDENTITY_SPAWN_FACTS]: facts };
return carried;
}
export function readAgentCommandExecutionIdentitySpawnFacts(
opts: AgentCommandOpts & AgentCommandExecutionIdentityCarrier,
): AgentCommandExecutionIdentitySpawnFacts | undefined {
return opts[EXECUTION_IDENTITY_SPAWN_FACTS];
}
export function withoutAgentCommandExecutionIdentitySpawnFacts<T extends AgentCommandOpts>(
opts: T,
): T {
return { ...opts, [EXECUTION_IDENTITY_SPAWN_FACTS]: undefined };
}
@@ -4,6 +4,10 @@ import {
type ExecutionIdentityAdmissionWork,
} from "../audit/execution-identity-admission.js";
import { attachAgentCommandAdmissionFacts } from "./agent-command-admission-facts.js";
import {
readAgentCommandExecutionIdentitySpawnFacts,
withAgentCommandExecutionIdentitySpawnFacts,
} from "./agent-command-execution-identity-spawn.js";
import {
prepareAgentCommandExecutionIdentity,
sanitizePublicAgentCommandIngressOpts,
@@ -39,6 +43,29 @@ describe("sanitizePublicAgentCommandIngressOpts", () => {
});
describe("Gateway agent command execution identity", () => {
it("preserves trusted spawn facts across internal option preparation", () => {
const facts = {
ingress: {
kind: "api" as const,
boundary: "sessions_spawn.subagent",
state: "present" as const,
},
invoker: { state: "present" as const, kind: "agent" as const, rawPrincipalRef: "main" },
applicableGrants: [{ rawGrantRef: "tool:sessions_spawn", state: "present" as const }],
assurance: [],
spawnAdmission: "[null,[]]",
};
const prepared = {
...withAgentCommandExecutionIdentitySpawnFacts(
{ message: "spawn", allowModelOverride: false },
facts,
),
lifecycleGeneration: "generation-1",
};
expect(readAgentCommandExecutionIdentitySpawnFacts(prepared)).toBe(facts);
});
it("carries only the prepared bounded, redacted label into opt-in run admission", async () => {
let work: ExecutionIdentityAdmissionWork | undefined;
const displayLabel = "Operator OPENAI_API_KEY=***".padEnd(128, "x");
+37 -9
View File
@@ -2,6 +2,7 @@ import type {
ExecutionIdentityAdmissionFacts,
ExecutionIdentityAdmissionToken,
} from "../audit/execution-identity-admission.js";
import { executionIdentitySpawnAdmission } from "../audit/execution-identity-spawn-admission.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -14,6 +15,11 @@ import {
attachAgentCommandAdmissionFacts,
getAgentCommandAdmissionFacts,
} from "./agent-command-admission-facts.js";
import {
type AgentCommandExecutionIdentitySpawnFacts,
readAgentCommandExecutionIdentitySpawnFacts,
withoutAgentCommandExecutionIdentitySpawnFacts,
} from "./agent-command-execution-identity-spawn.js";
import type {
AgentCommandGatewayIngressOpts,
AgentCommandIngressOpts,
@@ -44,17 +50,35 @@ function prepareAgentCommandRunAdmission(params: {
runId: string;
onAdmitted?: Parameters<typeof prepareAgentRunAdmission>[0]["onAdmitted"];
}) {
return prepareAgentCommandRunAdmissionWithSpawnFacts(params);
}
function prepareAgentCommandRunAdmissionWithSpawnFacts(
params: Parameters<typeof prepareAgentCommandRunAdmission>[0],
spawnFacts?: AgentCommandExecutionIdentitySpawnFacts,
) {
const admissionFacts = getAgentCommandAdmissionFacts(params.operationalRunInstance) ?? {
ingress: params.ingress,
};
const applicableGrants = spawnFacts?.applicableGrants;
const assurance = spawnFacts?.assurance ?? admissionFacts.assurance;
return prepareAgentRunAdmission({
cfg: params.cfg,
operationalRunInstance: params.operationalRunInstance,
facts: {
runId: params.runId,
agentId: params.agentId,
...admissionFacts,
},
facts: executionIdentitySpawnAdmission({
operation: "attach",
value: {
runId: params.runId,
agentId: params.agentId,
ingress: spawnFacts?.ingress ?? admissionFacts.ingress,
...((spawnFacts?.invoker ?? admissionFacts.invoker)
? { invoker: spawnFacts?.invoker ?? admissionFacts.invoker }
: {}),
...(applicableGrants ? { applicableGrants } : {}),
...(assurance ? { assurance } : {}),
},
extra: spawnFacts?.spawnAdmission,
}),
...(params.admission ? { recovery: params.admission } : {}),
...(params.onAdmitted ? { onAdmitted: params.onAdmitted } : {}),
});
@@ -111,7 +135,7 @@ export function prepareAgentCommandExecutionIdentity(params: {
if (admissionFacts) {
attachAgentCommandAdmissionFacts(operationalRunInstance, admissionFacts);
}
return executionIdentity.prepare({
const admissionParams: Parameters<typeof prepareAgentCommandRunAdmission>[0] = {
admission: opts.executionIdentityAdmission,
agentId: prepared.sessionAgentId,
cfg: prepared.cfg,
@@ -144,13 +168,17 @@ export function prepareAgentCommandExecutionIdentity(params: {
log.warn(`failed to bind restart recovery execution identity: ${bindingFailure}`);
}
},
});
};
const spawnFacts = readAgentCommandExecutionIdentitySpawnFacts(opts);
return spawnFacts
? prepareAgentCommandRunAdmissionWithSpawnFacts(admissionParams, spawnFacts)
: executionIdentity.prepare(admissionParams);
}
export function sanitizePublicAgentCommandIngressOpts(
opts: AgentCommandIngressOpts,
): AgentCommandGatewayIngressOpts {
return {
return withoutAgentCommandExecutionIdentitySpawnFacts({
...opts,
mainRestartRecoveryOwnerLease: undefined,
mainRestartRecoveryAdmitted: undefined,
@@ -159,7 +187,7 @@ export function sanitizePublicAgentCommandIngressOpts(
operationalRunInstance: undefined,
cronCreatorAuthorityCapability: undefined,
onAdmittedRunContext: undefined,
};
});
}
export const executionIdentity = {
@@ -0,0 +1,58 @@
import type { ExecutionIdentityAdmissionToken } from "../../../audit/execution-identity-admission.js";
import { recordSessionParticipantBestEffort } from "../../../sessions/session-participant-recording.js";
import { AGENT_LANE_SUBAGENT } from "../../lanes.js";
import type { AcpSpawnBootstrapDeliveryPlan } from "./acp-spawn-bootstrap-delivery.js";
import {
buildSubagentExecutionSessionSpawnContext,
withSubagentGatewayExecutionIdentity,
} from "./subagent-spawn-execution-identity.js";
import { callSubagentGateway } from "./subagent-spawn-gateway.js";
export async function launchAcpChildThroughGateway(params: {
attachments?: unknown[];
childIdem: string;
deliveryPlan: AcpSpawnBootstrapDeliveryPlan;
label?: string;
lineage: Parameters<typeof buildSubagentExecutionSessionSpawnContext>[0];
parentExecutionIdentityToken?: ExecutionIdentityAdmissionToken;
participantStorePath: string;
runTimeoutSeconds: number;
sessionKey: string;
task: string;
}) {
const response = await callSubagentGateway(
withSubagentGatewayExecutionIdentity(
{
method: "agent",
params: {
message: params.task,
sessionKey: params.sessionKey,
channel: params.deliveryPlan.channel,
to: params.deliveryPlan.to,
accountId: params.deliveryPlan.accountId,
threadId: params.deliveryPlan.threadId,
idempotencyKey: params.childIdem,
deliver: params.deliveryPlan.useInlineDelivery,
lane: AGENT_LANE_SUBAGENT,
acpTurnSource: "manual_spawn",
timeout: params.runTimeoutSeconds,
label: params.label || undefined,
...(params.attachments ? { attachments: params.attachments } : {}),
},
timeoutMs: 10_000,
},
{
sessionSpawnContext: buildSubagentExecutionSessionSpawnContext(params.lineage),
parentExecutionIdentityToken: params.parentExecutionIdentityToken,
},
),
);
recordSessionParticipantBestEffort({
actor: { type: "agent", id: params.lineage.parentAgentId },
agentId: params.lineage.targetAgentId,
sessionKey: params.sessionKey,
source: "agent",
storePath: params.participantStorePath,
});
return response;
}
@@ -5,8 +5,17 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpInitializeSessionInput } from "../../../acp/control-plane/manager.types.js";
import { createExecutionIdentityAdmissionToken } from "../../../audit/execution-identity-admission.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { readAgentRuntimeExecutionLineage } from "../../../gateway/agent-runtime-execution-lineage.js";
import type { AgentRuntimeIdentity } from "../../../gateway/agent-runtime-identity-token.js";
import { readInProcessAgentRuntimeIdentity } from "../../../gateway/in-process-agent-runtime-identity.js";
import type { dispatchGatewayMethodInProcess } from "../../../gateway/server-plugins.js";
import {
claimAgentRunDelegatedAuthority,
releaseAgentRunDelegatedAuthority,
} from "../../../infra/agent-run-registry.js";
import {
testing as sessionBindingServiceTesting,
registerSessionBindingAdapter,
@@ -15,7 +24,11 @@ import {
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import { normalizeSessionDeliveryState } from "../../../utils/delivery-context.shared.js";
import { createOperationalRunInstanceRef } from "../../admitted-run-context.js";
import { reserveChildAdmissionSlot } from "../../child-admission.js";
import { withGatewayToolCallerIdentity } from "../../tools/gateway-caller-context.js";
import { withParentExecutionIdentity } from "./execution-identity-spawn-context.js";
import { setSubagentSpawnDepsForTest } from "./subagent-spawn-deps.js";
type SessionBindingAdapterCapabilities = NonNullable<SessionBindingAdapter["capabilities"]>;
@@ -929,6 +942,62 @@ describe("spawnAcpDirect", () => {
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
});
it("forwards ACP lineage with unsupported external native actions and the exact parent token", async () => {
const parentToken = createExecutionIdentityAdmissionToken("parent-run", {
contextId: "parent-context",
executionId: "parent-execution",
});
replaceSpawnConfig({
...createDefaultSpawnConfig(),
logging: { audit: { enabled: true, executionIdentity: true } },
});
const operationalRunInstance = createOperationalRunInstanceRef("parent-run");
const authority = claimAgentRunDelegatedAuthority(operationalRunInstance);
let capturedIdentity: AgentRuntimeIdentity | undefined;
setSubagentSpawnDepsForTest({
hasInProcessGatewayContext: () => true,
dispatchGatewayMethodInProcess: async <T>(
_method: string,
_params: Record<string, unknown>,
options?: NonNullable<Parameters<typeof dispatchGatewayMethodInProcess>[2]>,
) => {
capturedIdentity = readInProcessAgentRuntimeIdentity(options);
return { runId: "acp-child-run" } as T;
},
});
try {
const result = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:telegram:direct:6098642967",
operationalRunInstance,
executionIdentityToken: parentToken,
},
() =>
spawnAcpDirect(
createSpawnRequest(),
withParentExecutionIdentity(createRequesterContext(), parentToken),
),
);
expectAcceptedSpawn(result);
expect(capturedIdentity?.executionIdentity).toBe(parentToken);
expect(readAgentRuntimeExecutionLineage(capturedIdentity?.sessionSpawnContext)).toMatchObject(
{
relation: "sessions_spawn",
requesterRef: "agent:main:telegram:direct:6098642967",
controllerRef: "agent:main:telegram:direct:6098642967",
externalNativeActions: "unsupported",
runtimeAssuranceRefs: ["spawn-runtime:acp"],
},
);
} finally {
releaseAgentRunDelegatedAuthority(authority);
setSubagentSpawnDepsForTest();
}
});
it("allows ACP resume IDs recorded for the requester session", async () => {
const resumeSessionId = "codex-inner-resume";
const ownedSessionKey = "agent:codex:acp:owned";
+29 -29
View File
@@ -5,6 +5,7 @@ import type { AcpTurnAttachment } from "../../../acp/control-plane/manager.types
import type { AcpSpawnRuntimeCloseHandle } from "../../../acp/control-plane/spawn.js";
import { cleanupFailedAcpSpawn } from "../../../acp/control-plane/spawn.js";
import { isAcpEnabledByPolicy, resolveAcpAgentPolicyError } from "../../../acp/policy.js";
import { isExecutionIdentityCollectionEnabled } from "../../../audit/audit-config.js";
import { getRuntimeConfig } from "../../../config/config.js";
import { resolveSessionStorePathCore } from "../../../config/sessions/paths.js";
import {
@@ -28,7 +29,6 @@ import {
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../../routing/session-key.js";
import { recordSessionParticipantBestEffort } from "../../../sessions/session-participant-recording.js";
import {
recordSessionCreated,
recordSubagentSpawned,
@@ -43,7 +43,6 @@ import {
inheritedToolAllowPatch,
inheritedToolDenyPatch,
} from "../../inherited-tool-deny.js";
import { AGENT_LANE_SUBAGENT } from "../../lanes.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import {
runSpawnPipeline,
@@ -65,6 +64,7 @@ import {
toGatewayImageAttachments,
type AcpSpawnBootstrapDeliveryPlan,
} from "./acp-spawn-bootstrap-delivery.js";
import { launchAcpChildThroughGateway } from "./acp-spawn-gateway.js";
import {
type AcpSpawnParentRelayHandle,
startAcpSpawnParentStreamRelay,
@@ -87,17 +87,17 @@ import {
resolveConfiguredAcpSubagentTargetIds,
resolveTargetAcpAgentId,
} from "./acp-spawn-target.js";
import { readParentExecutionIdentity } from "./execution-identity-spawn-context.js";
import {
isSubagentEnvelopeSession,
resolveSubagentCapabilityStore,
} from "./subagent-capabilities.js";
import { callSubagentGateway, readGatewayRunId } from "./subagent-spawn-gateway.js";
import { readGatewayRunId } from "./subagent-spawn-gateway.js";
import { resolveSubagentSpawnOwnership } from "./subagent-spawn-ownership.js";
import { resolveConfiguredSubagentRunTimeoutSeconds } from "./subagent-spawn-plan.js";
type SpawnAcpMode = "run" | "session";
type SpawnAcpSandboxMode = "inherit" | "require";
type SpawnAcpStreamTarget = "parent";
type SpawnAcpParams = {
task: string;
@@ -114,7 +114,7 @@ type SpawnAcpParams = {
sandbox?: SpawnAcpSandboxMode;
cleanup?: "delete" | "keep";
expectsCompletionMessage?: boolean;
streamTo?: SpawnAcpStreamTarget;
streamTo?: "parent";
attachments?: AcpTurnAttachment[];
};
@@ -587,31 +587,31 @@ export async function spawnAcpDirect(
cfg,
});
}
const response = await callSubagentGateway({
method: "agent",
params: {
message: params.task,
sessionKey,
channel: state.deliveryPlan.channel,
to: state.deliveryPlan.to,
accountId: state.deliveryPlan.accountId,
threadId: state.deliveryPlan.threadId,
idempotencyKey: childIdem,
deliver: state.deliveryPlan.useInlineDelivery,
lane: AGENT_LANE_SUBAGENT,
acpTurnSource: "manual_spawn",
timeout: runTimeoutSeconds,
label: params.label || undefined,
...(gatewayAttachments ? { attachments: gatewayAttachments } : {}),
},
timeoutMs: 10_000,
});
recordSessionParticipantBestEffort({
actor: { type: "agent", id: requesterAgentId },
agentId: targetAgentId,
const response = await launchAcpChildThroughGateway({
task: params.task,
sessionKey,
source: "agent",
storePath: resolveSessionStorePathCore(cfg.session?.store, { agentId: targetAgentId }),
deliveryPlan: state.deliveryPlan,
childIdem,
runTimeoutSeconds,
label: params.label,
attachments: gatewayAttachments,
lineage: {
enabled: isExecutionIdentityCollectionEnabled(cfg),
backend: "acp",
parentAgentId: requesterAgentId,
requesterRef: requesterInternalKey,
controllerRef: ownership.controllerSessionKey,
depth: admission.childSessionPatch?.spawnDepth ?? 1,
maxDepth: admission.maxSpawnDepth,
targetAgentId,
sandbox: params.sandbox === "require" ? "require" : "inherit",
inheritedToolAllowlist: ctx.inheritedToolAllowlist,
inheritedToolDenylist: ctx.inheritedToolDenylist,
},
parentExecutionIdentityToken: readParentExecutionIdentity(ctx),
participantStorePath: resolveSessionStorePathCore(cfg.session?.store, {
agentId: targetAgentId,
}),
});
const runId = readGatewayRunId(response) ?? childIdem;
if (state.parentRelay && runId !== childIdem && parentSessionKey) {
@@ -0,0 +1,22 @@
import type { ExecutionIdentityAdmissionToken } from "../../../audit/execution-identity-admission.js";
const parentExecutionIdentities = new WeakMap<object, ExecutionIdentityAdmissionToken>();
/** Carry exact parent provenance without adding it to public spawn context types. */
export function withParentExecutionIdentity<T extends object>(
context: T,
token: ExecutionIdentityAdmissionToken | undefined,
): T {
if (!token) {
return context;
}
const carried = { ...context };
parentExecutionIdentities.set(carried, token);
return carried;
}
export function readParentExecutionIdentity(
context: object,
): ExecutionIdentityAdmissionToken | undefined {
return parentExecutionIdentities.get(context);
}
@@ -0,0 +1,75 @@
import { createHash } from "node:crypto";
import type { ExecutionIdentityAdmissionToken } from "../../../audit/execution-identity-admission.js";
import { withAgentRuntimeExecutionLineage } from "../../../gateway/agent-runtime-execution-lineage.js";
import type { AgentRuntimeSessionSpawnContext } from "../../../gateway/agent-runtime-identity-token.js";
type SubagentGatewayExecutionIdentity = {
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
parentExecutionIdentityToken?: ExecutionIdentityAdmissionToken;
};
const subagentGatewayExecutionIdentities = new WeakMap<object, SubagentGatewayExecutionIdentity>();
function spawnInputRef(kind: string, value: unknown): string {
return `${kind}:${createHash("sha256").update(JSON.stringify(value)).digest("base64url")}`;
}
export function buildSubagentExecutionSessionSpawnContext(params: {
enabled: boolean;
backend: "acp" | "subagent";
parentAgentId: string;
requesterRef: string;
controllerRef: string;
depth: number;
maxDepth?: number;
targetAgentId: string;
sandbox: "inherit" | "require";
inheritedToolAllowlist?: string[];
inheritedToolDenylist?: string[];
}): AgentRuntimeSessionSpawnContext | undefined {
if (!params.enabled) {
return undefined;
}
const allow = params.inheritedToolAllowlist ?? [];
const deny = params.inheritedToolDenylist ?? [];
return withAgentRuntimeExecutionLineage(
{
inheritedToolPolicy: { version: 1, allow, deny },
},
{
relation: "sessions_spawn",
requesterRef: params.requesterRef,
controllerRef: params.controllerRef,
depth: params.depth,
applicableGrantRefs: ["tool:sessions_spawn"],
localPolicyRefs: [
spawnInputRef("spawn-depth-policy", [params.depth, params.maxDepth]),
spawnInputRef("sandbox-policy", [params.backend, params.sandbox]),
spawnInputRef("inherited-tool-policy", {
allow: allow.toSorted(),
deny: deny.toSorted(),
}),
],
runtimeAssuranceRefs: [`spawn-runtime:${params.backend}`],
targetPolicyRefs: [
spawnInputRef("target-policy", [params.parentAgentId, params.targetAgentId]),
],
externalNativeActions: params.backend === "acp" ? "unsupported" : "observable",
},
);
}
export function withSubagentGatewayExecutionIdentity<T extends object>(
params: T,
facts: SubagentGatewayExecutionIdentity,
): T {
const carried = { ...params };
subagentGatewayExecutionIdentities.set(carried, facts);
return carried;
}
export function readSubagentGatewayExecutionIdentity(
params: object,
): SubagentGatewayExecutionIdentity | undefined {
return subagentGatewayExecutionIdentities.get(params);
}
@@ -1,7 +1,16 @@
import type { AgentRuntimeIdentity } from "../../../gateway/agent-runtime-identity-token.js";
import { withInProcessAgentRuntimeIdentity } from "../../../gateway/in-process-agent-runtime-identity.js";
import type { WorkerTurnExecutionIdentity } from "../../../gateway/worker-environments/placement-turn-claim-events.js";
import { getActiveAgentRunDelegatedAuthority } from "../../../infra/agent-run-registry.js";
import { getGatewayToolCallerIdentity } from "../../tools/gateway-caller-context.js";
import { runWithGatewaySessionSpawnContext } from "../../tools/gateway-session-spawn-context.js";
import { runWithGatewaySessionSpawnParentExecutionIdentity } from "../../tools/gateway-session-spawn-execution-identity.js";
import { callGatewayTool } from "../../tools/gateway.js";
import { resolveSubagentRunTimerDelayMs } from "../registry/subagent-run-timeout.js";
import type { SubagentLaunchAuthorization } from "./subagent-launch-authorization.js";
import { applySubagentLaunchAuthorization } from "./subagent-launch-authorization.js";
import { getSubagentSpawnDeps } from "./subagent-spawn-deps.js";
import { readSubagentGatewayExecutionIdentity } from "./subagent-spawn-execution-identity.js";
import {
ADMIN_SCOPE,
callGateway,
@@ -19,6 +28,8 @@ async function callSubagentGatewayWithDispatchMode(
authorization?: SubagentLaunchAuthorization,
options?: { agentRunTracking?: "native_subagent" },
): Promise<{ response: SubagentGatewayResponse; dispatchMode: SubagentGatewayDispatchMode }> {
const { sessionSpawnContext, parentExecutionIdentityToken } =
readSubagentGatewayExecutionIdentity(params) ?? {};
// Subagent lifecycle requires methods spanning multiple scope tiers
// (sessions.delete → admin, agent → write). When each call
// independently negotiates least-privilege scopes the first connection pairs
@@ -51,6 +62,7 @@ async function callSubagentGatewayWithDispatchMode(
params: authorizedParams,
...(scopes != null ? { scopes } : {}),
};
const gatewayCaller = getGatewayToolCallerIdentity();
if (
hasInProcessGateway &&
request.params != null &&
@@ -63,21 +75,82 @@ async function callSubagentGatewayWithDispatchMode(
// Reusing that external identity makes collector preflight treat the launch as spoofed.
const isChildRunLaunch = request.method === "agent";
const forceSyntheticClient = isChildRunLaunch || scopes != null;
const response = await deps.dispatchGatewayMethodInProcess(
request.method,
request.params as Record<string, unknown>,
{
expectFinal: request.expectFinal,
...(allowModelOverride ? { allowSyntheticModelOverride: true } : {}),
...(options?.agentRunTracking ? { agentRunTracking: options.agentRunTracking } : {}),
...(forceSyntheticClient ? { forceSyntheticClient: true } : {}),
...(typeof request.timeoutMs === "number" ? { timeoutMs: request.timeoutMs } : {}),
...(scopes != null ? { syntheticScopes: scopes } : {}),
},
);
const dispatch = async (workerIdentity?: WorkerTurnExecutionIdentity) => {
const operationalRunInstance = gatewayCaller?.workerTurnClaim
? workerIdentity?.operationalRunInstance
: gatewayCaller?.operationalRunInstance;
const activeAuthority = operationalRunInstance
? (workerIdentity?.delegatedAuthority ??
getActiveAgentRunDelegatedAuthority(operationalRunInstance))
: undefined;
const agentRuntimeIdentity: AgentRuntimeIdentity | undefined =
sessionSpawnContext && gatewayCaller && operationalRunInstance && activeAuthority
? {
kind: "agentRuntime",
agentId: workerIdentity?.agentId ?? gatewayCaller.agentId,
sessionKey: workerIdentity?.sessionKey ?? gatewayCaller.sessionKey,
operationalRunInstance,
delegatedAuthority: workerIdentity
? { kind: "worker", ...activeAuthority, turnClaim: workerIdentity.turnClaim }
: { kind: "local", ...activeAuthority },
...(parentExecutionIdentityToken
? { executionIdentity: parentExecutionIdentityToken }
: {}),
sessionSpawnContext,
}
: undefined;
return await deps.dispatchGatewayMethodInProcess(
request.method,
request.params as Record<string, unknown>,
withInProcessAgentRuntimeIdentity(
{
expectFinal: request.expectFinal,
...(allowModelOverride ? { allowSyntheticModelOverride: true } : {}),
...(options?.agentRunTracking ? { agentRunTracking: options.agentRunTracking } : {}),
...(forceSyntheticClient ? { forceSyntheticClient: true } : {}),
...(typeof request.timeoutMs === "number" ? { timeoutMs: request.timeoutMs } : {}),
...(scopes != null ? { syntheticScopes: scopes } : {}),
},
agentRuntimeIdentity,
),
);
};
const workerCapability = gatewayCaller?.workerTurnExecutionIdentityCapability;
const response = workerCapability
? await workerCapability.run(async (identity) => {
if (
gatewayCaller?.agentId !== identity.agentId ||
gatewayCaller.sessionKey !== identity.sessionKey ||
gatewayCaller.operationalRunInstance !== identity.operationalRunInstance ||
gatewayCaller.executionIdentityToken !== identity.executionIdentityToken ||
gatewayCaller.workerTurnClaim !== identity.turnClaim ||
parentExecutionIdentityToken !== identity.executionIdentityToken
) {
throw new Error("worker child admission identity changed");
}
return await dispatch(identity);
})
: await dispatch();
return { response, dispatchMode: "in_process" };
}
return { response: await deps.callGateway(request), dispatchMode: "out_of_process" };
const response =
sessionSpawnContext && gatewayCaller?.operationalRunInstance
? await runWithGatewaySessionSpawnContext(sessionSpawnContext, () =>
runWithGatewaySessionSpawnParentExecutionIdentity(parentExecutionIdentityToken, () =>
callGatewayTool(
request.method,
typeof request.timeoutMs === "number" ? { timeoutMs: request.timeoutMs } : {},
request.params,
{
expectFinal: request.expectFinal,
scopes,
requireAgentRuntimeIdentity: true,
},
),
),
)
: await deps.callGateway(request);
return { response, dispatchMode: "out_of_process" };
}
export async function callSubagentGateway(
@@ -2,13 +2,17 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createExecutionIdentityAdmissionToken } from "../../../audit/execution-identity-admission.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
getRuntimeConfig,
} from "../../../config/config.js";
import { readAgentRuntimeExecutionLineage } from "../../../gateway/agent-runtime-execution-lineage.js";
import type { AgentRuntimeIdentity } from "../../../gateway/agent-runtime-identity-token.js";
import { prepareAgentRequestPreflight } from "../../../gateway/agent-turn/agent-request-preflight.js";
import { createAgentTurnIo } from "../../../gateway/agent-turn/io.js";
import { readInProcessAgentRuntimeIdentity } from "../../../gateway/in-process-agent-runtime-identity.js";
import { resolveGatewayAgentTaskTrackingMode } from "../../../gateway/server-methods/agent-task-tracking.js";
import type {
GatewayRequestContext,
@@ -19,6 +23,15 @@ import {
clearFallbackGatewayContext,
type dispatchGatewayMethodInProcess,
} from "../../../gateway/server-plugins.js";
import type { WorkerSessionTurnClaim } from "../../../gateway/worker-environments/placement-record.js";
import type {
WorkerTurnExecutionIdentity,
WorkerTurnExecutionIdentityCapability,
} from "../../../gateway/worker-environments/placement-turn-claim-events.js";
import {
claimAgentRunDelegatedAuthority,
releaseAgentRunDelegatedAuthority,
} from "../../../infra/agent-run-registry.js";
import { withPluginRuntimeGatewayRequestScope } from "../../../plugins/runtime/gateway-request-scope.js";
import {
isGatewaySubordinateWorkAdmissionClosed,
@@ -31,6 +44,8 @@ import {
setDetachedTaskLifecycleRuntime,
} from "../../../tasks/detached-task-runtime.test-support.js";
import { captureEnv, setTestEnvValue } from "../../../test-utils/env.js";
import { createOperationalRunInstanceRef } from "../../admitted-run-context.js";
import { withGatewayToolCallerIdentity } from "../../tools/gateway-caller-context.js";
import { subagentRuns } from "../registry/subagent-registry-memory.js";
import { markSubagentRunTerminated } from "../registry/subagent-registry.js";
import {
@@ -38,6 +53,8 @@ import {
testing as subagentRegistryTesting,
} from "../registry/subagent-registry.test-helpers.js";
import { testing as swarmSchedulerTesting } from "../swarm/swarm-scheduler.test-support.js";
import { withParentExecutionIdentity } from "./execution-identity-spawn-context.js";
import { buildSubagentExecutionSessionSpawnContext } from "./subagent-spawn-execution-identity.js";
import { callSubagentGateway } from "./subagent-spawn-gateway.js";
import { spawnSubagentDirect } from "./subagent-spawn.js";
import { testing as subagentSpawnTesting } from "./subagent-spawn.test-support.js";
@@ -103,6 +120,43 @@ async function waitForAssertion(assertion: () => void, timeoutMs = 2_000): Promi
}
describe("spawnSubagentDirect in-process Gateway collector launch", () => {
it("does not construct private lineage while identity collection is disabled", () => {
expect(
buildSubagentExecutionSessionSpawnContext({
enabled: false,
backend: "subagent",
parentAgentId: "main",
requesterRef: "agent:main:main",
controllerRef: "agent:main:main",
depth: 1,
targetAgentId: "main",
sandbox: "inherit",
}),
).toBeUndefined();
});
it("canonicalizes set-like inherited policy inputs in lineage references", () => {
const build = (allow: string[], deny: string[]) =>
readAgentRuntimeExecutionLineage(
buildSubagentExecutionSessionSpawnContext({
enabled: true,
backend: "subagent",
parentAgentId: "main",
requesterRef: "agent:main:main",
controllerRef: "agent:main:main",
depth: 1,
targetAgentId: "worker",
sandbox: "inherit",
inheritedToolAllowlist: allow,
inheritedToolDenylist: deny,
}),
)?.localPolicyRefs;
expect(build(["read", "write"], ["exec", "browser"])).toEqual(
build(["write", "read"], ["browser", "exec"]),
);
});
beforeEach(async () => {
resetGatewayWorkAdmission();
swarmSchedulerTesting.reset();
@@ -123,6 +177,7 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => {
await writeFile(
path.join(stateDir, "openclaw.json"),
`${JSON.stringify({
logging: { audit: { enabled: true, executionIdentity: true } },
session: { mainKey: "main", scope: "per-sender" },
tools: { swarm: { enabled: true, maxConcurrent: 1 } },
agents: {
@@ -260,6 +315,146 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => {
expect(subordinateAdmissionStates).toEqual([false, false]);
});
it("consumes the exact private parent token in the child Gateway identity", async () => {
const parentToken = createExecutionIdentityAdmissionToken("parent-run", {
contextId: "parent-context",
executionId: "parent-execution",
});
const operationalRunInstance = createOperationalRunInstanceRef("parent-run");
const authority = claimAgentRunDelegatedAuthority(operationalRunInstance);
let childIdentity: AgentRuntimeIdentity | undefined;
subagentSpawnTesting.setDepsForTest({
dispatchGatewayMethodInProcess: async <T>(
_method: string,
params: Record<string, unknown>,
options?: NonNullable<Parameters<typeof dispatchGatewayMethodInProcess>[2]>,
) => {
childIdentity = readInProcessAgentRuntimeIdentity(options);
return { runId: params.idempotencyKey, status: "accepted" } as T;
},
});
try {
const result = await withPluginRuntimeGatewayRequestScope(
{
context: makeGatewayContext(),
client: externalCliClient(),
isWebchatConnect: () => false,
},
() =>
withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
operationalRunInstance,
executionIdentityToken: parentToken,
},
() =>
spawnSubagentDirect(
{ task: "inspect lineage", context: "isolated", lightContext: true },
withParentExecutionIdentity({ agentSessionKey: "agent:main:main" }, parentToken),
),
),
);
expect(result.error).toBeUndefined();
expect(result.status).toBe("accepted");
expect(childIdentity?.executionIdentity).toBe(parentToken);
expect(readAgentRuntimeExecutionLineage(childIdentity?.sessionSpawnContext)).toMatchObject({
relation: "sessions_spawn",
requesterRef: "agent:main:main",
controllerRef: "agent:main:main",
depth: 1,
applicableGrantRefs: ["tool:sessions_spawn"],
externalNativeActions: "observable",
});
} finally {
releaseAgentRunDelegatedAuthority(authority);
}
});
it("revalidates the worker capability at the child Gateway admission boundary", async () => {
const parentToken = createExecutionIdentityAdmissionToken("parent-run", {
contextId: "parent-context",
executionId: "parent-execution",
});
const operationalRunInstance = createOperationalRunInstanceRef("parent-run");
const authority = claimAgentRunDelegatedAuthority(operationalRunInstance);
const turnClaim = {
sessionId: "parent-session-id",
runId: "parent-run",
claimId: "parent-claim",
placementGeneration: 4,
owner: { kind: "worker", environmentId: "worker-env", ownerEpoch: 7 },
} satisfies WorkerSessionTurnClaim;
const identity: WorkerTurnExecutionIdentity = {
agentId: "main",
delegatedAuthority: authority,
executionIdentityToken: parentToken,
operationalRunInstance,
sessionKey: "agent:main:main",
turnClaim,
};
let validations = 0;
const capability: WorkerTurnExecutionIdentityCapability = {
async run<T>(callback: (current: WorkerTurnExecutionIdentity) => Promise<T> | T) {
validations += 1;
return await callback(identity);
},
};
let childIdentity: AgentRuntimeIdentity | undefined;
subagentSpawnTesting.setDepsForTest({
dispatchGatewayMethodInProcess: async <T>(
_method: string,
params: Record<string, unknown>,
options?: NonNullable<Parameters<typeof dispatchGatewayMethodInProcess>[2]>,
) => {
childIdentity = readInProcessAgentRuntimeIdentity(options);
return { runId: params.idempotencyKey, status: "accepted" } as T;
},
});
try {
const result = await withPluginRuntimeGatewayRequestScope(
{
context: makeGatewayContext(),
client: externalCliClient(),
isWebchatConnect: () => false,
},
() =>
capability.run((current) =>
withGatewayToolCallerIdentity(
{
agentId: current.agentId,
sessionKey: current.sessionKey,
operationalRunInstance: current.operationalRunInstance,
executionIdentityToken: current.executionIdentityToken,
workerTurnClaim: current.turnClaim,
workerTurnExecutionIdentityCapability: capability,
},
() =>
spawnSubagentDirect(
{ task: "inspect worker lineage", context: "isolated", lightContext: true },
withParentExecutionIdentity(
{ agentSessionKey: current.sessionKey },
current.executionIdentityToken,
),
),
),
),
);
expect(result.status).toBe("accepted");
expect(validations).toBe(2);
expect(childIdentity?.delegatedAuthority).toMatchObject({
kind: "worker",
turnClaim,
});
} finally {
releaseAgentRunDelegatedAuthority(authority);
}
});
it("aborts a collector cancelled while Gateway acceptance is in flight", async () => {
const gatewayContext = makeGatewayContext();
let releaseFirstLaunch!: () => void;
+29 -5
View File
@@ -6,6 +6,7 @@
import { promises as fs } from "node:fs";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js";
import { isExecutionIdentityCollectionEnabled } from "../../../audit/audit-config.js";
import { resolveSessionStorePathCore } from "../../../config/sessions/paths.js";
import type { SubagentSpawnPreparation } from "../../../context-engine/types.js";
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
@@ -29,6 +30,7 @@ import {
startQueuedSubagentRun,
} from "../registry/subagent-registry.js";
import { activateSwarmRun, removeQueuedSwarmRun } from "../swarm/swarm-scheduler.js";
import { readParentExecutionIdentity } from "./execution-identity-spawn-context.js";
import {
materializeSubagentAttachments,
type SubagentAttachmentReceiptFile,
@@ -52,6 +54,10 @@ import type {
SpawnSubagentResult,
} from "./subagent-spawn-contract.js";
import { setSubagentSpawnDepsForTest } from "./subagent-spawn-deps.js";
import {
buildSubagentExecutionSessionSpawnContext,
withSubagentGatewayExecutionIdentity,
} from "./subagent-spawn-execution-identity.js";
import { callNativeSubagentGateway, readGatewayRunId } from "./subagent-spawn-gateway.js";
import { buildSubagentLaunchRequest } from "./subagent-spawn-launch-request.js";
import { createSubagentSpawnLifecycleEmitter } from "./subagent-spawn-lifecycle.js";
@@ -376,11 +382,29 @@ export async function spawnSubagentDirect(
});
const launchChildRun = async () =>
await callNativeSubagentGateway(
{
method: "agent",
params: childLaunch.request,
timeoutMs: childLaunch.timeoutMs,
},
withSubagentGatewayExecutionIdentity(
{
method: "agent",
params: childLaunch.request,
timeoutMs: childLaunch.timeoutMs,
},
{
sessionSpawnContext: buildSubagentExecutionSessionSpawnContext({
enabled: isExecutionIdentityCollectionEnabled(cfg),
backend: "subagent",
parentAgentId: requesterAgentId,
requesterRef: requesterInternalKey,
controllerRef: ownership.controllerSessionKey,
depth: childDepth,
maxDepth: maxSpawnDepth,
targetAgentId,
sandbox: sandboxMode,
inheritedToolAllowlist: ctx.inheritedToolAllowlist,
inheritedToolDenylist: ctx.inheritedToolDenylist,
}),
parentExecutionIdentityToken: readParentExecutionIdentity(ctx),
},
),
childLaunch.authorization,
);
@@ -2,6 +2,8 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.js";
import type { WorkerSessionTurnClaim } from "../../gateway/worker-environments/placement-record.js";
import type { WorkerTurnExecutionIdentityCapability } from "../../gateway/worker-environments/placement-turn-claim-events.js";
import type { AdmittedRunContext, OperationalRunInstanceRef } from "../admitted-run-context.js";
import { copyAgentToolMetadata } from "../agent-tool-metadata.js";
import {
@@ -19,6 +21,10 @@ type GatewayToolCallerIdentity = {
/** Opaque already-signed identity used only by isolated worker transports. */
signedAgentRuntimeIdentityToken?: string;
executionIdentityToken?: ExecutionIdentityAdmissionToken;
/** Exact Gateway-owned worker claim; never sourced from model or RPC arguments. */
workerTurnClaim?: WorkerSessionTurnClaim;
/** Closure-bound Gateway capability; revalidates both owners at child admission. */
workerTurnExecutionIdentityCapability?: WorkerTurnExecutionIdentityCapability;
/** Host-signed capability for the scheduled run's existing self-management surface. */
cronSelfManagementJobId?: string;
cronToolsAllowCapture?: "final-executable-surface";
@@ -107,6 +113,10 @@ export async function withGatewayToolCallerIdentity<T>(
identity.signedAgentRuntimeIdentityToken?.trim();
const executionIdentityToken =
inheritedOwner?.executionIdentityToken ?? identity.executionIdentityToken;
const workerTurnClaim = inheritedOwner?.workerTurnClaim ?? identity.workerTurnClaim;
const workerTurnExecutionIdentityCapability =
inheritedOwner?.workerTurnExecutionIdentityCapability ??
identity.workerTurnExecutionIdentityCapability;
const cronSelfManagementJobId =
identity.cronSelfManagementJobId?.trim() ?? inheritedOwner?.cronSelfManagementJobId;
const cronToolsAllowCapture =
@@ -134,6 +144,8 @@ export async function withGatewayToolCallerIdentity<T>(
...(cronToolsAllowCapture ? { cronToolsAllowCapture } : {}),
...(cronCreatorAuthorityGrant ? { cronCreatorAuthorityGrant } : {}),
...(executionIdentityToken ? { executionIdentityToken } : {}),
...(workerTurnClaim ? { workerTurnClaim } : {}),
...(workerTurnExecutionIdentityCapability ? { workerTurnExecutionIdentityCapability } : {}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceLocal === true ? { turnSourceLocal: true } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
@@ -0,0 +1,18 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
const parentExecutionIdentityToken = new AsyncLocalStorage<ExecutionIdentityAdmissionToken>();
/** Scope exact parent evidence to the same local Gateway call as spawn authority. */
export function runWithGatewaySessionSpawnParentExecutionIdentity<T>(
token: ExecutionIdentityAdmissionToken | undefined,
run: () => Promise<T>,
): Promise<T> {
return token ? parentExecutionIdentityToken.run(token, run) : run();
}
export function getGatewaySessionSpawnParentExecutionIdentityToken():
| ExecutionIdentityAdmissionToken
| undefined {
return parentExecutionIdentityToken.getStore();
}
@@ -1,7 +1,9 @@
// Gateway tool runtime-identity tests keep current-turn authority fail closed.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import { withAgentRuntimeExecutionLineage } from "../../gateway/agent-runtime-execution-lineage.js";
import { verifyAgentRuntimeIdentityToken } from "../../gateway/agent-runtime-identity-token.js";
import { resolveExecutionIdentitySpawnFacts } from "../../gateway/agent-turn/agent-run-execution-lineage.js";
import type { CallGatewayOptions } from "../../gateway/call.js";
import {
mintMessageActionTurnCapability,
@@ -18,6 +20,7 @@ import {
withGatewayToolCallerIdentity,
} from "./gateway-caller-context.js";
import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
import { runWithGatewaySessionSpawnParentExecutionIdentity } from "./gateway-session-spawn-execution-identity.js";
import { callGatewayTool, resolveMessageActionAgentRuntimeIdentityToken } from "./gateway.js";
const mocks = vi.hoisted(() => ({
@@ -100,12 +103,17 @@ describe("gateway tool runtime identity", () => {
it("scopes signed session-spawn authority to its Gateway call", async () => {
mocks.callGateway.mockResolvedValueOnce({ key: "agent:ops:dashboard:child" });
const parentExecutionIdentity = createExecutionIdentityAdmissionToken("run-1", {
contextId: "parent-context",
executionId: "parent-execution",
});
await withActiveGatewayToolCallerIdentity(
{
agentId: "ops",
sessionKey: "agent:ops:main",
operationalRunInstance: createOperationalRunInstanceRef("run-1"),
executionIdentityToken: parentExecutionIdentity,
},
async () =>
await runWithGatewaySessionSpawnContext(
@@ -114,11 +122,13 @@ describe("gateway tool runtime identity", () => {
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
},
() =>
callGatewayTool(
"sessions.create",
{},
{ parentSessionKey: "agent:ops:main", spawnDepth: 1 },
{ requireAgentRuntimeIdentity: true },
runWithGatewaySessionSpawnParentExecutionIdentity(parentExecutionIdentity, () =>
callGatewayTool(
"sessions.create",
{},
{ parentSessionKey: "agent:ops:main", spawnDepth: 1 },
{ requireAgentRuntimeIdentity: true },
),
),
),
);
@@ -126,6 +136,7 @@ describe("gateway tool runtime identity", () => {
await expect(
verifyAgentRuntimeIdentityToken(capturedGatewayCall().agentRuntimeIdentityToken),
).resolves.toMatchObject({
executionIdentity: parentExecutionIdentity,
sessionSpawnContext: {
completionOwnerSessionKey: "agent:ops:discord:direct:alice",
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
@@ -133,6 +144,124 @@ describe("gateway tool runtime identity", () => {
});
});
it("does not recover missing forwarded parent evidence from ambient identity", async () => {
mocks.callGateway.mockResolvedValueOnce({ key: "agent:ops:dashboard:child" });
const ambientToken = createExecutionIdentityAdmissionToken("run-1");
const identity = await withActiveGatewayToolCallerIdentity(
{
agentId: "ops",
sessionKey: "agent:ops:main",
operationalRunInstance: createOperationalRunInstanceRef("run-1"),
executionIdentityToken: ambientToken,
},
async () => {
await runWithGatewaySessionSpawnContext(
withAgentRuntimeExecutionLineage(
{
inheritedToolPolicy: { version: 1, allow: [], deny: [] },
},
{
relation: "sessions_spawn",
requesterRef: "agent:ops:main",
controllerRef: "agent:ops:main",
depth: 1,
applicableGrantRefs: ["tool:sessions_spawn"],
localPolicyRefs: [],
runtimeAssuranceRefs: ["spawn-runtime:subagent"],
targetPolicyRefs: [],
externalNativeActions: "observable",
},
),
() =>
callGatewayTool(
"sessions.create",
{},
{ parentSessionKey: "agent:ops:main", spawnDepth: 1 },
{ requireAgentRuntimeIdentity: true },
),
);
return await verifyAgentRuntimeIdentityToken(
capturedGatewayCall().agentRuntimeIdentityToken,
);
},
);
expect(identity).toBeDefined();
expect(identity).not.toHaveProperty("executionIdentity");
await expect(
verifyAgentRuntimeIdentityToken(capturedGatewayCall().agentRuntimeIdentityToken),
).resolves.toBeUndefined();
});
it("redeems spawn lineage once without placing parent facts in the runtime bearer", async () => {
mocks.callGateway.mockResolvedValueOnce({ runId: "child-run" });
const parentExecutionIdentity = createExecutionIdentityAdmissionToken("run-private", {
contextId: "private-parent-context",
executionId: "private-parent-execution",
});
const result = await withActiveGatewayToolCallerIdentity(
{
agentId: "ops",
sessionKey: "agent:ops:main",
operationalRunInstance: createOperationalRunInstanceRef("run-private"),
executionIdentityToken: parentExecutionIdentity,
},
async () => {
await runWithGatewaySessionSpawnContext(
withAgentRuntimeExecutionLineage(
{ inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] } },
{
relation: "sessions_spawn",
requesterRef: "private-requester-ref",
controllerRef: "private-controller-ref",
depth: 2,
applicableGrantRefs: ["tool:sessions_spawn"],
localPolicyRefs: ["private-local-policy"],
runtimeAssuranceRefs: ["spawn-runtime:subagent"],
targetPolicyRefs: ["private-target-policy"],
externalNativeActions: "observable",
},
),
() =>
runWithGatewaySessionSpawnParentExecutionIdentity(parentExecutionIdentity, () =>
callGatewayTool(
"agent",
{},
{ sessionKey: "agent:child:main", message: "test", idempotencyKey: "child-run" },
{ requireAgentRuntimeIdentity: true },
),
),
);
const token = capturedGatewayCall().agentRuntimeIdentityToken ?? "";
const [encodedPayload] = token.split(".");
const payload = JSON.parse(
Buffer.from(encodedPayload ?? "", "base64url").toString("utf8"),
) as Record<string, unknown>;
expect(payload.executionLineageHandoffId).toEqual(expect.any(String));
expect(payload).not.toHaveProperty("executionIdentity");
expect(payload).not.toHaveProperty("sessionSpawnContext");
expect(JSON.stringify(payload)).not.toMatch(
/private-parent|private-requester|private-controller|private-local|private-target/,
);
const verified = await verifyAgentRuntimeIdentityToken(token);
const copied = verified ? { ...verified } : undefined;
return {
identity: verified,
facts: resolveExecutionIdentitySpawnFacts(copied),
replayFacts: resolveExecutionIdentitySpawnFacts(verified),
};
},
);
expect(result.identity).toBeDefined();
expect(result.facts?.spawnAdmission).toEqual(expect.any(String));
expect(result.replayFacts).toBeUndefined();
await expect(
verifyAgentRuntimeIdentityToken(capturedGatewayCall().agentRuntimeIdentityToken),
).resolves.toBeUndefined();
});
it("mints message action identity only for an exact admitted source turn", async () => {
const capabilityInput = {
agentId: "ops",
+43 -5
View File
@@ -15,6 +15,10 @@ import {
import { ErrorCodes } from "../../../packages/gateway-protocol/src/schema/error-codes.js";
import { getRuntimeConfig, resolveGatewayPort } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
createAgentRuntimeExecutionLineageHandoff,
readAgentRuntimeExecutionLineage,
} from "../../gateway/agent-runtime-execution-lineage.js";
import { mintAgentRuntimeIdentityToken } from "../../gateway/agent-runtime-identity-token.js";
import { callGateway } from "../../gateway/call.js";
import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js";
@@ -24,6 +28,7 @@ import {
type OperatorScope,
} from "../../gateway/method-scopes.js";
import { getOperatorApprovalRuntimeToken } from "../../gateway/operator-approval-runtime-token.js";
import { getActiveAgentRunDelegatedAuthority } from "../../infra/agent-run-registry.js";
import {
loadDeviceIdentityIfPresent,
loadOrCreateDeviceIdentity,
@@ -33,6 +38,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { readPositiveIntegerParam, readToolStringParam } from "./common.js";
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
import { getGatewaySessionSpawnParentExecutionIdentityToken } from "./gateway-session-spawn-execution-identity.js";
/** Optional gateway connection overrides accepted by agent tools. */
export type GatewayCallOptions = {
@@ -384,11 +390,43 @@ async function resolveAgentRuntimeIdentityTokenForGatewayTool(params: {
}
try {
const sessionSpawnContext = getGatewaySessionSpawnContext();
return await mintAgentRuntimeIdentityToken({
...identity,
operationalRunInstance: identity.operationalRunInstance,
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
});
const parentExecutionIdentityToken = getGatewaySessionSpawnParentExecutionIdentityToken();
const activeAuthority = getActiveAgentRunDelegatedAuthority(identity.operationalRunInstance);
const executionLineage = readAgentRuntimeExecutionLineage(sessionSpawnContext);
if (executionLineage && !activeAuthority) {
throw new Error("execution lineage handoff requires active parent authority");
}
const lineageHandoff =
sessionSpawnContext && executionLineage && activeAuthority
? createAgentRuntimeExecutionLineageHandoff({
agentId: identity.agentId,
sessionKey: identity.sessionKey,
operationalRunInstance: identity.operationalRunInstance,
delegatedAuthority: activeAuthority,
...(parentExecutionIdentityToken
? { executionIdentity: parentExecutionIdentityToken }
: {}),
sessionSpawnContext,
})
: undefined;
if (executionLineage && !lineageHandoff) {
throw new Error("execution lineage handoff could not bind the parent admission");
}
try {
return await mintAgentRuntimeIdentityToken({
...identity,
operationalRunInstance: identity.operationalRunInstance,
...(lineageHandoff ? { executionIdentityToken: undefined } : {}),
...(lineageHandoff
? { executionLineageHandoffId: lineageHandoff.id }
: sessionSpawnContext
? { executionIdentityToken: parentExecutionIdentityToken, sessionSpawnContext }
: {}),
});
} catch (error) {
lineageHandoff?.revoke();
throw error;
}
} catch (error) {
if (optionalLocalIdentity && !params.required) {
return undefined;
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readInProcessAgentRuntimeIdentity } from "../../gateway/in-process-agent-runtime-identity.js";
const mocks = vi.hoisted(() => ({
hasContext: true,
@@ -24,6 +25,7 @@ import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.j
import {
callAgentToolGatewayRequest,
callInProcessGatewayToolWithCreation,
withAgentToolGatewayRuntimeIdentity,
} from "./in-process-gateway.js";
describe("trusted in-process Gateway session creation", () => {
@@ -145,6 +147,35 @@ describe("request-shaped in-process Gateway dispatch", () => {
expect(mocks.callGateway).not.toHaveBeenCalled();
});
it("carries trusted runtime identity only through the private in-process carrier", async () => {
const identity = {
kind: "agentRuntime",
agentId: "main",
sessionKey: "agent:main:worker",
operationalRunInstance: { instanceId: "instance-1", runId: "run-1" },
delegatedAuthority: {
kind: "local",
lifecycleGeneration: "generation-1",
claimId: "claim-1",
operationalRunInstance: { instanceId: "instance-1", runId: "run-1" },
},
} as const;
const request = withAgentToolGatewayRuntimeIdentity(
{ method: "chat.send", params: { sessionKey: "agent:main:child" } },
identity,
);
mocks.dispatch.mockImplementationOnce(async (_method, _params, options) => {
expect(readInProcessAgentRuntimeIdentity(options)).toBe(identity);
return { runId: "run-1" };
});
await callAgentToolGatewayRequest(request);
expect(JSON.stringify(request)).toBe(
'{"method":"chat.send","params":{"sessionKey":"agent:main:child"}}',
);
});
it.each([
[null, undefined],
[0, 0],
@@ -197,6 +228,7 @@ describe("request-shaped in-process Gateway dispatch", () => {
method: "sessions.list",
params: { limit: 5 },
timeoutMs: 2_000,
agentRunTracking: "native_subagent",
agentToolCaller: {
agentId: "main",
sessionKey: "agent:main:discord:direct:colin",
@@ -212,4 +244,28 @@ describe("request-shaped in-process Gateway dispatch", () => {
});
expect(mocks.dispatch).not.toHaveBeenCalled();
});
it("does not drop a private runtime identity onto the transport fallback", async () => {
mocks.hasContext = false;
const request = withAgentToolGatewayRuntimeIdentity(
{ method: "chat.send", params: { sessionKey: "agent:main:child" } },
{
kind: "agentRuntime",
agentId: "main",
sessionKey: "agent:main:worker",
operationalRunInstance: { instanceId: "instance-1", runId: "run-1" },
delegatedAuthority: {
kind: "local",
lifecycleGeneration: "generation-1",
claimId: "claim-1",
operationalRunInstance: { instanceId: "instance-1", runId: "run-1" },
},
},
);
await expect(callAgentToolGatewayRequest(request)).rejects.toThrow(
"trusted agent runtime identity requires in-process Gateway dispatch",
);
expect(mocks.callGateway).not.toHaveBeenCalled();
});
});
+30 -2
View File
@@ -1,8 +1,11 @@
import type { AgentRuntimeIdentity } from "../../gateway/agent-runtime-identity-token.js";
/** In-process Gateway calls for built-in agent tools. */
import type { CallGatewayOptions } from "../../gateway/call.js";
import { withInProcessAgentRuntimeIdentity } from "../../gateway/in-process-agent-runtime-identity.js";
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
import type { TrustedSessionCreation } from "../../gateway/server-methods/session-creation-provenance.js";
import type {
GatewayAgentRunTaskOwner,
GatewayRequestContext,
TrustedAgentToolCaller,
} from "../../gateway/server-methods/types.js";
@@ -31,9 +34,25 @@ type AgentToolGatewayRequest = Pick<
| "scopes"
| "timeoutMs"
> & {
agentRunTracking?: GatewayAgentRunTaskOwner;
agentToolCaller?: TrustedAgentToolCaller;
};
const agentToolGatewayRuntimeIdentities = new WeakMap<object, AgentRuntimeIdentity>();
/** Carry trusted runtime identity without making it enumerable or transportable. */
export function withAgentToolGatewayRuntimeIdentity<T extends object>(
request: T,
identity: AgentRuntimeIdentity | undefined,
): T {
if (!identity) {
return request;
}
const carried = { ...request };
agentToolGatewayRuntimeIdentities.set(carried, identity);
return carried;
}
export type AgentToolGatewayRequestCaller = <T = Record<string, unknown>>(
request: AgentToolGatewayRequest,
) => Promise<T>;
@@ -56,9 +75,17 @@ export function getInProcessGatewayToolContext(): GatewayRequestContext | undefi
export const callAgentToolGatewayRequest: AgentToolGatewayRequestCaller = async <T>(
request: AgentToolGatewayRequest,
): Promise<T> => {
const runtimeIdentity = agentToolGatewayRuntimeIdentities.get(request);
if (!hasInProcessGatewayContext()) {
if (runtimeIdentity) {
throw new Error("trusted agent runtime identity requires in-process Gateway dispatch");
}
const { callGateway } = await import("../../gateway/call.js");
const { agentToolCaller: _agentToolCaller, ...wireRequest } = request;
const {
agentRunTracking: _agentRunTracking,
agentToolCaller: _agentToolCaller,
...wireRequest
} = request;
return await callGateway<T>(wireRequest);
}
const scopes =
@@ -69,6 +96,7 @@ export const callAgentToolGatewayRequest: AgentToolGatewayRequestCaller = async
: (request.timeoutMs ?? DEFAULT_IN_PROCESS_GATEWAY_REQUEST_TIMEOUT_MS);
const dispatchOptions = {
forceSyntheticClient: true,
...(request.agentRunTracking ? { agentRunTracking: request.agentRunTracking } : {}),
...(request.agentToolCaller ? { agentToolCaller: request.agentToolCaller } : {}),
syntheticScopes: scopes,
...(request.expectFinal !== undefined ? { expectFinal: request.expectFinal } : {}),
@@ -87,7 +115,7 @@ export const callAgentToolGatewayRequest: AgentToolGatewayRequestCaller = async
return await dispatchGatewayMethodInProcess<T>(
request.method,
(request.params ?? {}) as Record<string, unknown>,
dispatchOptions,
withInProcessAgentRuntimeIdentity(dispatchOptions, runtimeIdentity),
);
};
@@ -3,13 +3,17 @@ import path from "node:path";
// dispatch, and result details for spawned child sessions.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js";
import { GatewayClientRequestError } from "../../gateway/client.js";
import { withTestDir } from "../../test-helpers/temp-dir.js";
import { createOperationalRunInstanceRef } from "../admitted-run-context.js";
import { readParentExecutionIdentity } from "../subagents/spawn/execution-identity-spawn-context.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "../subagents/swarm/swarm-code-mode.js";
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn();
@@ -234,6 +238,45 @@ describe("sessions_spawn tool", () => {
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it.each([
{ runtime: "subagent" as const, spawn: hoisted.spawnSubagentDirectMock },
{ runtime: "acp" as const, spawn: hoisted.spawnAcpDirectMock },
])(
"forwards the exact private parent token to the $runtime spawn owner",
async ({ runtime, spawn }) => {
if (runtime === "acp") {
registerAcpBackendForTest();
}
const parentToken = createExecutionIdentityAdmissionToken("parent-run", {
contextId: "parent-context",
executionId: "parent-execution",
now: 100,
});
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
const result = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
operationalRunInstance: createOperationalRunInstanceRef("parent-run"),
executionIdentityToken: parentToken,
},
async () =>
await tool.execute(`spawn-${runtime}`, {
task: "inspect child lineage",
runtime,
...(runtime === "acp" ? { agentId: "codex" } : {}),
}),
);
const context = mockCallArg(spawn, 0, 1, `${runtime} spawn`);
expect(readParentExecutionIdentity(context)).toBe(parentToken);
expect(JSON.stringify(tool.parameters)).not.toContain("parentExecutionIdentityToken");
expect(JSON.stringify(result.details)).not.toContain("parent-context");
expect(JSON.stringify(result.details)).not.toContain("parent-execution");
},
);
it.each([
{ label: "native", args: { task: "investigate", runtime: "subagent" } },
{ label: "ACP", args: { task: "investigate", runtime: "acp" }, acp: true },
+49 -40
View File
@@ -23,6 +23,7 @@ import {
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";
import { getSubagentDeliveryBacklogPressure } from "../subagents/registry/subagent-registry.js";
import { withParentExecutionIdentity } from "../subagents/spawn/execution-identity-spawn-context.js";
import { resolveAcpSessionsSpawnImageAttachments } from "../subagents/spawn/subagent-attachments.js";
import {
SUBAGENT_SPAWN_CONTEXT_MODES,
@@ -48,6 +49,7 @@ import {
readToolStringParam,
ToolInputError,
} from "./common.js";
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
resolveEffectiveSessionToolsVisibility,
@@ -489,6 +491,7 @@ export function createSessionsSpawnTool(
mimeType?: string;
}>)
: undefined;
const parentExecutionIdentityToken = getGatewayToolCallerIdentity()?.executionIdentityToken;
if (runtime === "acp") {
const { spawnAcpDirect } = await loadAcpSpawnModule();
@@ -522,25 +525,28 @@ export function createSessionsSpawnTool(
streamTo,
attachments: acpAttachments?.attachments,
},
{
agentSessionKey: opts?.agentSessionKey,
requesterTurnRunId: opts?.requesterTurnRunId,
completionOwnerKey: opts?.completionOwnerKey,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId ?? undefined,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
sandboxed: opts?.sandboxed,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
},
withParentExecutionIdentity(
{
agentSessionKey: opts?.agentSessionKey,
requesterTurnRunId: opts?.requesterTurnRunId,
completionOwnerKey: opts?.completionOwnerKey,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId ?? undefined,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
sandboxed: opts?.sandboxed,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
},
parentExecutionIdentityToken,
),
);
return jsonResult(addRoleToFailureResult(result, requestedAgentId));
}
@@ -586,27 +592,30 @@ export function createSessionsSpawnTool(
? readToolStringParam(params.attachAs as Record<string, unknown>, "mountPath")
: undefined,
},
{
agentSessionKey: opts?.agentSessionKey,
requesterTurnRunId: opts?.requesterTurnRunId,
completionOwnerKey: opts?.completionOwnerKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget ?? opts?.currentChannelId,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
workspaceDir: opts?.workspaceDir,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
requesterRunId: opts?.requesterRunId,
},
withParentExecutionIdentity(
{
agentSessionKey: opts?.agentSessionKey,
requesterTurnRunId: opts?.requesterTurnRunId,
completionOwnerKey: opts?.completionOwnerKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget ?? opts?.currentChannelId,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
workspaceDir: opts?.workspaceDir,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
requesterRunId: opts?.requesterRunId,
},
parentExecutionIdentityToken,
),
);
return jsonResult(addRoleToFailureResult(result, requestedAgentId));
+44 -18
View File
@@ -6,6 +6,7 @@ import { type Static, Type } from "typebox";
import { Value } from "typebox/value";
import { redactSensitiveText } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { executionIdentitySpawnAdmission } from "./execution-identity-spawn-admission.js";
const EXECUTION_IDENTITY_ADMISSION_MAX_BYTES = 16 * 1024;
const EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS = 16;
@@ -179,11 +180,11 @@ function uniqueSorted<T>(values: readonly T[], key: (value: T) => string): T[] {
}
function freezeEnvelope<T>(value: T, seen = new WeakSet<object>()): T {
if (!value || typeof value !== "object" || seen.has(value as object)) {
if (!value || typeof value !== "object" || seen.has(value)) {
return value;
}
seen.add(value as object);
for (const nested of Object.values(value as Record<string, unknown>)) {
seen.add(value);
for (const nested of Object.values(value)) {
freezeEnvelope(nested, seen);
}
return Object.freeze(value);
@@ -246,27 +247,39 @@ function copyOwnedData<T>(value: T, ancestors = new WeakSet<object>()): T {
}
}
function validateEnvelope(value: unknown): ExecutionIdentityAdmissionEnvelope {
function validateEnvelope(
value: unknown,
): ExecutionIdentityAdmissionEnvelope & Record<string, unknown> {
const owned = copyOwnedData(value);
const baseEnvelope = executionIdentitySpawnAdmission({
operation: "base-envelope",
value: owned,
});
if (
!Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, owned) ||
!Number.isSafeInteger(owned.createdAt)
!Value.Check(ExecutionIdentityAdmissionEnvelopeSchema, baseEnvelope) ||
typeof baseEnvelope.createdAt !== "number" ||
!Number.isSafeInteger(baseEnvelope.createdAt)
) {
throw new Error("execution identity admission envelope violates its bounded contract");
}
if (!owned || typeof owned !== "object" || Array.isArray(owned)) {
throw new Error("execution identity admission envelope violates its bounded contract");
}
const encoded = JSON.stringify(owned);
if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_ADMISSION_MAX_BYTES) {
throw new Error("execution identity admission envelope exceeds 16 KiB");
}
return owned;
return owned as ExecutionIdentityAdmissionEnvelope & Record<string, unknown>;
}
function validateFacts(value: unknown): ExecutionIdentityAdmissionFacts {
const owned = copyOwnedData(value);
const baseFacts = executionIdentitySpawnAdmission({ operation: "base-facts", value });
const owned = copyOwnedData(baseFacts);
if (!Value.Check(ExecutionIdentityAdmissionFactsSchema, owned)) {
throw new Error("execution identity admission facts violate their bounded contract");
}
return owned;
const spawnFacts = executionIdentitySpawnAdmission({ operation: "read", value });
return executionIdentitySpawnAdmission({ operation: "attach", value: owned, extra: spawnFacts });
}
function validateToken(value: unknown): ExecutionIdentityAdmissionToken {
@@ -330,7 +343,8 @@ function captureExecutionIdentityAdmissionEnvelope(
strength: "boundary-verified" as const,
},
];
const envelope = {
const serializedSpawnFacts = executionIdentitySpawnAdmission({ operation: "read", value: facts });
const baseEnvelope = {
envelopeVersion: 1 as const,
contextId: ownedToken.contextId,
executionId: ownedToken.executionId,
@@ -367,6 +381,11 @@ function captureExecutionIdentityAdmissionEnvelope(
strength: item.strength,
})),
};
const envelope = executionIdentitySpawnAdmission({
operation: "extend-envelope",
value: baseEnvelope,
extra: serializedSpawnFacts,
});
return freezeEnvelope(validateEnvelope(envelope));
}
@@ -375,14 +394,21 @@ export function parseExecutionIdentityAdmissionEnvelope(
value: unknown,
): ExecutionIdentityAdmissionEnvelope {
const envelope = validateEnvelope(value);
const parsed = captureExecutionIdentityAdmissionEnvelope(envelope, {
token: createExecutionIdentityAdmissionToken(envelope.runId, {
contextId: envelope.contextId,
executionId: envelope.executionId,
now: envelope.createdAt,
}),
runtimeInstanceId: envelope.runtimeInstanceId,
});
const spawnFacts = executionIdentitySpawnAdmission({ operation: "read", value: envelope });
if (!spawnFacts) {
throw new Error("execution identity admission envelope is missing spawn evidence state");
}
const parsed = captureExecutionIdentityAdmissionEnvelope(
executionIdentitySpawnAdmission({ operation: "attach", value: envelope, extra: spawnFacts }),
{
token: createExecutionIdentityAdmissionToken(envelope.runId, {
contextId: envelope.contextId,
executionId: envelope.executionId,
now: envelope.createdAt,
}),
runtimeInstanceId: envelope.runtimeInstanceId,
},
);
if (JSON.stringify(parsed) !== JSON.stringify(envelope)) {
throw new Error("execution identity admission envelope is not canonical");
}
+51 -4
View File
@@ -4,6 +4,7 @@ import type { ExecutionIdentityContextV1 } from "../../packages/gateway-protocol
import { validateExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js";
import { pseudonymizeExecutionIdentityRef } from "./audit-identity.js";
import type { ExecutionIdentityAdmissionEnvelope } from "./execution-identity-admission.js";
import { executionIdentitySpawnAdmission } from "./execution-identity-spawn-admission.js";
const EXECUTION_IDENTITY_CONTEXT_MAX_BYTES = 16 * 1024;
@@ -107,8 +108,52 @@ export function buildExecutionIdentityContext(
})),
(grant) => `${grant.grantRef}\0${grant.state}`,
);
const missingEvidence = envelope.invoker?.state === "present" ? [] : ["invoker.principal"];
const context: ExecutionIdentityContextV1 = {
const serializedSpawnFacts = executionIdentitySpawnAdmission({
operation: "read",
value: envelope,
});
const [lineageFacts, spawnMissingEvidence] = serializedSpawnFacts
? executionIdentitySpawnAdmission({ operation: "parse", value: serializedSpawnFacts })
: [undefined, []];
const lineage = lineageFacts
? {
...(typeof lineageFacts.parentContextId === "string"
? { parentContextId: lineageFacts.parentContextId }
: {}),
...(typeof lineageFacts.parentExecutionId === "string"
? { parentExecutionId: lineageFacts.parentExecutionId }
: {}),
...(typeof lineageFacts.parentRunId === "string"
? { parentRunId: lineageFacts.parentRunId }
: {}),
parentAgentPrincipal: {
kind: "agent" as const,
domainRef,
principalRef: lineageFacts.parentAgentId,
},
delegationRef: hmacRef(
db,
"grant",
`${domainRef}:delegation`,
JSON.stringify([
lineageFacts.relation,
lineageFacts.rawRequesterRef,
lineageFacts.rawControllerRef,
lineageFacts.localPolicyRefs,
lineageFacts.targetPolicyRefs,
]),
),
depth: lineageFacts.depth,
}
: undefined;
const missingEvidence = uniqueSorted(
[
...(envelope.invoker?.state === "present" ? [] : ["invoker.principal"]),
...spawnMissingEvidence,
],
(item) => item,
);
const context: Record<string, unknown> = {
schemaVersion: 1,
contextId,
executionId,
@@ -136,8 +181,10 @@ export function buildExecutionIdentityContext(
runtimeInstance: { runtimeRef, kind: envelope.runtime.kind, state: "present" },
applicableGrants,
assurance,
coverageState:
envelope.invoker?.state === "present"
...(lineage ? { lineage } : {}),
coverageState: lineage
? "attribution-only"
: envelope.invoker?.state === "present"
? "attribution-only"
: envelope.invoker?.state === "unknown"
? "unknown"
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
configureExecutionIdentityAdmissionSink,
enqueueExecutionIdentityContextAtAdmission,
type ExecutionIdentityAdmissionEnvelope,
type ExecutionIdentityAdmissionFacts,
} from "./execution-identity-admission.js";
import { processExecutionIdentityAdmissionWork } from "./execution-identity-context.js";
import { executionIdentitySpawnAdmission } from "./execution-identity-spawn-admission.js";
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function facts(
runId: string,
overrides: Partial<ExecutionIdentityAdmissionFacts> & {
spawnLineage?: Record<string, unknown>;
spawnMissingEvidence?: string[];
} = {},
): ExecutionIdentityAdmissionFacts {
const { spawnLineage, spawnMissingEvidence, ...admissionOverrides } = overrides;
return executionIdentitySpawnAdmission({
operation: "attach",
value: {
runId,
agentId: "main",
ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
runtime: { kind: "embedded" },
...admissionOverrides,
},
extra:
spawnLineage || spawnMissingEvidence
? executionIdentitySpawnAdmission({
operation: "serialize",
value: spawnLineage,
extra: spawnMissingEvidence ?? [],
})
: undefined,
});
}
function prepareContext(
admissionFacts: ExecutionIdentityAdmissionFacts,
ids: { contextId: string; executionId: string; now?: number },
) {
let envelope: ExecutionIdentityAdmissionEnvelope | undefined;
const clear = configureExecutionIdentityAdmissionSink((captured) => {
if (captured.kind === "capture") {
envelope = captured.envelope;
}
return true;
});
try {
enqueueExecutionIdentityContextAtAdmission(admissionFacts, {
enabled: true,
contextId: ids.contextId,
executionId: ids.executionId,
...(ids.now !== undefined ? { now: ids.now } : {}),
});
} finally {
clear();
}
if (!envelope) {
throw new Error("expected admission envelope");
}
return processExecutionIdentityAdmissionWork(
{ kind: "capture", envelope },
{
env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-lineage-") },
...(ids.now !== undefined ? { now: ids.now } : {}),
},
);
}
describe("execution identity child lineage", () => {
it("preserves private spawn facts across the prepared-admission copy", () => {
const context = prepareContext(
{
...facts("copied-child-run", {
spawnLineage: {
parentContextId: "copied-parent-context",
parentExecutionId: "copied-parent-execution",
parentRunId: "copied-parent-run",
parentAgentId: "parent-agent",
relation: "sessions_spawn",
rawRequesterRef: "requester",
rawControllerRef: "controller",
depth: 1,
localPolicyRefs: [],
targetPolicyRefs: [],
},
}),
},
{ contextId: "copied-child-context", executionId: "copied-child-execution", now: 100 },
);
expect(context.lineage).toMatchObject({
parentContextId: "copied-parent-context",
parentExecutionId: "copied-parent-execution",
parentRunId: "copied-parent-run",
depth: 1,
});
});
it("projects bounded narrowing inputs without retaining raw owner refs", () => {
const context = prepareContext(
facts("child-run", {
ingress: { kind: "subagent", boundary: "sessions_spawn.subagent", state: "present" },
invoker: { state: "present", kind: "agent", rawPrincipalRef: "parent-agent" },
applicableGrants: [{ rawGrantRef: "tool:sessions_spawn", state: "present" }],
assurance: [
{
kind: "spawn-lineage",
rawEvidenceRef: "native-spawn-proof",
strength: "boundary-verified",
},
],
spawnLineage: {
parentContextId: "parent-context",
parentExecutionId: "parent-execution",
parentRunId: "parent-run",
parentAgentId: "parent-agent",
relation: "sessions_spawn",
rawRequesterRef: "agent:main:private-requester",
rawControllerRef: "agent:main:private-controller",
depth: 2,
localPolicyRefs: ["local-policy-secret"],
targetPolicyRefs: ["target-policy-secret"],
},
}),
{ contextId: "child-context", executionId: "child-execution", now: 100 },
);
expect(context.coverageState).toBe("attribution-only");
expect(context.lineage).toMatchObject({
parentContextId: "parent-context",
parentExecutionId: "parent-execution",
parentRunId: "parent-run",
parentAgentPrincipal: { kind: "agent", principalRef: "parent-agent" },
depth: 2,
delegationRef: expect.any(String),
});
expect(context.applicableGrants).toHaveLength(1);
expect(context.assurance).toHaveLength(1);
expect(context.missingEvidence).toEqual([]);
expect(JSON.stringify(context)).not.toMatch(
/private-requester|private-controller|local-policy-secret|target-policy-secret|native-spawn-proof|tool:sessions_spawn/,
);
});
it("keeps attribution visible when parent evidence and ACP callbacks are unavailable", () => {
const context = prepareContext(
facts("acp-child", {
ingress: { kind: "acp", boundary: "sessions_spawn.acp", state: "present" },
invoker: { state: "present", kind: "agent", rawPrincipalRef: "parent-agent" },
spawnLineage: {
parentAgentId: "parent-agent",
relation: "sessions_spawn",
rawRequesterRef: "requester",
rawControllerRef: "controller",
depth: 1,
localPolicyRefs: [],
targetPolicyRefs: [],
},
spawnMissingEvidence: [
"lineage.parent-context",
"lineage.parent-execution",
"lineage.parent-run",
"acp.native-action-callback",
],
}),
{ contextId: "acp-context", executionId: "acp-execution" },
);
expect(context.coverageState).toBe("attribution-only");
expect(context.lineage).toMatchObject({ depth: 1 });
expect(context.lineage).not.toHaveProperty("parentContextId");
expect(context.missingEvidence).toEqual([
"acp.native-action-callback",
"lineage.parent-context",
"lineage.parent-execution",
"lineage.parent-run",
]);
});
});
@@ -0,0 +1,249 @@
const EXECUTION_IDENTITY_SPAWN_ADMISSION_FACTS = Symbol("executionIdentitySpawnAdmissionFacts");
type ExecutionIdentitySpawnLineage = {
parentContextId?: string;
parentExecutionId?: string;
parentRunId?: string;
parentAgentId: string;
relation: "sessions_spawn";
rawRequesterRef: string;
rawControllerRef: string;
depth: number;
localPolicyRefs: string[];
targetPolicyRefs: string[];
};
type ExecutionIdentitySpawnAdmissionExtension = {
lineage?: ExecutionIdentitySpawnLineage;
missingEvidence: string[];
};
type ExecutionIdentitySpawnAdmissionInput =
| { operation: "serialize"; value: unknown; extra: unknown }
| { operation: "parse"; value: unknown }
| { operation: "attach"; value: unknown; extra?: unknown }
| { operation: "base-facts"; value: unknown }
| { operation: "extend-envelope"; value: unknown; extra?: unknown }
| { operation: "base-envelope"; value: unknown }
| { operation: "read"; value: unknown };
function isCarrierRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function ownDataDescriptor(value: object, key: PropertyKey): PropertyDescriptor | undefined {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor) {
return undefined;
}
if (!descriptor.enumerable || !("value" in descriptor)) {
throw new Error("execution identity spawn admission carrier is invalid");
}
return descriptor;
}
function copyBaseFacts(value: object): Record<string, unknown> {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new Error("execution identity spawn admission carrier is invalid");
}
const descriptors: Record<PropertyKey, PropertyDescriptor> =
Object.getOwnPropertyDescriptors(value);
for (const key of Reflect.ownKeys(descriptors)) {
const descriptor = descriptors[key];
if (
!descriptor ||
!descriptor.enumerable ||
!("value" in descriptor) ||
(typeof key !== "string" &&
(key !== EXECUTION_IDENTITY_SPAWN_ADMISSION_FACTS || typeof descriptor.value !== "string"))
) {
throw new Error("execution identity spawn admission carrier is invalid");
}
if (typeof key !== "string") {
delete descriptors[key];
}
}
return Object.create(null, descriptors);
}
function validRef(value: unknown, maxLength: number): value is string {
return typeof value === "string" && value.length >= 1 && value.length <= maxLength;
}
function validateEnvelopeExtension(
lineage: unknown,
missingEvidence: unknown,
): ExecutionIdentitySpawnAdmissionExtension {
if (
!Array.isArray(missingEvidence) ||
missingEvidence.length > 16 ||
!missingEvidence.every((item) => validRef(item, 256))
) {
throw new Error("execution identity spawn missing-evidence facts are invalid");
}
if (lineage === undefined || lineage === null) {
return { missingEvidence };
}
if (
!isCarrierRecord(lineage) ||
(lineage.parentContextId !== undefined && !validRef(lineage.parentContextId, 256)) ||
(lineage.parentExecutionId !== undefined && !validRef(lineage.parentExecutionId, 256)) ||
(lineage.parentRunId !== undefined && !validRef(lineage.parentRunId, 256)) ||
!validRef(lineage.parentAgentId, 256) ||
lineage.relation !== "sessions_spawn" ||
!validRef(lineage.rawRequesterRef, 4_096) ||
!validRef(lineage.rawControllerRef, 4_096) ||
!Number.isSafeInteger(lineage.depth) ||
typeof lineage.depth !== "number" ||
lineage.depth < 1 ||
lineage.depth > 64 ||
!Array.isArray(lineage.localPolicyRefs) ||
lineage.localPolicyRefs.length > 16 ||
!lineage.localPolicyRefs.every((item) => validRef(item, 4_096)) ||
!Array.isArray(lineage.targetPolicyRefs) ||
lineage.targetPolicyRefs.length > 16 ||
!lineage.targetPolicyRefs.every((item) => validRef(item, 4_096))
) {
throw new Error("execution identity spawn lineage facts are invalid");
}
return {
lineage: {
...(lineage.parentContextId !== undefined
? { parentContextId: lineage.parentContextId }
: {}),
...(lineage.parentExecutionId !== undefined
? { parentExecutionId: lineage.parentExecutionId }
: {}),
...(lineage.parentRunId !== undefined ? { parentRunId: lineage.parentRunId } : {}),
parentAgentId: lineage.parentAgentId,
relation: lineage.relation,
rawRequesterRef: lineage.rawRequesterRef,
rawControllerRef: lineage.rawControllerRef,
depth: lineage.depth,
localPolicyRefs: lineage.localPolicyRefs,
targetPolicyRefs: lineage.targetPolicyRefs,
},
missingEvidence,
};
}
function uniqueSorted(values: readonly string[]): string[] {
return [...new Set(values)].toSorted();
}
export function executionIdentitySpawnAdmission(input: {
operation: "serialize";
value: unknown;
extra: unknown;
}): string;
export function executionIdentitySpawnAdmission(input: {
operation: "parse";
value: unknown;
}): readonly [ExecutionIdentitySpawnLineage | undefined, string[]];
export function executionIdentitySpawnAdmission<T extends object>(input: {
operation: "attach";
value: T;
extra?: unknown;
}): T;
export function executionIdentitySpawnAdmission(input: {
operation: "base-facts";
value: unknown;
}): Record<string, unknown>;
export function executionIdentitySpawnAdmission<T extends object>(input: {
operation: "extend-envelope";
value: T;
extra?: unknown;
}): T & ExecutionIdentitySpawnAdmissionExtension;
export function executionIdentitySpawnAdmission(input: {
operation: "base-envelope";
value: unknown;
}): Record<string, unknown>;
export function executionIdentitySpawnAdmission(input: {
operation: "read";
value: unknown;
}): string | undefined;
export function executionIdentitySpawnAdmission(
operationInput: ExecutionIdentitySpawnAdmissionInput,
): unknown {
const { operation, value } = operationInput;
if (operation === "serialize") {
const extension = validateEnvelopeExtension(value, operationInput.extra);
return JSON.stringify([extension.lineage ?? null, extension.missingEvidence]);
}
if (operation === "parse") {
if (typeof value !== "string") {
throw new Error("execution identity spawn admission facts are invalid");
}
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed) || parsed.length !== 2) {
throw new Error("execution identity spawn admission facts are invalid");
}
const extension = validateEnvelopeExtension(parsed[0], parsed[1]);
return [extension.lineage, extension.missingEvidence];
}
if (!isCarrierRecord(value)) {
throw new Error("execution identity spawn admission carrier is invalid");
}
if (operation === "attach") {
if (typeof operationInput.extra !== "string") {
return value;
}
const carried = Object.create(
Object.getPrototypeOf(value),
Object.getOwnPropertyDescriptors(value),
);
Object.defineProperty(carried, EXECUTION_IDENTITY_SPAWN_ADMISSION_FACTS, {
configurable: true,
enumerable: true,
value: operationInput.extra,
writable: true,
});
return carried;
}
if (operation === "base-facts") {
return copyBaseFacts(value);
}
if (operation === "extend-envelope") {
const serialized =
typeof operationInput.extra === "string" ? operationInput.extra : JSON.stringify([null, []]);
const [lineage, missingEvidence] = executionIdentitySpawnAdmission({
operation: "parse",
value: serialized,
});
const normalizedLineage = lineage
? {
...lineage,
localPolicyRefs: uniqueSorted(lineage.localPolicyRefs),
targetPolicyRefs: uniqueSorted(lineage.targetPolicyRefs),
}
: undefined;
return {
...value,
...(normalizedLineage ? { lineage: normalizedLineage } : {}),
missingEvidence: uniqueSorted(missingEvidence),
};
}
if (operation === "base-envelope") {
const baseEnvelope = copyBaseFacts(value);
const { lineage, missingEvidence } = baseEnvelope;
validateEnvelopeExtension(lineage, missingEvidence);
delete baseEnvelope.lineage;
delete baseEnvelope.missingEvidence;
return baseEnvelope;
}
const attached = ownDataDescriptor(value, EXECUTION_IDENTITY_SPAWN_ADMISSION_FACTS)?.value;
if (attached !== undefined) {
if (typeof attached !== "string") {
throw new Error("execution identity spawn admission carrier is invalid");
}
return attached;
}
const missingEvidence = ownDataDescriptor(value, "missingEvidence")?.value;
if (!Array.isArray(missingEvidence)) {
return undefined;
}
const lineage = ownDataDescriptor(value, "lineage")?.value;
const extension = validateEnvelopeExtension(lineage, missingEvidence);
return JSON.stringify([extension.lineage ?? null, extension.missingEvidence]);
}
+18 -1
View File
@@ -481,6 +481,18 @@ describe("audit run explanation", () => {
strength: "boundary-verified",
},
],
lineage: {
parentContextId: "parent-context",
parentExecutionId: "parent-execution",
parentRunId: "parent-run",
parentAgentPrincipal: {
kind: "agent",
domainRef: hmacRef,
principalRef: "parent-agent",
},
delegationRef: hmacRef,
depth: 2,
},
coverageState: "unattributed",
missingEvidence: ["invoker.principal"],
},
@@ -591,7 +603,12 @@ describe("audit run explanation", () => {
"Sponsor [absent]",
"Applicable grants [absent]",
"Assurance [present]",
"Parent [absent]",
"Parent context [present]",
"Parent execution [present]",
"Parent run [present]",
"Parent agent [present]",
"Delegation [present]",
"Depth [present]",
]) {
expect(output).toContain(label);
}
+28 -9
View File
@@ -368,6 +368,33 @@ function contextIdentityLines(context: ExecutionIdentityContextV1): string[] {
];
}
function contextLineageLines(context: ExecutionIdentityContextV1): string[] {
const lineage = context.lineage;
if (!lineage) {
return [fieldLine("Parent", "absent")];
}
return [
fieldLine(
"Parent context",
lineage.parentContextId ? "present" : "unknown",
lineage.parentContextId,
),
fieldLine(
"Parent execution",
lineage.parentExecutionId ? "present" : "unknown",
lineage.parentExecutionId,
),
fieldLine("Parent run", lineage.parentRunId ? "present" : "unknown", lineage.parentRunId),
fieldLine(
"Parent agent",
lineage.parentAgentPrincipal ? "present" : "unknown",
lineage.parentAgentPrincipal ? principalText(lineage.parentAgentPrincipal) : undefined,
),
fieldLine("Delegation", lineage.delegationRef ? "present" : "unknown", lineage.delegationRef),
fieldLine("Depth", "present", String(lineage.depth)),
];
}
function unavailableIdentityLines(state: "unknown" | "unsupported"): string[] {
return IDENTITY_FIELD_LABELS.map((label) => fieldLine(label, state));
}
@@ -417,15 +444,7 @@ function formatAuditRunInspection(result: AuditRunInspectResult): string[] {
...identityLines.slice(8),
"",
"Lineage",
result.identity.context.lineage
? fieldLine(
"Parent",
"present",
result.identity.context.lineage.parentRunId ??
result.identity.context.lineage.parentContextId ??
`depth ${String(result.identity.context.lineage.depth)}`,
)
: fieldLine("Parent", "absent"),
...contextLineageLines(result.identity.context),
);
} else if (result.identity.state === "ambiguous") {
lines.push(
@@ -0,0 +1,203 @@
import { randomUUID } from "node:crypto";
import type { ExecutionIdentityAdmissionToken } from "../audit/execution-identity-admission.js";
import {
validateAgentRunDelegatedAuthority,
type AgentRunDelegatedAuthority,
} from "../infra/agent-run-registry.js";
import { resolveGlobalMap } from "../shared/global-singleton.js";
import type { AgentRuntimeSessionSpawnContext } from "./agent-runtime-session-spawn-context.js";
type AgentRuntimeExecutionLineage = {
relation: "sessions_spawn";
requesterRef: string;
controllerRef: string;
depth: number;
applicableGrantRefs: string[];
localPolicyRefs: string[];
runtimeAssuranceRefs: string[];
targetPolicyRefs: string[];
externalNativeActions: "observable" | "unsupported";
};
const AGENT_RUNTIME_EXECUTION_LINEAGE = Symbol("agentRuntimeExecutionLineage");
const AGENT_RUNTIME_EXECUTION_LINEAGE_REDEMPTION = Symbol("agentRuntimeExecutionLineageRedemption");
const EXECUTION_LINEAGE_HANDOFF_TTL_MS = 60_000;
const MAX_EXECUTION_LINEAGE_HANDOFFS = 256;
type AgentRuntimeExecutionLineageCarrier = {
[AGENT_RUNTIME_EXECUTION_LINEAGE]?: AgentRuntimeExecutionLineage;
};
type AgentRuntimeExecutionLineageRedemption = Readonly<{ consume: () => boolean }>;
type AgentRuntimeExecutionLineageRedemptionCarrier = {
[AGENT_RUNTIME_EXECUTION_LINEAGE_REDEMPTION]: AgentRuntimeExecutionLineageRedemption;
};
function hasAgentRuntimeExecutionLineageRedemption(
identity: object,
): identity is object & AgentRuntimeExecutionLineageRedemptionCarrier {
return AGENT_RUNTIME_EXECUTION_LINEAGE_REDEMPTION in identity;
}
type ExecutionLineageHandoff = Readonly<{
agentId: string;
sessionKey: string;
operationalRunInstance: Readonly<{ instanceId: string; runId: string }>;
delegatedAuthority: AgentRunDelegatedAuthority;
executionIdentity?: ExecutionIdentityAdmissionToken;
sessionSpawnContext: AgentRuntimeSessionSpawnContext & AgentRuntimeExecutionLineageCarrier;
expiresAtMs: number;
}>;
const executionLineageHandoffs = resolveGlobalMap<string, ExecutionLineageHandoff>(
Symbol.for("openclaw.agentRuntimeExecutionLineageHandoffs"),
(handoffs) => handoffs.clear(),
);
function sameOperationalRunInstance(
left: Readonly<{ instanceId: string; runId: string }>,
right: Readonly<{ instanceId: string; runId: string }>,
): boolean {
return left.instanceId === right.instanceId && left.runId === right.runId;
}
function pruneExecutionLineageHandoffs(nowMs: number): void {
for (const [id, handoff] of executionLineageHandoffs) {
if (
handoff.expiresAtMs <= nowMs ||
!validateAgentRunDelegatedAuthority(handoff.delegatedAuthority)
) {
executionLineageHandoffs.delete(id);
}
}
// A lost local connection must not leave an unbounded process-lifetime registry.
// Oldest insertion wins because Map preserves insertion order.
while (executionLineageHandoffs.size >= MAX_EXECUTION_LINEAGE_HANDOFFS) {
const oldest = executionLineageHandoffs.keys().next().value;
if (typeof oldest !== "string") {
break;
}
executionLineageHandoffs.delete(oldest);
}
}
/** Add process-local lineage without expanding or serializing the spawn context. */
export function withAgentRuntimeExecutionLineage<T extends AgentRuntimeSessionSpawnContext>(
context: T,
lineage: AgentRuntimeExecutionLineage,
): T & AgentRuntimeExecutionLineageCarrier {
return { ...context, [AGENT_RUNTIME_EXECUTION_LINEAGE]: lineage };
}
export function readAgentRuntimeExecutionLineage(
context: (AgentRuntimeSessionSpawnContext & AgentRuntimeExecutionLineageCarrier) | undefined,
): AgentRuntimeExecutionLineage | undefined {
return context?.[AGENT_RUNTIME_EXECUTION_LINEAGE];
}
/** Register a local one-shot handoff; its opaque id is correlation, never authority. */
export function createAgentRuntimeExecutionLineageHandoff(params: {
agentId: string;
sessionKey: string;
operationalRunInstance: Readonly<{ instanceId: string; runId: string }>;
delegatedAuthority: AgentRunDelegatedAuthority;
executionIdentity?: ExecutionIdentityAdmissionToken;
sessionSpawnContext: AgentRuntimeSessionSpawnContext;
}): Readonly<{ id: string; revoke: () => void }> | undefined {
const lineage = readAgentRuntimeExecutionLineage(params.sessionSpawnContext);
if (!lineage || !validateAgentRunDelegatedAuthority(params.delegatedAuthority)) {
return undefined;
}
if (
!sameOperationalRunInstance(
params.operationalRunInstance,
params.delegatedAuthority.operationalRunInstance,
) ||
(params.executionIdentity !== undefined &&
params.executionIdentity.runId !== params.operationalRunInstance.runId)
) {
throw new Error("execution lineage handoff disagrees with its parent admission");
}
const nowMs = Date.now();
pruneExecutionLineageHandoffs(nowMs);
const id = randomUUID();
executionLineageHandoffs.set(
id,
Object.freeze({
agentId: params.agentId,
sessionKey: params.sessionKey,
operationalRunInstance: params.operationalRunInstance,
delegatedAuthority: params.delegatedAuthority,
...(params.executionIdentity ? { executionIdentity: params.executionIdentity } : {}),
sessionSpawnContext: params.sessionSpawnContext,
expiresAtMs: nowMs + EXECUTION_LINEAGE_HANDOFF_TTL_MS,
}),
);
return Object.freeze({
id,
revoke: () => {
executionLineageHandoffs.delete(id);
},
});
}
/** Redeem the host-owned handoff while binding it to the exact signed parent owner. */
export function redeemAgentRuntimeExecutionLineageHandoff(params: {
id: string;
agentId: string;
sessionKey: string;
operationalRunInstance: Readonly<{ instanceId: string; runId: string }>;
delegatedAuthority: AgentRunDelegatedAuthority;
}):
| Readonly<{
executionIdentity?: ExecutionIdentityAdmissionToken;
sessionSpawnContext: AgentRuntimeSessionSpawnContext;
redemption: AgentRuntimeExecutionLineageRedemption;
}>
| undefined {
const handoff = executionLineageHandoffs.get(params.id);
executionLineageHandoffs.delete(params.id);
if (
!handoff ||
handoff.expiresAtMs <= Date.now() ||
handoff.agentId !== params.agentId ||
handoff.sessionKey !== params.sessionKey ||
!sameOperationalRunInstance(handoff.operationalRunInstance, params.operationalRunInstance) ||
handoff.delegatedAuthority.claimId !== params.delegatedAuthority.claimId ||
handoff.delegatedAuthority.lifecycleGeneration !==
params.delegatedAuthority.lifecycleGeneration ||
!validateAgentRunDelegatedAuthority(handoff.delegatedAuthority)
) {
return undefined;
}
let consumed = false;
return Object.freeze({
...(handoff.executionIdentity ? { executionIdentity: handoff.executionIdentity } : {}),
sessionSpawnContext: handoff.sessionSpawnContext,
redemption: Object.freeze({
consume: () => {
if (consumed || !validateAgentRunDelegatedAuthority(handoff.delegatedAuthority)) {
return false;
}
consumed = true;
return true;
},
}),
});
}
export function withAgentRuntimeExecutionLineageRedemption<T extends object>(
identity: T,
redemption: AgentRuntimeExecutionLineageRedemption,
): T & AgentRuntimeExecutionLineageRedemptionCarrier {
return { ...identity, [AGENT_RUNTIME_EXECUTION_LINEAGE_REDEMPTION]: redemption };
}
/** Direct in-process lineage needs no redemption; handed-off lineage is one-shot. */
export function consumeAgentRuntimeExecutionLineage(identity: object): boolean {
return hasAgentRuntimeExecutionLineageRedemption(identity)
? identity[AGENT_RUNTIME_EXECUTION_LINEAGE_REDEMPTION].consume()
: true;
}
@@ -14,6 +14,10 @@ import { readExecApprovalsSnapshot } from "../infra/exec-approvals-store.js";
import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
readAgentRuntimeExecutionLineage,
withAgentRuntimeExecutionLineage,
} from "./agent-runtime-execution-lineage.js";
const envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
@@ -267,27 +271,52 @@ describe("agent runtime identity token", () => {
});
});
it("round-trips a signed visible-session spawn policy", async () => {
it("round-trips spawn policy without serializing private lineage", async () => {
useTempHome();
const runtimeToken = await importRuntimeTokenModule();
const parentExecutionIdentity = createExecutionIdentityAdmissionToken("run-1", {
contextId: "parent-context",
executionId: "parent-execution",
});
const token = await runtimeToken.mintAgentRuntimeIdentityToken({
agentId: "main",
sessionKey: "agent:main:main",
...operationalRun(),
sessionSpawnContext: {
completionOwnerSessionKey: " agent:main:discord:direct:alice ",
inheritedToolPolicy: {
version: 1,
allow: [" read ", "sessions_spawn"],
deny: ["exec"],
executionIdentityToken: parentExecutionIdentity,
sessionSpawnContext: withAgentRuntimeExecutionLineage(
{
completionOwnerSessionKey: " agent:main:discord:direct:alice ",
inheritedToolPolicy: {
version: 1,
allow: [" read ", "sessions_spawn"],
deny: ["exec"],
},
},
},
{
relation: "sessions_spawn",
requesterRef: "private-requester-ref",
controllerRef: "private-controller-ref",
depth: 2,
applicableGrantRefs: ["tool:sessions_spawn"],
localPolicyRefs: ["local-policy"],
runtimeAssuranceRefs: ["spawn-runtime:subagent"],
targetPolicyRefs: ["target-policy"],
externalNativeActions: "observable",
},
),
});
await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toMatchObject({
const [payload] = token.split(".");
const decodedPayload = Buffer.from(payload ?? "", "base64url").toString("utf8");
expect(decodedPayload).not.toContain("private-requester-ref");
expect(decodedPayload).not.toContain("private-controller-ref");
const identity = await runtimeToken.verifyAgentRuntimeIdentityToken(token);
expect(identity).toMatchObject({
kind: "agentRuntime",
agentId: "main",
sessionKey: "agent:main:main",
executionIdentity: parentExecutionIdentity,
sessionSpawnContext: {
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicy: {
@@ -297,6 +326,7 @@ describe("agent runtime identity token", () => {
},
},
});
expect(readAgentRuntimeExecutionLineage(identity?.sessionSpawnContext)).toBeUndefined();
});
it("round-trips a short-lived cron self-management capability", async () => {
+50 -12
View File
@@ -19,6 +19,11 @@ import { ensureExecApprovalsSnapshot, loadExecApprovalsAsync } from "../infra/ex
import { normalizeOptionalAccountId } from "../routing/account-id.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import {
redeemAgentRuntimeExecutionLineageHandoff,
withAgentRuntimeExecutionLineageRedemption,
} from "./agent-runtime-execution-lineage.js";
import type { AgentRuntimeSessionSpawnContext } from "./agent-runtime-session-spawn-context.js";
import type { CronCreatorAuthorityGrant } from "./cron-creator-authority-grant.js";
import type { AgentRuntimeMessageActionContext } from "./message-action-turn-capability.js";
import type { WorkerSessionTurnClaim } from "./worker-environments/placement-record.js";
@@ -63,14 +68,7 @@ export type AgentRuntimeDelegatedAuthority = AgentRunDelegatedAuthority &
}
);
export type AgentRuntimeSessionSpawnContext = {
completionOwnerSessionKey?: string;
inheritedToolPolicy: {
version: 1;
allow: string[];
deny: string[];
};
};
export type { AgentRuntimeSessionSpawnContext } from "./agent-runtime-session-spawn-context.js";
type AgentRuntimeIdentityTokenPayload = {
kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND;
@@ -90,6 +88,7 @@ type AgentRuntimeIdentityTokenPayload = {
cronToolsAllowCapture?: "final-executable-surface";
cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant;
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
executionLineageHandoffId?: string;
};
const normalizedRequiredStringSchema = z
@@ -223,6 +222,7 @@ const agentRuntimeIdentityTokenPayloadSchema = z.object({
cronToolsAllowCapture: z.literal("final-executable-surface").optional(),
cronCreatorAuthorityGrant: cronCreatorAuthorityGrantSchema.optional(),
sessionSpawnContext: sessionSpawnContextSchema.optional(),
executionLineageHandoffId: normalizedRequiredStringSchema.optional(),
});
function decodeDelegatedAuthority(
@@ -359,6 +359,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
return undefined;
}
const sessionSpawnContext = raw.sessionSpawnContext;
const executionLineageHandoffId = raw.executionLineageHandoffId;
const cronToolsAllowCapture = raw.cronToolsAllowCapture;
const cronCreatorAuthorityGrant = raw.cronCreatorAuthorityGrant;
if (cronCreatorAuthorityGrant && !cronToolsAllowCapture) {
@@ -390,6 +391,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
...(messageActionContext ? { messageActionContext } : {}),
...(cronSelfManagementContext ? { cronSelfManagementContext } : {}),
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
...(executionLineageHandoffId ? { executionLineageHandoffId } : {}),
...(cronToolsAllowCapture ? { cronToolsAllowCapture } : {}),
...(cronCreatorAuthorityGrant ? { cronCreatorAuthorityGrant } : {}),
...(executionIdentity ? { executionIdentity } : {}),
@@ -415,6 +417,7 @@ export type AgentRuntimeIdentityTokenParams = {
cronToolsAllowCapture?: "final-executable-surface";
cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant;
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
executionLineageHandoffId?: string;
workerTurnClaim?: WorkerSessionTurnClaim;
};
@@ -424,6 +427,17 @@ function prepareAgentRuntimeIdentityTokenPayload(params: AgentRuntimeIdentityTok
if (!operationalInstanceId || !operationalRunId) {
throw new Error("agent runtime identity requires an operational run instance");
}
const parsedSessionSpawnContext = params.sessionSpawnContext
? sessionSpawnContextSchema.safeParse(params.sessionSpawnContext)
: undefined;
if (parsedSessionSpawnContext && !parsedSessionSpawnContext.success) {
throw new Error("agent runtime session spawn context violates its bounded contract");
}
const sessionSpawnContext = parsedSessionSpawnContext?.data;
const executionLineageHandoffId = normalizeOptionalString(params.executionLineageHandoffId);
if (executionLineageHandoffId && (sessionSpawnContext || params.executionIdentityToken)) {
throw new Error("execution lineage handoff cannot duplicate private spawn facts");
}
const activeAuthority = getActiveAgentRunDelegatedAuthority({
instanceId: operationalInstanceId,
runId: operationalRunId,
@@ -506,7 +520,8 @@ function prepareAgentRuntimeIdentityTokenPayload(params: AgentRuntimeIdentityTok
...(params.cronCreatorAuthorityGrant
? { cronCreatorAuthorityGrant: params.cronCreatorAuthorityGrant }
: {}),
...(params.sessionSpawnContext ? { sessionSpawnContext: params.sessionSpawnContext } : {}),
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
...(executionLineageHandoffId ? { executionLineageHandoffId } : {}),
...(params.executionIdentityToken?.runId === operationalRunId
? { executionIdentity: params.executionIdentityToken }
: {}),
@@ -551,7 +566,19 @@ export async function verifyAgentRuntimeIdentityToken(
if (!payload) {
return undefined;
}
return {
const handoff = payload.executionLineageHandoffId
? redeemAgentRuntimeExecutionLineageHandoff({
id: payload.executionLineageHandoffId,
agentId: payload.agentId,
sessionKey: payload.sessionKey,
operationalRunInstance: payload.operationalRunInstance,
delegatedAuthority: payload.delegatedAuthority,
})
: undefined;
if (payload.executionLineageHandoffId && !handoff) {
return undefined;
}
const identity: AgentRuntimeIdentity = {
kind: "agentRuntime",
agentId: payload.agentId,
sessionKey: payload.sessionKey,
@@ -560,7 +587,11 @@ export async function verifyAgentRuntimeIdentityToken(
...(payload.approvalOwnerPluginId
? { approvalOwnerPluginId: payload.approvalOwnerPluginId }
: {}),
...(payload.executionIdentity ? { executionIdentity: payload.executionIdentity } : {}),
...(handoff?.executionIdentity
? { executionIdentity: handoff.executionIdentity }
: payload.executionIdentity
? { executionIdentity: payload.executionIdentity }
: {}),
...(payload.turnSourceChannel ? { turnSourceChannel: payload.turnSourceChannel } : {}),
...(payload.turnSourceLocal === true ? { turnSourceLocal: true } : {}),
...(payload.turnSourceTo ? { turnSourceTo: payload.turnSourceTo } : {}),
@@ -578,8 +609,15 @@ export async function verifyAgentRuntimeIdentityToken(
...(payload.cronCreatorAuthorityGrant
? { cronCreatorAuthorityGrant: payload.cronCreatorAuthorityGrant }
: {}),
...(payload.sessionSpawnContext ? { sessionSpawnContext: payload.sessionSpawnContext } : {}),
...(handoff?.sessionSpawnContext
? { sessionSpawnContext: handoff.sessionSpawnContext }
: payload.sessionSpawnContext
? { sessionSpawnContext: payload.sessionSpawnContext }
: {}),
};
return handoff
? withAgentRuntimeExecutionLineageRedemption(identity, handoff.redemption)
: identity;
}
export type AgentRuntimeApprovalAuthorityValidator = (identity: AgentRuntimeIdentity) => boolean;
@@ -0,0 +1,8 @@
export type AgentRuntimeSessionSpawnContext = {
completionOwnerSessionKey?: string;
inheritedToolPolicy: {
version: 1;
allow: string[];
deny: string[];
};
};
@@ -0,0 +1,21 @@
import type { AgentCommandExecutionIdentitySpawnFacts } from "../../agents/agent-command-execution-identity-spawn.js";
const dispatchExecutionIdentities = new WeakMap<object, AgentCommandExecutionIdentitySpawnFacts>();
export function withAgentRunDispatchExecutionIdentity<T extends object>(
params: T,
facts: AgentCommandExecutionIdentitySpawnFacts | undefined,
): T {
if (!facts) {
return params;
}
const carried = { ...params };
dispatchExecutionIdentities.set(carried, facts);
return carried;
}
export function readAgentRunDispatchExecutionIdentity(
params: object,
): AgentCommandExecutionIdentitySpawnFacts | undefined {
return dispatchExecutionIdentities.get(params);
}
+8 -2
View File
@@ -1,4 +1,5 @@
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { withAgentCommandExecutionIdentitySpawnFacts } from "../../agents/agent-command-execution-identity-spawn.js";
import {
buildAgentRunTerminalOutcome,
classifyAgentRunTerminalOutcome,
@@ -29,6 +30,7 @@ import {
import type { GatewayCronCreatorAuthorityAdmission } from "../server-methods/cron-creator-authority-admission.js";
import { formatForLog } from "../ws-log.js";
import { setGatewayDedupeEntries } from "./agent-dedupe.js";
import { readAgentRunDispatchExecutionIdentity } from "./agent-run-dispatch-execution-identity.js";
import type { AgentTurnContext, AgentTurnIo } from "./types.js";
function resolveResolvedAgentTimeoutStopReason(
@@ -178,11 +180,15 @@ export function dispatchAgentRunFromGateway(params: {
params.cronCreatorAuthority.callerOrigin,
)
: undefined;
const ingressOptsWithSpawnFacts = withAgentCommandExecutionIdentitySpawnFacts(
params.ingressOpts,
readAgentRunDispatchExecutionIdentity(params),
);
const runAgent = () =>
agentCommandFromGatewayIngress(
cronCreatorAuthorityCapability
? { ...params.ingressOpts, cronCreatorAuthorityCapability }
: params.ingressOpts,
? { ...ingressOptsWithSpawnFacts, cronCreatorAuthorityCapability }
: ingressOptsWithSpawnFacts,
defaultRuntime,
params.context.deps,
{
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { createExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import { executionIdentitySpawnAdmission } from "../../audit/execution-identity-spawn-admission.js";
import { withAgentRuntimeExecutionLineage } from "../agent-runtime-execution-lineage.js";
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
import { resolveExecutionIdentitySpawnFacts } from "./agent-run-execution-lineage.js";
function parentIdentity(params: {
runtime: "acp" | "subagent";
withToken: boolean;
depth?: number;
}): AgentRuntimeIdentity {
return {
kind: "agentRuntime",
agentId: "parent-agent",
sessionKey: "agent:parent:main",
operationalRunInstance: { instanceId: "parent-instance", runId: "parent-run" },
delegatedAuthority: {} as AgentRuntimeIdentity["delegatedAuthority"],
...(params.withToken
? {
executionIdentity: createExecutionIdentityAdmissionToken("parent-run", {
contextId: "parent-context",
executionId: "parent-execution",
}),
}
: {}),
sessionSpawnContext: withAgentRuntimeExecutionLineage(
{ inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] } },
{
relation: "sessions_spawn",
requesterRef: "agent:parent:main",
controllerRef: "agent:controller:main",
depth: params.depth ?? 1,
applicableGrantRefs: ["tool:sessions_spawn"],
localPolicyRefs: ["local-policy"],
runtimeAssuranceRefs: [`spawn-runtime:${params.runtime}`],
targetPolicyRefs: ["target-policy"],
externalNativeActions: params.runtime === "acp" ? "unsupported" : "observable",
},
),
};
}
describe("child execution identity lineage", () => {
it("consumes the exact parent correlation and all narrowing-input categories", () => {
const facts = resolveExecutionIdentitySpawnFacts(
parentIdentity({ runtime: "subagent", withToken: true, depth: 3 }),
);
expect(facts).toMatchObject({
ingress: { kind: "subagent", boundary: "sessions_spawn.subagent" },
invoker: { state: "present", kind: "agent", rawPrincipalRef: "parent-agent" },
applicableGrants: [{ rawGrantRef: "tool:sessions_spawn", state: "present" }],
});
expect(
executionIdentitySpawnAdmission({
operation: "parse",
value: facts?.spawnAdmission ?? "",
}),
).toEqual([
{
parentContextId: "parent-context",
parentExecutionId: "parent-execution",
parentRunId: "parent-run",
parentAgentId: "parent-agent",
relation: "sessions_spawn",
rawRequesterRef: "agent:parent:main",
rawControllerRef: "agent:controller:main",
depth: 3,
localPolicyRefs: ["local-policy"],
targetPolicyRefs: ["target-policy"],
},
[],
]);
expect(facts?.assurance).toEqual([
{
kind: "spawn-lineage",
rawEvidenceRef: "agent:parent:main",
strength: "boundary-verified",
},
{
kind: "runtime-binding",
rawEvidenceRef: "spawn-runtime:subagent",
strength: "boundary-verified",
},
]);
});
it("reports missing parent evidence and unsupported ACP-native callbacks without inference", () => {
const facts = resolveExecutionIdentitySpawnFacts(
parentIdentity({ runtime: "acp", withToken: false }),
);
expect(facts?.ingress.kind).toBe("acp");
const [lineage, missingEvidence] = executionIdentitySpawnAdmission({
operation: "parse",
value: facts?.spawnAdmission ?? "",
});
expect(lineage).not.toHaveProperty("parentContextId");
expect(missingEvidence).toEqual([
"lineage.parent-context",
"lineage.parent-execution",
"lineage.parent-run",
"acp.native-action-callback",
]);
expect(JSON.stringify(facts)).not.toMatch(/task|prompt|externalSession/i);
});
});
@@ -0,0 +1,70 @@
import type { ExecutionIdentityAdmissionFacts } from "../../audit/execution-identity-admission.js";
import { executionIdentitySpawnAdmission } from "../../audit/execution-identity-spawn-admission.js";
import {
consumeAgentRuntimeExecutionLineage,
readAgentRuntimeExecutionLineage,
} from "../agent-runtime-execution-lineage.js";
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
type ExecutionIdentitySpawnFacts = Pick<
ExecutionIdentityAdmissionFacts,
"applicableGrants" | "assurance" | "ingress" | "invoker"
> & {
spawnAdmission: string;
};
/** Consume authenticated spawn provenance once, at the child admission owner. */
export function resolveExecutionIdentitySpawnFacts(
identity: AgentRuntimeIdentity | undefined,
): ExecutionIdentitySpawnFacts | undefined {
const lineage = readAgentRuntimeExecutionLineage(identity?.sessionSpawnContext);
if (!identity || !lineage || !consumeAgentRuntimeExecutionLineage(identity)) {
return undefined;
}
const parent = identity.executionIdentity;
return {
ingress: {
kind: lineage.externalNativeActions === "unsupported" ? "acp" : "subagent",
boundary: `sessions_spawn.${lineage.externalNativeActions === "unsupported" ? "acp" : "subagent"}`,
state: "present",
},
invoker: { state: "present", kind: "agent", rawPrincipalRef: identity.agentId },
applicableGrants: lineage.applicableGrantRefs.map((rawGrantRef) => ({
rawGrantRef,
state: "present",
})),
assurance: [
{
kind: "spawn-lineage",
rawEvidenceRef: lineage.requesterRef,
strength: "boundary-verified",
},
...lineage.runtimeAssuranceRefs.map((rawEvidenceRef) => ({
kind: "runtime-binding" as const,
rawEvidenceRef,
strength: "boundary-verified" as const,
})),
],
spawnAdmission: executionIdentitySpawnAdmission({
operation: "serialize",
value: {
...(parent?.contextId ? { parentContextId: parent.contextId } : {}),
...(parent?.executionId ? { parentExecutionId: parent.executionId } : {}),
...(parent?.runId ? { parentRunId: parent.runId } : {}),
parentAgentId: identity.agentId,
relation: lineage.relation,
rawRequesterRef: lineage.requesterRef,
rawControllerRef: lineage.controllerRef,
depth: lineage.depth,
localPolicyRefs: lineage.localPolicyRefs,
targetPolicyRefs: lineage.targetPolicyRefs,
},
extra: [
...(!parent?.contextId ? ["lineage.parent-context"] : []),
...(!parent?.executionId ? ["lineage.parent-execution"] : []),
...(!parent?.runId ? ["lineage.parent-run"] : []),
...(lineage.externalNativeActions === "unsupported" ? ["acp.native-action-callback"] : []),
],
}),
};
}
@@ -44,10 +44,12 @@ import {
resolveAgentRestartRecoveryExecutionIdentityAdmission,
} from "./agent-restart-recovery-context.js";
import type { PreparedAgentRunDispatch } from "./agent-run-admission-phase.js";
import { withAgentRunDispatchExecutionIdentity } from "./agent-run-dispatch-execution-identity.js";
import {
resolveAbortedAgentStopReason,
dispatchAgentRunFromGateway,
} from "./agent-run-dispatch.js";
import { resolveExecutionIdentitySpawnFacts } from "./agent-run-execution-lineage.js";
import {
finalizePreparedAgentRunUserTurn,
releasePreparedAgentRunUserTurn,
@@ -207,6 +209,12 @@ export function startAgentRunExecution(params: {
runId: params.runId,
sessionEntry: params.sessionEntry,
});
const agentRuntimeIdentity = params.client?.internal?.agentRuntimeIdentity;
const executionIdentitySpawnFacts =
agentRuntimeIdentity &&
params.context.validateAgentRuntimeApprovalAuthority?.(agentRuntimeIdentity) === true
? resolveExecutionIdentitySpawnFacts(agentRuntimeIdentity)
: undefined;
const restartRecoveryChannelContext = resolveAgentRestartRecoveryChannelContext({
canUseInternalRuntimeHandoff: params.canUseInternalRuntimeHandoff,
expectedExistingSessionId: params.request.expectedExistingSessionId,
@@ -239,152 +247,161 @@ export function startAgentRunExecution(params: {
attachAgentCommandAdmissionFacts(runContext, localUserIngress.facts);
}
finalizePreparedAgentRunUserTurn(prepared.userTurn);
dispatchAgentRunFromGateway({
cronCreatorAuthority: prepared.cronCreatorAuthority,
ingressOpts: {
message,
images: params.images,
imageOrder: params.imageOrder,
media: params.media,
agentId: ingressAgentId,
provider: prepared.effectiveProviderOverride,
model: prepared.effectiveModelOverride,
to: params.delivery.resolvedTo,
sessionId: params.resolvedSessionId,
sessionKey: params.resolvedSessionKey,
thinking: prepared.effectiveThinking,
deliver: params.delivery.deliver,
deliveryTargetMode: params.delivery.deliveryTargetMode,
channel: params.delivery.resolvedChannel,
accountId: params.delivery.resolvedAccountId,
threadId: prepared.resolvedThreadId,
runContext,
...(prepared.userTurn.bashElevated
? { bashElevated: prepared.userTurn.bashElevated }
: {}),
...(execApprovalContinuationPromptRange ? { execApprovalContinuationPromptRange } : {}),
...(execApprovalContinuationTranscriptPromptRange
? { execApprovalContinuationTranscriptPromptRange }
: {}),
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
timeout: params.request.timeout?.toString(),
bestEffortDeliver: params.bestEffortDeliver,
messageChannel: params.delivery.originMessageChannel,
runId: params.runId,
lane: params.request.lane,
modelRun: params.request.modelRun === true,
promptMode: params.request.promptMode,
extraSystemPrompt: params.request.extraSystemPrompt,
bootstrapContextMode: params.request.bootstrapContextMode,
bootstrapContextRunKind: params.effectiveBootstrapContextRunKind,
toolsAllow: params.restoredCronContinuation?.toolsAllow,
runtimePluginToolGrant,
trustedInternalHandoff: prepared.trustedInternalHandoff,
toolsAllowIsDefault: params.restoredCronContinuation?.toolsAllowIsDefault,
scheduledToolPolicy: params.restoredCronContinuation
? resolveScheduledToolPolicyContext({
toolsAllow: params.restoredCronContinuation.toolsAllow,
scheduledToolPolicy: params.restoredCronContinuation.scheduledToolPolicy,
callerOrigin: params.restoredCronContinuation.scheduledToolCallerOrigin,
})
: undefined,
requireExplicitMessageTarget:
params.restoredCronContinuation?.cliSessionBindingFacts?.requireExplicitMessageTarget,
cliSessionBindingFacts: params.restoredCronContinuation?.cliSessionBindingFacts,
acpTurnSource: params.request.acpTurnSource,
internalEvents: params.request.internalEvents,
inputProvenance: params.inputProvenance,
senderIsOwner,
sessionEffects: params.sessionEffects,
skipInitialSessionTouch: params.skipAgentInitialSessionTouch,
preserveUserFacingSessionModelState:
params.preserveUserFacingSessionModelState && !params.restoredCronContinuation,
sourceReplyDeliveryMode: params.restoredCronContinuation
? params.restoredCronContinuation.cliSessionBindingFacts?.sourceReplyDeliveryMode
: params.request.sourceReplyDeliveryMode,
disableMessageTool: params.request.disableMessageTool,
swarmCollector: params.request.swarmCollector,
swarmOutputSchema: params.request.swarmOutputSchema,
forceRestartSafeTools: params.request.forceRestartSafeTools,
forceCodeModeTools: params.request.forceCodeModeTools,
...(executionIdentityAdmission ? { executionIdentityAdmission } : {}),
operationalRunInstance: prepared.operationalRunInstance,
onAdmittedRunContext: (admittedRunContext) => {
const authority = getAdmittedRunDelegatedAuthority(admittedRunContext);
if (!authority) {
throw new Error("agent run delegated authority was not admitted");
}
// Sessionless runs intentionally have no abort-map owner. Their
// prepared admission retains authority until agentCommand closes it.
if (prepared.activeRunAbort.registered) {
prepared.activeRunAbort.bindAgentRunDelegatedAuthority(authority);
}
},
internalDeliveryMediaUrls: params.client?.internal?.internalDeliveryMediaUrls,
internalDeliverySuppressText: params.client?.internal?.internalDeliverySuppressText,
suppressPromptPersistence: prepared.userTurn.suppressPromptPersistence,
userTurnTranscriptRecorder,
cleanupBundleMcpOnRunEnd: params.request.cleanupBundleMcpOnRunEnd,
abortSignal: prepared.activeRunAbort.controller.signal,
lifecycleGeneration: params.lifecycleGeneration,
onExecutionStarted: () => prepared.activeRunAbort.markExecutionStarted(),
onActiveModelSelected: createAgentRunModelSelectionHandler({
context: params.context,
dispatchAgentRunFromGateway(
withAgentRunDispatchExecutionIdentity(
{
cronCreatorAuthority: prepared.cronCreatorAuthority,
ingressOpts: {
message,
images: params.images,
imageOrder: params.imageOrder,
media: params.media,
agentId: ingressAgentId,
provider: prepared.effectiveProviderOverride,
model: prepared.effectiveModelOverride,
to: params.delivery.resolvedTo,
sessionId: params.resolvedSessionId,
sessionKey: params.resolvedSessionKey,
thinking: prepared.effectiveThinking,
deliver: params.delivery.deliver,
deliveryTargetMode: params.delivery.deliveryTargetMode,
channel: params.delivery.resolvedChannel,
accountId: params.delivery.resolvedAccountId,
threadId: prepared.resolvedThreadId,
runContext,
...(prepared.userTurn.bashElevated
? { bashElevated: prepared.userTurn.bashElevated }
: {}),
...(execApprovalContinuationPromptRange
? { execApprovalContinuationPromptRange }
: {}),
...(execApprovalContinuationTranscriptPromptRange
? { execApprovalContinuationTranscriptPromptRange }
: {}),
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
timeout: params.request.timeout?.toString(),
bestEffortDeliver: params.bestEffortDeliver,
messageChannel: params.delivery.originMessageChannel,
runId: params.runId,
lane: params.request.lane,
modelRun: params.request.modelRun === true,
promptMode: params.request.promptMode,
extraSystemPrompt: params.request.extraSystemPrompt,
bootstrapContextMode: params.request.bootstrapContextMode,
bootstrapContextRunKind: params.effectiveBootstrapContextRunKind,
toolsAllow: params.restoredCronContinuation?.toolsAllow,
runtimePluginToolGrant,
trustedInternalHandoff: prepared.trustedInternalHandoff,
toolsAllowIsDefault: params.restoredCronContinuation?.toolsAllowIsDefault,
scheduledToolPolicy: params.restoredCronContinuation
? resolveScheduledToolPolicyContext({
toolsAllow: params.restoredCronContinuation.toolsAllow,
scheduledToolPolicy: params.restoredCronContinuation.scheduledToolPolicy,
callerOrigin: params.restoredCronContinuation.scheduledToolCallerOrigin,
})
: undefined,
requireExplicitMessageTarget:
params.restoredCronContinuation?.cliSessionBindingFacts
?.requireExplicitMessageTarget,
cliSessionBindingFacts: params.restoredCronContinuation?.cliSessionBindingFacts,
acpTurnSource: params.request.acpTurnSource,
internalEvents: params.request.internalEvents,
inputProvenance: params.inputProvenance,
senderIsOwner,
sessionEffects: params.sessionEffects,
skipInitialSessionTouch: params.skipAgentInitialSessionTouch,
preserveUserFacingSessionModelState:
params.preserveUserFacingSessionModelState && !params.restoredCronContinuation,
sourceReplyDeliveryMode: params.restoredCronContinuation
? params.restoredCronContinuation.cliSessionBindingFacts?.sourceReplyDeliveryMode
: params.request.sourceReplyDeliveryMode,
disableMessageTool: params.request.disableMessageTool,
swarmCollector: params.request.swarmCollector,
swarmOutputSchema: params.request.swarmOutputSchema,
forceRestartSafeTools: params.request.forceRestartSafeTools,
forceCodeModeTools: params.request.forceCodeModeTools,
...(executionIdentityAdmission ? { executionIdentityAdmission } : {}),
operationalRunInstance: prepared.operationalRunInstance,
onAdmittedRunContext: (admittedRunContext) => {
const authority = getAdmittedRunDelegatedAuthority(admittedRunContext);
if (!authority) {
throw new Error("agent run delegated authority was not admitted");
}
// Sessionless runs intentionally have no abort-map owner. Their
// prepared admission retains authority until agentCommand closes it.
if (prepared.activeRunAbort.registered) {
prepared.activeRunAbort.bindAgentRunDelegatedAuthority(authority);
}
},
internalDeliveryMediaUrls: params.client?.internal?.internalDeliveryMediaUrls,
internalDeliverySuppressText: params.client?.internal?.internalDeliverySuppressText,
suppressPromptPersistence: prepared.userTurn.suppressPromptPersistence,
userTurnTranscriptRecorder,
cleanupBundleMcpOnRunEnd: params.request.cleanupBundleMcpOnRunEnd,
abortSignal: prepared.activeRunAbort.controller.signal,
lifecycleGeneration: params.lifecycleGeneration,
onExecutionStarted: () => prepared.activeRunAbort.markExecutionStarted(),
onActiveModelSelected: createAgentRunModelSelectionHandler({
context: params.context,
runId: params.runId,
cfg: params.cfg,
cfgForAgent: params.cfgForAgent,
restoredCronContinuationLifecycleRevision:
prepared.restoredCronContinuationLifecycleRevision,
resolvedSessionKey: params.resolvedSessionKey,
lifecycleStorePath: prepared.lifecycleStorePath,
activeSessionAgentId: params.activeSessionAgentId,
trustedInternalHandoff: prepared.trustedInternalHandoff,
}),
onSessionIdChanged: (sessionId) => {
if (prepared.activeRunAbort.entry) {
prepared.activeRunAbort.entry.sessionId = sessionId;
}
},
workspaceDir: resolveIngressWorkspaceOverrideForSessionRun({
spawnedBy: params.spawnedBy,
workspaceDir: params.sessionEntry?.spawnedWorkspaceDir,
cwd: params.sessionEntry?.spawnedCwd,
}),
cwd: resolveSessionRuntimeCwd({
requestedCwd: params.request.cwd,
sessionEntry: params.sessionEntry,
}),
allowGatewaySubagentBinding: true,
...(params.mainRestartRecoveryOwnerLease
? { mainRestartRecoveryOwnerLease: params.mainRestartRecoveryOwnerLease }
: {}),
...(params.isRestartRecoveryResumeRun ? { mainRestartRecoveryAdmitted: true } : {}),
...(params.request.internalExecutionIdentityRecoveryAttempt !== undefined
? {
mainRestartRecoveryAttempt:
params.request.internalExecutionIdentityRecoveryAttempt,
}
: {}),
allowModelOverride: prepared.effectiveAllowModelOverride,
},
runId: params.runId,
cfg: params.cfg,
cfgForAgent: params.cfgForAgent,
restoredCronContinuationLifecycleRevision:
prepared.restoredCronContinuationLifecycleRevision,
resolvedSessionKey: params.resolvedSessionKey,
lifecycleStorePath: prepared.lifecycleStorePath,
activeSessionAgentId: params.activeSessionAgentId,
trustedInternalHandoff: prepared.trustedInternalHandoff,
}),
onSessionIdChanged: (sessionId) => {
if (prepared.activeRunAbort.entry) {
prepared.activeRunAbort.entry.sessionId = sessionId;
}
dedupeKeys: params.agentDedupeKeys,
abortController: prepared.activeRunAbort.controller,
cleanupAbortController: cleanupAdmittedRun,
onSettled: params.restoredCronContinuation
? async ({ terminalOutcome, onRecovered }) =>
await params.releaseCronContinuationClaimWithRecovery(
{ terminalOutcome },
onRecovered,
)
: undefined,
io: params.io,
context: params.context,
taskTrackingMode: prepared.dispatchTaskTrackingMode,
restoreAdmittedRecovery: prepared.restoreAdmittedRestartRecoveryInterrupted,
},
workspaceDir: resolveIngressWorkspaceOverrideForSessionRun({
spawnedBy: params.spawnedBy,
workspaceDir: params.sessionEntry?.spawnedWorkspaceDir,
cwd: params.sessionEntry?.spawnedCwd,
}),
cwd: resolveSessionRuntimeCwd({
requestedCwd: params.request.cwd,
sessionEntry: params.sessionEntry,
}),
allowGatewaySubagentBinding: true,
...(params.mainRestartRecoveryOwnerLease
? { mainRestartRecoveryOwnerLease: params.mainRestartRecoveryOwnerLease }
: {}),
...(params.isRestartRecoveryResumeRun ? { mainRestartRecoveryAdmitted: true } : {}),
...(params.request.internalExecutionIdentityRecoveryAttempt !== undefined
? {
mainRestartRecoveryAttempt: params.request.internalExecutionIdentityRecoveryAttempt,
}
: {}),
allowModelOverride: prepared.effectiveAllowModelOverride,
},
runId: params.runId,
dedupeKeys: params.agentDedupeKeys,
abortController: prepared.activeRunAbort.controller,
cleanupAbortController: cleanupAdmittedRun,
onSettled: params.restoredCronContinuation
? async ({ terminalOutcome, onRecovered }) =>
await params.releaseCronContinuationClaimWithRecovery(
{ terminalOutcome },
onRecovered,
)
: undefined,
io: params.io,
context: params.context,
taskTrackingMode: prepared.dispatchTaskTrackingMode,
restoreAdmittedRecovery: prepared.restoreAdmittedRestartRecoveryInterrupted,
});
executionIdentitySpawnFacts,
),
);
dispatched = true;
} catch (err) {
const renderedErr = formatErrorMessageWithCode(err);
+1
View File
@@ -41,4 +41,5 @@ export type AgentTurnContext = Pick<
| "loadGatewayModelCatalog"
| "loadGatewayModelCatalogSnapshot"
| "logGateway"
| "validateAgentRuntimeApprovalAuthority"
>;
@@ -0,0 +1,22 @@
import type { AgentRuntimeIdentity } from "./agent-runtime-identity-token.js";
const inProcessAgentRuntimeIdentities = new WeakMap<object, AgentRuntimeIdentity>();
/** Carry authenticated runtime identity without widening plugin dispatch options. */
export function withInProcessAgentRuntimeIdentity<T extends object>(
options: T,
identity: AgentRuntimeIdentity | undefined,
): T {
if (!identity) {
return options;
}
const carried = { ...options };
inProcessAgentRuntimeIdentities.set(carried, identity);
return carried;
}
export function readInProcessAgentRuntimeIdentity(
options: object | undefined,
): AgentRuntimeIdentity | undefined {
return options ? inProcessAgentRuntimeIdentities.get(options) : undefined;
}
@@ -4,6 +4,7 @@ import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-
import type { PluginSubagentRequesterContext } from "../plugins/runtime/subagent-requester-context.js";
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { readInProcessAgentRuntimeIdentity } from "./in-process-agent-runtime-identity.js";
import {
dispatchGatewayRequestInProcessRaw,
type GatewayMethodDispatchResponse,
@@ -86,7 +87,7 @@ function resolveInProcessGatewayDispatch(
const delegatedToolPolicyHandoffId = options?.delegatedToolPolicyHandoff
? registerSubagentCompletionToolHandoff(options.delegatedToolPolicyHandoff)
: undefined;
const syntheticClient = createSyntheticPluginRuntimeClient({
const baseSyntheticClient = createSyntheticPluginRuntimeClient({
allowModelOverride: options?.allowSyntheticModelOverride === true,
agentToolCaller: options?.agentToolCaller,
agentRunTracking: options?.agentRunTracking,
@@ -104,6 +105,13 @@ function resolveInProcessGatewayDispatch(
...(options?.sessionCreation ? { sessionCreation: options.sessionCreation } : {}),
scopes: options?.syntheticScopes,
});
const agentRuntimeIdentity = readInProcessAgentRuntimeIdentity(options);
const syntheticClient = agentRuntimeIdentity
? {
...baseSyntheticClient,
internal: { ...baseSyntheticClient.internal, agentRuntimeIdentity },
}
: baseSyntheticClient;
const scopedClient = mergePluginRuntimeClientInternal(
scope?.client,
pluginRuntimeOwnerId ||
@@ -41,6 +41,7 @@ import {
canTransitionWorkerSessionPlacement,
type WorkerSessionPlacementState,
} from "./placement-state.js";
import { attachWorkerTurnExecutionIdentityStore } from "./placement-turn-claim-events.js";
import {
createPlacementTurnClaimOps,
registerWorkerTurnClaimClosedHandler,
@@ -131,7 +132,7 @@ export function createWorkerSessionPlacementStore(
}
};
return {
const store = {
...createPlacementTurnClaimOps(runtime),
...createPlacementPendingFailureOps(runtime),
...createPlacementMoveOps(runtime),
@@ -539,6 +540,8 @@ export function createWorkerSessionPlacementStore(
).rows.map((row) => withWorkspaceResultConflict(fromRow(row))!);
},
};
attachWorkerTurnExecutionIdentityStore(store, path);
return store;
}
export type WorkerSessionPlacementStore = ReturnType<typeof createWorkerSessionPlacementStore>;
@@ -2,6 +2,12 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createOperationalRunInstanceRef } from "../../agents/admitted-run-context.js";
import { createExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import {
claimAgentRunDelegatedAuthority,
releaseAgentRunDelegatedAuthority,
} from "../../infra/agent-run-registry.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
@@ -12,6 +18,10 @@ import {
createWorkerSessionPlacementStore,
type WorkerSessionPlacementStore,
} from "./placement-store.js";
import {
bindWorkerTurnExecutionIdentity,
getWorkerTurnExecutionIdentityCapability,
} from "./placement-turn-claim-events.js";
const SESSION: WorkerSessionPlacementIdentity = {
sessionId: "session-placement-claim-close",
@@ -113,3 +123,77 @@ it("emits exact worker claim closure after release and owner fencing", () => {
expect(closed).toHaveBeenCalledTimes(2);
unregister();
});
it("rejects retained worker lineage capabilities after either owner closes", async () => {
const active = advanceToActive();
const owner = {
kind: "worker" as const,
environmentId: active.environmentId,
ownerEpoch: active.activeOwnerEpoch,
};
const bindingFor = (runId: string) => ({
sessionId: SESSION.sessionId,
environmentId: owner.environmentId,
ownerEpoch: owner.ownerEpoch,
runId,
});
const placementClosedClaim = store.claimTurn({
...SESSION,
owner,
claimId: "claim-placement-close",
runId: "run-placement-close",
});
const placementClosedRun = createOperationalRunInstanceRef(placementClosedClaim.runId);
const placementClosedAuthority = claimAgentRunDelegatedAuthority(placementClosedRun);
bindWorkerTurnExecutionIdentity(
store,
placementClosedClaim,
createExecutionIdentityAdmissionToken(placementClosedClaim.runId),
placementClosedRun,
{ agentId: SESSION.agentId, sessionKey: SESSION.sessionKey },
);
const placementCapability = getWorkerTurnExecutionIdentityCapability(
store,
bindingFor(placementClosedClaim.runId),
);
if (!placementCapability) {
throw new Error("expected placement-bound lineage capability");
}
store.releaseTurn(placementClosedClaim);
await expect(placementCapability.run(async () => "stale")).rejects.toThrow(
"worker turn authority changed",
);
releaseAgentRunDelegatedAuthority(placementClosedAuthority);
const runClosedClaim = store.claimTurn({
...SESSION,
owner,
claimId: "claim-run-close",
runId: "run-run-close",
});
const runClosedOperational = createOperationalRunInstanceRef(runClosedClaim.runId);
const runClosedAuthority = claimAgentRunDelegatedAuthority(runClosedOperational);
bindWorkerTurnExecutionIdentity(
store,
runClosedClaim,
createExecutionIdentityAdmissionToken(runClosedClaim.runId),
runClosedOperational,
{ agentId: SESSION.agentId, sessionKey: SESSION.sessionKey },
);
const runCapability = getWorkerTurnExecutionIdentityCapability(
store,
bindingFor(runClosedClaim.runId),
);
if (!runCapability) {
throw new Error("expected run-bound lineage capability");
}
await expect(
runCapability.run(async () => {
await Promise.resolve();
releaseAgentRunDelegatedAuthority(runClosedAuthority);
return "closed-after-await";
}),
).rejects.toThrow("worker turn authority changed");
store.releaseTurn(runClosedClaim);
});
@@ -1,3 +1,10 @@
import type { OperationalRunInstanceRef } from "../../agents/admitted-run-context.js";
import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import {
getActiveAgentRunDelegatedAuthority,
validateAgentRunDelegatedAuthority,
type AgentRunDelegatedAuthority,
} from "../../infra/agent-run-registry.js";
import { resolveGlobalMap } from "../../shared/global-singleton.js";
import type { WorkerSessionTurnClaim } from "./placement-record.js";
@@ -25,6 +32,111 @@ const workerTurnClaimClosedHandlers = resolveGlobalMap<
handlersByPath.clear();
});
export type WorkerTurnExecutionIdentity = Readonly<{
agentId: string;
delegatedAuthority: AgentRunDelegatedAuthority;
executionIdentityToken: ExecutionIdentityAdmissionToken;
operationalRunInstance: OperationalRunInstanceRef;
sessionKey: string;
turnClaim: WorkerSessionTurnClaim;
}>;
export type WorkerTurnExecutionIdentityCapability = Readonly<{
run<T>(callback: (identity: WorkerTurnExecutionIdentity) => Promise<T> | T): Promise<T>;
}>;
type BoundWorkerTurnExecutionIdentity = {
capability: WorkerTurnExecutionIdentityCapability;
claim: WorkerSessionTurnClaim;
claimKey: string;
};
const workerTurnExecutionIdentities = resolveGlobalMap<
string,
Map<string, BoundWorkerTurnExecutionIdentity>
>(Symbol.for("openclaw.workerTurnExecutionIdentities"), (identities) => identities.clear());
const WORKER_TURN_EXECUTION_IDENTITY_PATH = Symbol("workerTurnExecutionIdentityPath");
type WorkerTurnExecutionIdentityStore = {
validateTurnClaim(claim: WorkerSessionTurnClaim): boolean;
[WORKER_TURN_EXECUTION_IDENTITY_PATH]?: string;
};
function claimKey(claim: WorkerSessionTurnClaim): string {
return JSON.stringify([
claim.claimId,
claim.runId,
claim.placementGeneration,
claim.owner.kind,
claim.owner.kind === "worker" ? claim.owner.environmentId : null,
claim.owner.kind === "worker" ? claim.owner.ownerEpoch : null,
]);
}
/** Bind diagnostic provenance to the exact live run and worker owners. */
export function bindWorkerTurnExecutionIdentity(
store: WorkerTurnExecutionIdentityStore,
claim: WorkerSessionTurnClaim,
token: ExecutionIdentityAdmissionToken,
operationalRunInstance: OperationalRunInstanceRef,
source: { agentId: string; sessionKey: string },
): void {
const path = store[WORKER_TURN_EXECUTION_IDENTITY_PATH];
const delegatedAuthority = getActiveAgentRunDelegatedAuthority(operationalRunInstance);
if (!path || !store.validateTurnClaim(claim) || !delegatedAuthority) {
throw new Error(`Session ${claim.sessionId} worker turn authority changed`);
}
const identity = Object.freeze({
agentId: source.agentId,
delegatedAuthority,
executionIdentityToken: token,
operationalRunInstance,
sessionKey: source.sessionKey,
turnClaim: claim,
});
const assertActive = () => {
if (
!store.validateTurnClaim(claim) ||
!validateAgentRunDelegatedAuthority(delegatedAuthority)
) {
throw new Error(`Session ${claim.sessionId} worker turn authority changed`);
}
};
const capability = Object.freeze({
async run<T>(callback: (current: WorkerTurnExecutionIdentity) => Promise<T> | T): Promise<T> {
assertActive();
const result = await callback(identity);
// Awaited policy, RPC, approval, and recovery work may close either owner.
assertActive();
return result;
},
});
const identities = workerTurnExecutionIdentities.get(path) ?? new Map();
identities.set(claim.sessionId, { capability, claim, claimKey: claimKey(claim) });
workerTurnExecutionIdentities.set(path, identities);
}
export function getWorkerTurnExecutionIdentityCapability(
store: WorkerTurnExecutionIdentityStore,
binding: { sessionId: string; environmentId: string; ownerEpoch: number; runId: string },
): WorkerTurnExecutionIdentityCapability | undefined {
const path = store[WORKER_TURN_EXECUTION_IDENTITY_PATH];
const bound = path ? workerTurnExecutionIdentities.get(path)?.get(binding.sessionId) : undefined;
const owner = bound?.claim.owner;
return bound &&
owner?.kind === "worker" &&
bound.claim.runId === binding.runId &&
owner.environmentId === binding.environmentId &&
owner.ownerEpoch === binding.ownerEpoch &&
store.validateTurnClaim(bound.claim)
? bound.capability
: undefined;
}
export function attachWorkerTurnExecutionIdentityStore(store: object, path: string): void {
Object.defineProperty(store, WORKER_TURN_EXECUTION_IDENTITY_PATH, { value: path });
}
export function waitersFor(path: string, sessionId: string): Set<TurnClaimReleaseWaiter> {
let bySession = turnClaimReleaseWaiters.get(path);
if (!bySession) {
@@ -90,6 +202,13 @@ export function registerWorkerTurnClaimClosedHandler(
export function signalWorkerTurnClaimClosed(path: string, claim: WorkerSessionTurnClaim): void {
signalTurnClaimRelease(path, claim.sessionId);
const identities = workerTurnExecutionIdentities.get(path);
if (identities?.get(claim.sessionId)?.claimKey === claimKey(claim)) {
identities.delete(claim.sessionId);
if (identities.size === 0) {
workerTurnExecutionIdentities.delete(path);
}
}
for (const handler of workerTurnClaimClosedHandlers.get(path) ?? []) {
try {
handler(claim);
@@ -2,24 +2,36 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createOperationalRunInstanceRef } from "../../agents/admitted-run-context.js";
import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
claimAgentRunDelegatedAuthority,
releaseAgentRunDelegatedAuthority,
type AgentRunDelegatedAuthority,
} from "../../infra/agent-run-registry.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
type OpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import { readAgentRuntimeExecutionLineage } from "../agent-runtime-execution-lineage.js";
import type { WorkerConnectionIdentity } from "./connection-identity.js";
import {
createWorkerSessionPlacementStore,
type WorkerSessionPlacementStore,
} from "./placement-store.js";
import { bindWorkerTurnExecutionIdentity } from "./placement-turn-claim-events.js";
import { createWorkerSessionToolExecutor } from "./worker-session-tool-executor.js";
const sessionEntries = vi.hoisted(() => new Map<string, SessionEntry>());
const delivered = vi.hoisted(() => vi.fn());
const gatewayRequest = vi.hoisted(() => vi.fn());
const gatewayCreate = vi.hoisted(() => vi.fn());
const gatewayRuntimeIdentity = vi.hoisted(() => vi.fn());
const dispatchChild = vi.hoisted(() => vi.fn());
const spawnCallerIdentity = vi.hoisted(() => vi.fn());
const spawnArgs = vi.hoisted(() => vi.fn());
const scopedSessionAccess = vi.hoisted(() =>
vi.fn(async (params: { run: () => Promise<unknown> }) => await params.run()),
);
@@ -47,23 +59,29 @@ vi.mock("../../agents/tools/sessions-send-tool.js", () => ({
}),
}));
vi.mock("../../agents/tools/sessions-spawn-tool.js", () => ({
createSessionsSpawnTool: (options: {
agentSessionKey: string;
callGateway: (method: string, params: Record<string, unknown>) => Promise<unknown>;
}) => ({
execute: async (_toolCallId: string, args: { task: string }) => {
const details = await options.callGateway("sessions.create", {
parentSessionKey: options.agentSessionKey,
task: args.task,
});
return {
content: [{ type: "text", text: "spawned" }],
details,
};
},
}),
}));
vi.mock("../../agents/tools/sessions-spawn-tool.js", async () => {
const { getGatewayToolCallerIdentity } =
await import("../../agents/tools/gateway-caller-context.js");
return {
createSessionsSpawnTool: (options: {
agentSessionKey: string;
callGateway: (method: string, params: Record<string, unknown>) => Promise<unknown>;
}) => ({
execute: async (_toolCallId: string, args: { task: string }) => {
spawnCallerIdentity(getGatewayToolCallerIdentity());
spawnArgs(args);
const details = await options.callGateway("sessions.create", {
parentSessionKey: options.agentSessionKey,
task: args.task,
});
return {
content: [{ type: "text", text: "spawned" }],
details,
};
},
}),
};
});
vi.mock("../../agents/tools/scoped-session-access.js", () => ({
runWithScopedSessionAccess: (params: unknown) => scopedSessionAccess(params as never),
@@ -76,6 +94,10 @@ vi.mock("../../agents/tools/in-process-gateway.js", () => ({
params: Record<string, unknown>,
creation: unknown,
) => gatewayCreate({ creation, method, params }),
withAgentToolGatewayRuntimeIdentity: (request: unknown, identity: unknown) => {
gatewayRuntimeIdentity(request, identity);
return request;
},
}));
const SOURCE = {
@@ -108,6 +130,13 @@ const GRANDCHILD = {
environmentId: "spawned-grandchild-environment",
ownerEpoch: 6,
};
const PARENT_EXECUTION_IDENTITY_TOKEN = {
tokenVersion: 1,
contextId: "parent-context",
executionId: "parent-execution",
runId: "source-run",
createdAt: 1,
} satisfies ExecutionIdentityAdmissionToken;
describe("worker session tool topology", () => {
let root: string;
@@ -116,6 +145,7 @@ describe("worker session tool topology", () => {
let identity: WorkerConnectionIdentity;
let execute: ReturnType<typeof createWorkerSessionToolExecutor>;
let sourceClaim: ReturnType<WorkerSessionPlacementStore["claimTurn"]>;
let delegatedAuthorities: AgentRunDelegatedAuthority[];
let childSessionKey: string | undefined;
let spawnOrder: string[];
@@ -138,6 +168,16 @@ describe("worker session tool topology", () => {
},
});
placements.authorizeWorkerTurnTools(sourceClaim, ["sessions_send", "sessions_spawn"]);
delegatedAuthorities = [];
const sourceOperationalRun = createOperationalRunInstanceRef(sourceClaim.runId);
delegatedAuthorities.push(claimAgentRunDelegatedAuthority(sourceOperationalRun));
bindWorkerTurnExecutionIdentity(
placements,
sourceClaim,
PARENT_EXECUTION_IDENTITY_TOKEN,
sourceOperationalRun,
{ agentId: SOURCE.agentId, sessionKey: SOURCE.sessionKey },
);
identity = {
environmentId: SOURCE.environmentId,
credentialHash: "credential-hash",
@@ -154,7 +194,10 @@ describe("worker session tool topology", () => {
delivered.mockReset();
gatewayRequest.mockReset();
gatewayCreate.mockReset();
gatewayRuntimeIdentity.mockReset();
dispatchChild.mockReset();
spawnCallerIdentity.mockReset();
spawnArgs.mockReset();
scopedSessionAccess.mockClear();
childSessionKey = undefined;
spawnOrder = [];
@@ -180,7 +223,7 @@ describe("worker session tool topology", () => {
});
gatewayRequest.mockImplementation(
async (request: { method: string; params: Record<string, unknown> }) => {
if (request.method === "chat.send") {
if (request.method === "agent") {
spawnOrder.push("send");
expect(placements.get(CHILD.sessionId)?.state).toBe("active");
return { runId: "spawned-child-run", status: "accepted" };
@@ -230,6 +273,9 @@ describe("worker session tool topology", () => {
});
afterEach(async () => {
for (const authority of delegatedAuthorities) {
releaseAgentRunDelegatedAuthority(authority);
}
closeOpenClawStateDatabaseForTest();
await fs.rm(root, { recursive: true, force: true });
});
@@ -314,6 +360,7 @@ describe("worker session tool topology", () => {
const first = await execute(request);
const replay = await execute(request);
expect(childSessionKey).toMatch(/^agent:main:dashboard:cloud-[a-f0-9]{32}$/u);
expect(spawnOrder).toEqual(["create", "dispatch", "send"]);
expect(gatewayCreate).toHaveBeenCalledOnce();
expect(gatewayCreate).toHaveBeenCalledWith(
@@ -340,7 +387,8 @@ describe("worker session tool topology", () => {
});
expect(gatewayRequest).toHaveBeenLastCalledWith(
expect.objectContaining({
method: "chat.send",
agentRunTracking: "native_subagent",
method: "agent",
params: expect.objectContaining({
idempotencyKey: expect.stringMatching(/^worker-session-spawn:/u),
message: "run in the nested cloud session",
@@ -348,6 +396,9 @@ describe("worker session tool topology", () => {
}),
}),
);
expect(spawnArgs).toHaveBeenCalledWith(
expect.objectContaining({ expectsCompletionMessage: false, visible: true, worktree: true }),
);
expect(placements.get(CHILD.sessionId)?.state).toBe("active");
expect(sessionEntries.get(childSessionKey!)).toMatchObject({
sessionId: CHILD.sessionId,
@@ -357,6 +408,53 @@ describe("worker session tool topology", () => {
expect(replay.resultJson).toBe(first.resultJson);
});
it("carries the exact admitted parent identity into a worker-hosted child spawn", async () => {
setEntry(SOURCE.sessionKey, SOURCE.sessionId);
await execute({
identity,
toolName: "sessions_spawn",
request: { toolCallId: "spawn-with-parent-identity", task: "start the child" },
});
expect(spawnCallerIdentity).toHaveBeenCalledWith(
expect.objectContaining({
agentId: SOURCE.agentId,
sessionKey: SOURCE.sessionKey,
executionIdentityToken: PARENT_EXECUTION_IDENTITY_TOKEN,
operationalRunInstance: expect.objectContaining({ runId: sourceClaim.runId }),
workerTurnClaim: sourceClaim,
}),
);
const runtimeIdentity = gatewayRuntimeIdentity.mock.calls[0]?.[1];
expect(runtimeIdentity).toMatchObject({
kind: "agentRuntime",
agentId: SOURCE.agentId,
sessionKey: SOURCE.sessionKey,
executionIdentity: PARENT_EXECUTION_IDENTITY_TOKEN,
operationalRunInstance: expect.objectContaining({ runId: sourceClaim.runId }),
delegatedAuthority: expect.objectContaining({ kind: "worker", turnClaim: sourceClaim }),
sessionSpawnContext: {
inheritedToolPolicy: {
version: 1,
allow: ["sessions_spawn", "sessions_send"],
deny: [],
},
},
});
expect(readAgentRuntimeExecutionLineage(runtimeIdentity?.sessionSpawnContext)).toMatchObject({
relation: "sessions_spawn",
requesterRef: SOURCE.sessionKey,
controllerRef: SOURCE.sessionKey,
depth: 1,
externalNativeActions: "observable",
});
expect(JSON.stringify(gatewayRuntimeIdentity.mock.calls[0]?.[0])).not.toContain(
PARENT_EXECUTION_IDENTITY_TOKEN.executionId,
);
expect(JSON.stringify(runtimeIdentity?.sessionSpawnContext)).not.toContain(SOURCE.sessionKey);
});
it("coalesces concurrent spawn retries into one cloud child", async () => {
setEntry(SOURCE.sessionKey, SOURCE.sessionId);
const create = gatewayCreate.getMockImplementation();
@@ -430,7 +528,7 @@ describe("worker session tool topology", () => {
});
gatewayRequest.mockImplementation(
async (request: { method: string; params: Record<string, unknown> }) => {
if (request.method === "chat.send") {
if (request.method === "agent") {
spawnOrder.push("send");
return { runId: "spawned-child-run", status: "accepted" };
}
@@ -458,7 +556,7 @@ describe("worker session tool topology", () => {
const sendKeys: string[] = [];
gatewayRequest.mockImplementation(
async (request: { method: string; params: Record<string, unknown> }) => {
if (request.method === "chat.send") {
if (request.method === "agent") {
spawnOrder.push("send");
sendKeys.push(String(request.params.idempotencyKey));
if (sendKeys.length === 1) {
@@ -506,6 +604,22 @@ describe("worker session tool topology", () => {
},
});
placements.authorizeWorkerTurnTools(childClaim, ["sessions_spawn", "sessions_send"]);
const childExecutionIdentityToken = {
...PARENT_EXECUTION_IDENTITY_TOKEN,
contextId: "child-context",
executionId: "child-execution",
runId: childClaim.runId,
createdAt: 2,
} satisfies ExecutionIdentityAdmissionToken;
const childOperationalRun = createOperationalRunInstanceRef(childClaim.runId);
delegatedAuthorities.push(claimAgentRunDelegatedAuthority(childOperationalRun));
bindWorkerTurnExecutionIdentity(
placements,
childClaim,
childExecutionIdentityToken,
childOperationalRun,
{ agentId: CHILD.agentId, sessionKey: spawnedChildKey },
);
const childIdentity: WorkerConnectionIdentity = {
...identity,
environmentId: CHILD.environmentId,
@@ -535,7 +649,7 @@ describe("worker session tool topology", () => {
});
gatewayRequest.mockImplementation(
async (request: { method: string; params: Record<string, unknown> }) => {
if (request.method === "chat.send") {
if (request.method === "agent") {
return { runId: "spawned-grandchild-run", status: "accepted" };
}
throw new Error(`Unexpected gateway request: ${request.method}`);
@@ -547,6 +661,10 @@ describe("worker session tool topology", () => {
toolName: "sessions_spawn",
request: { toolCallId: "spawn-grandchild", task: "start the grandchild" },
});
expect(spawnCallerIdentity.mock.calls.map((call) => call[0]?.executionIdentityToken)).toEqual([
PARENT_EXECUTION_IDENTITY_TOKEN,
childExecutionIdentityToken,
]);
expect(sessionEntries.get(spawnedGrandchildKey!)).toMatchObject({
parentSessionKey: spawnedChildKey,
parentSessionId: CHILD.sessionId,
@@ -9,23 +9,34 @@ import {
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
} from "../../../packages/gateway-protocol/src/schema/worker-protocol-primitives.js";
import { buildSubagentExecutionSessionSpawnContext } from "../../agents/subagents/spawn/subagent-spawn-execution-identity.js";
import {
getGatewayToolCallerIdentity,
withGatewayToolCallerIdentity,
} from "../../agents/tools/gateway-caller-context.js";
import {
callAgentToolGatewayRequest,
callInProcessGatewayToolWithCreation,
type InProcessGatewayCaller,
withAgentToolGatewayRuntimeIdentity,
} from "../../agents/tools/in-process-gateway.js";
import { runWithScopedSessionAccess } from "../../agents/tools/scoped-session-access.js";
import { createSessionsSendTool } from "../../agents/tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "../../agents/tools/sessions-spawn-tool.js";
import { jsonResult } from "../../agents/tools/tool-results.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js";
import { getRuntimeConfig } from "../../config/config.js";
import { sha256Base64Url } from "../../infra/crypto-digest.js";
import { sha256Base64Url, sha256HexPrefixCore } from "../../infra/crypto-digest.js";
import { redactSensitiveText } from "../../logging/redact.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js";
import { loadGatewaySessionEntryReadOnly } from "../session-utils.js";
import type { WorkerConnectionIdentity } from "./connection-identity.js";
import type { WorkerSessionPlacementStore } from "./placement-store.js";
import {
getWorkerTurnExecutionIdentityCapability,
type WorkerTurnExecutionIdentity,
} from "./placement-turn-claim-events.js";
import type { WorkerPlacementDispatchContract } from "./service-contract.js";
import type { WorkerEnvironmentService } from "./service.js";
import {
@@ -102,7 +113,10 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
}
function childSessionKey(params: { operationSeed: string; targetAgentId: string }): string {
const suffix = operationKey(params.operationSeed, "child-session").slice(0, 32);
const suffix = sha256HexPrefixCore(
`openclaw.worker-session-tool-operation.v1\0${params.operationSeed}\0child-session`,
32,
);
return `agent:${params.targetAgentId}:dashboard:cloud-${suffix}`;
}
@@ -136,6 +150,13 @@ export function createWorkerSessionToolExecutor(params: {
const authorizedTools = WORKER_TOOL_NAMES.filter((name) =>
params.placements.isWorkerTurnToolAuthorized(operation.source.turnClaim, name),
);
const lineageCapability = getWorkerTurnExecutionIdentityCapability(params.placements, {
sessionId: operation.source.sessionId,
environmentId: operation.source.turnClaim.owner.environmentId,
ownerEpoch: operation.source.turnClaim.owner.ownerEpoch,
runId: operation.source.turnClaim.runId,
});
let workerIdentity: WorkerTurnExecutionIdentity | undefined;
const gatewayCall: InProcessGatewayCaller = async <T = Record<string, unknown>>(
method: string,
requestParams: Record<string, unknown>,
@@ -285,8 +306,27 @@ export function createWorkerSessionToolExecutor(params: {
targetAgentId,
});
const childRunId = operationKey(operation.operationSeed, "initial-task");
const config = getRuntimeConfig();
const gatewayCaller = getGatewayToolCallerIdentity();
const sessionSpawnContext = lineageCapability
? buildSubagentExecutionSessionSpawnContext({
enabled: true,
backend: "subagent",
parentAgentId: operation.source.agentId,
requesterRef: operation.source.sessionKey,
controllerRef: operation.source.sessionKey,
depth: (operation.source.entry.spawnDepth ?? 0) + 1,
maxDepth:
config.agents?.defaults?.subagents?.maxSpawnDepth ??
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
targetAgentId,
sandbox: "inherit",
inheritedToolAllowlist: authorizedTools,
inheritedToolDenylist: [],
})
: undefined;
const run = await runWithScopedSessionAccess({
cfg: getRuntimeConfig(),
cfg: config,
expectedSessionId: childSessionId,
targetSessionKey: operation.childSessionKey,
...(operation.signal ? { signal: operation.signal } : {}),
@@ -304,19 +344,55 @@ export function createWorkerSessionToolExecutor(params: {
targetAgentId,
});
assertActiveChildPlacement();
sendResult = await callAgentToolGatewayRequest({
method: "chat.send",
const request = {
method: "agent",
agentRunTracking: "native_subagent",
params: {
sessionKey: operation.childSessionKey,
sessionId: childSessionId,
expectedExistingSessionId: childSessionId,
message: operation.request.task,
deliver: false,
sessionEffects: "visible",
// A lost response is replayed with this same downstream key;
// the child turn is never started under a fresh identity.
idempotencyKey: `worker-session-spawn:${childRunId}`,
},
...(operation.signal ? { signal: operation.signal } : {}),
timeoutMs: null,
});
} as const;
sendResult =
lineageCapability && workerIdentity
? await lineageCapability.run(async (identity) => {
if (
identity !== workerIdentity ||
gatewayCaller?.agentId !== identity.agentId ||
gatewayCaller.sessionKey !== identity.sessionKey ||
gatewayCaller.operationalRunInstance !==
identity.operationalRunInstance ||
gatewayCaller.executionIdentityToken !==
identity.executionIdentityToken ||
gatewayCaller.workerTurnClaim !== identity.turnClaim
) {
throw new Error("worker child admission identity changed");
}
return await callAgentToolGatewayRequest(
withAgentToolGatewayRuntimeIdentity(request, {
kind: "agentRuntime",
agentId: identity.agentId,
sessionKey: identity.sessionKey,
operationalRunInstance: identity.operationalRunInstance,
delegatedAuthority: {
kind: "worker",
...identity.delegatedAuthority,
turnClaim: identity.turnClaim,
},
executionIdentity: identity.executionIdentityToken,
sessionSpawnContext,
}),
);
})
: await callAgentToolGatewayRequest(request);
break;
} catch (error) {
if (attempt === 1) {
@@ -355,17 +431,39 @@ export function createWorkerSessionToolExecutor(params: {
expectedParentSessionId: operation.source.sessionId,
...(operation.signal ? { signal: operation.signal } : {}),
});
return await tool.execute(operation.request.toolCallId, {
task: operation.request.task,
...(operation.request.label ? { label: operation.request.label } : {}),
...(operation.request.agentId ? { agentId: operation.request.agentId } : {}),
...(operation.request.model ? { model: operation.request.model } : {}),
...(operation.request.runTimeoutSeconds === undefined
? {}
: { runTimeoutSeconds: operation.request.runTimeoutSeconds }),
visible: true,
worktree: true,
});
const executeSpawn = () =>
tool.execute(operation.request.toolCallId, {
task: operation.request.task,
...(operation.request.label ? { label: operation.request.label } : {}),
...(operation.request.agentId ? { agentId: operation.request.agentId } : {}),
...(operation.request.model ? { model: operation.request.model } : {}),
...(operation.request.runTimeoutSeconds === undefined
? {}
: { runTimeoutSeconds: operation.request.runTimeoutSeconds }),
expectsCompletionMessage: false,
visible: true,
worktree: true,
});
return lineageCapability
? await lineageCapability.run(async (identity) => {
workerIdentity = identity;
try {
return await withGatewayToolCallerIdentity(
{
agentId: identity.agentId,
sessionKey: identity.sessionKey,
operationalRunInstance: identity.operationalRunInstance,
executionIdentityToken: identity.executionIdentityToken,
workerTurnClaim: identity.turnClaim,
workerTurnExecutionIdentityCapability: lineageCapability,
},
executeSpawn,
);
} finally {
workerIdentity = undefined;
}
})
: await executeSpawn();
};
const send = async (operation: {
@@ -7,7 +7,9 @@ export type WorkerSessionToolSource = {
agentId: string;
sessionKey: string;
sessionId: string;
turnClaim: NonNullable<WorkerConnectionIdentity["turnClaim"]>;
turnClaim: NonNullable<WorkerConnectionIdentity["turnClaim"]> & {
owner: { kind: "worker"; environmentId: string; ownerEpoch: number };
};
entry: NonNullable<ReturnType<typeof loadGatewaySessionEntryReadOnly>["entry"]>;
};
@@ -59,7 +61,7 @@ export function resolveWorkerSessionToolSource(params: {
agentId: placement.agentId,
sessionKey: placement.sessionKey,
sessionId: identity.sessionId,
turnClaim: claim,
turnClaim: { ...claim, owner: claim.owner },
entry: loaded.entry,
};
}
@@ -202,6 +202,7 @@ async function executeWorkerTurn(params: {
const { operationalRunInstance, runtimeIdentity } = await prepareWorkerAgentRuntimeIdentity({
agentId: placement.agentId,
runtimeInstanceId: placement.environmentId,
placements: params.placements,
sessionKey: placement.sessionKey,
turn,
turnClaim: params.turnClaim,
@@ -42,6 +42,8 @@ import {
type AgentRuntimeIdentityTokenParams,
} from "../agent-runtime-identity-token.js";
import type { WorkerSessionTurnClaim } from "./placement-record.js";
import type { WorkerSessionPlacementStore } from "./placement-store.js";
import { bindWorkerTurnExecutionIdentity } from "./placement-turn-claim-events.js";
type WorkerInitialMessagePlan =
| { kind: "complete"; messages: WorkerTranscriptMessage[] }
@@ -84,7 +86,11 @@ function buildWorkerAgentRuntimeIdentity(params: {
type PrepareWorkerAgentRuntimeIdentityParams = Omit<
Parameters<typeof buildWorkerAgentRuntimeIdentity>[0],
"admittedRunContext" | "turn"
> & { runtimeInstanceId: string; turn: SessionPlacementTurnParams };
> & {
runtimeInstanceId: string;
turn: SessionPlacementTurnParams;
placements: WorkerSessionPlacementStore;
};
export async function prepareWorkerAgentRuntimeIdentity(
params: PrepareWorkerAgentRuntimeIdentityParams,
@@ -96,9 +102,21 @@ export async function prepareWorkerAgentRuntimeIdentity(
admittedRunContext: params.turn.admittedRunContext,
preparedRunAdmission: params.turn.preparedRunAdmission,
});
const runtimeIdentity = buildWorkerAgentRuntimeIdentity({ ...params, admittedRunContext });
// Worker session RPC carries no raw identity token. Bind provenance to the exact
// host claim before launch so child lineage cannot become bearer authority.
if (runtimeIdentity.executionIdentityToken) {
bindWorkerTurnExecutionIdentity(
params.placements,
params.turnClaim,
runtimeIdentity.executionIdentityToken,
admittedRunContext.operationalRunInstance,
{ agentId: params.agentId, sessionKey: params.sessionKey },
);
}
return {
operationalRunInstance: admittedRunContext.operationalRunInstance,
runtimeIdentity: buildWorkerAgentRuntimeIdentity({ ...params, admittedRunContext }),
runtimeIdentity,
};
}
@@ -4,7 +4,6 @@ import { createServer, type Server } from "node:http";
import path from "node:path";
import { promisify } from "node:util";
import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import { expect, vi } from "vitest";
import { startQaGatewayChild } from "../../../../extensions/qa-lab/api.js";
import {
GATEWAY_CLIENT_MODES,
@@ -34,6 +33,20 @@ import { MODEL_REF, PROOF_TIMEOUT_MS } from "./cloud-worker-midturn-loss-fixture
const execFileAsync = promisify(execFile);
const NODE_DISPLAY_NAME = "QA Gateway-bundle worker node";
async function waitUntil<T>(read: () => Promise<T | undefined>): Promise<T> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const value = await read();
if (value !== undefined) {
return value;
}
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
}
throw new Error("timed out waiting for paired worker node state");
}
export type WireGateway = Awaited<ReturnType<typeof startQaGatewayChild>>;
type WireGatewayEvent = { event: string; payload?: unknown };
export type WireNodeRead = {
@@ -186,48 +199,32 @@ function isPairingRequired(error: unknown): boolean {
}
async function approveNodePairing(operator: GatewayClient, nodeId: string): Promise<void> {
let nodeRequestId: string | undefined;
await vi.waitFor(
async () => {
const result = await operator.request<{
pending?: Array<{ requestId?: string; nodeId?: string }>;
}>("node.pair.list", {});
nodeRequestId = result.pending?.find((entry) => entry.nodeId === nodeId)?.requestId;
expect(nodeRequestId).toBeTruthy();
},
{ timeout: 30_000, interval: 100 },
);
const nodeRequestId = await waitUntil(async () => {
const result = await operator.request<{
pending?: Array<{ requestId?: string; nodeId?: string }>;
}>("node.pair.list", {});
return result.pending?.find((entry) => entry.nodeId === nodeId)?.requestId;
});
await operator.request("node.pair.approve", { requestId: nodeRequestId });
}
async function approvePairing(operator: GatewayClient, nodeId: string): Promise<void> {
let deviceRequestId: string | undefined;
await vi.waitFor(
async () => {
const result = await operator.request<{
pending?: Array<{ requestId?: string; deviceId?: string; role?: string }>;
}>("device.pair.list", {});
deviceRequestId = result.pending?.find(
(entry) => entry.deviceId === nodeId || entry.role === "node",
)?.requestId;
expect(deviceRequestId).toBeTruthy();
},
{ timeout: 30_000, interval: 100 },
);
const deviceRequestId = await waitUntil(async () => {
const result = await operator.request<{
pending?: Array<{ requestId?: string; deviceId?: string; role?: string }>;
}>("device.pair.list", {});
return result.pending?.find((entry) => entry.deviceId === nodeId || entry.role === "node")
?.requestId;
});
await operator.request("device.pair.approve", { requestId: deviceRequestId });
await approveNodePairing(operator, nodeId);
}
async function ensureNodeApproved(operator: GatewayClient, nodeId: string): Promise<boolean> {
let approvalState: string | undefined;
await vi.waitFor(
async () => {
const result = await operator.request<{ nodes?: WireNodeRead[] }>("node.list", {});
approvalState = result.nodes?.find((node) => node.nodeId === nodeId)?.approvalState;
expect(approvalState).toBeTruthy();
},
{ timeout: 30_000, interval: 100 },
);
const approvalState = await waitUntil(async () => {
const result = await operator.request<{ nodes?: WireNodeRead[] }>("node.list", {});
return result.nodes?.find((node) => node.nodeId === nodeId)?.approvalState;
});
if (approvalState !== "approved") {
await approveNodePairing(operator, nodeId);
return true;
@@ -239,25 +236,16 @@ async function waitForApprovedWireNode(
operator: GatewayClient,
nodeId: string,
): Promise<WireNodeRead> {
let approved: WireNodeRead | undefined;
await vi.waitFor(
async () => {
const result = await operator.request<{ nodes?: WireNodeRead[] }>("node.list", {});
approved = result.nodes?.find((node) => node.nodeId === nodeId);
expect(approved).toMatchObject({
nodeId,
approvalState: "approved",
connected: true,
paired: true,
sessionHost: true,
});
},
{ timeout: 30_000, interval: 100 },
);
if (!approved) {
throw new Error("paired worker node did not become available");
}
return approved;
return await waitUntil(async () => {
const result = await operator.request<{ nodes?: WireNodeRead[] }>("node.list", {});
const approved = result.nodes?.find((node) => node.nodeId === nodeId);
return approved?.approvalState === "approved" &&
approved.connected === true &&
approved.paired === true &&
approved.sessionHost === true
? approved
: undefined;
});
}
type WireWorkerHostOptions = {
@@ -427,19 +415,16 @@ export async function createPairedNodeWorkerHost(
await drainInvokeTasks();
},
async waitForWorkersIdle() {
await vi.waitFor(
async () => {
const receipts = await Promise.all(
[...launchIds].map(async (launchId) => await supervisor.status(launchId)),
);
expect(
receipts.every(
(receipt) => receipt !== undefined && !["pending", "running"].includes(receipt.state),
),
).toBe(true);
},
{ timeout: 30_000, interval: 100 },
);
await waitUntil(async () => {
const receipts = await Promise.all(
[...launchIds].map(async (launchId) => await supervisor.status(launchId)),
);
return receipts.every(
(receipt) => receipt !== undefined && !["pending", "running"].includes(receipt.state),
)
? true
: undefined;
});
},
async installedBundleDirectory(bundleHash) {
const namespaces = await fs.readdir(nodeHostRoot, { withFileTypes: true });
@@ -491,10 +476,14 @@ export async function createPairedNodeWorkerHost(
export async function startPairedNodeWorkerGateway(params: {
providerBaseUrl: string;
executionIdentity?: boolean;
repoRoot?: string;
useRepoCli?: boolean;
workspaceDir?: string;
}): Promise<WireGateway> {
return await startQaGatewayChild({
repoRoot: process.cwd(),
useRepoCli: true,
repoRoot: params.repoRoot ?? process.cwd(),
useRepoCli: params.useRepoCli ?? true,
providerBaseUrl: `${params.providerBaseUrl}/v1`,
providerMode: "mock-openai",
primaryModel: MODEL_REF,
@@ -503,6 +492,23 @@ export async function startPairedNodeWorkerGateway(params: {
controlUiEnabled: false,
mutateConfig: (config) => ({
...config,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
...(params.workspaceDir ? { workspace: params.workspaceDir } : {}),
subagents: {
...config.agents?.defaults?.subagents,
maxSpawnDepth: 2,
},
},
},
logging: params.executionIdentity
? {
...config.logging,
audit: { ...config.logging?.audit, executionIdentity: true },
}
: config.logging,
nodeHost: {
...config.nodeHost,
workerRuns: { enabled: true },
File diff suppressed because it is too large Load Diff