mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user