fix(agents): reject attribution collisions before capture

This commit is contained in:
Vincent Koc
2026-08-07 04:33:48 +02:00
parent 374e05f092
commit 51fc5aa3db
7 changed files with 150 additions and 8 deletions
@@ -5,6 +5,7 @@ import {
} from "../audit/execution-identity-admission.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { captureAgentRunLifecycleGeneration } from "../infra/agent-events.js";
import { assertAgentRunAttributionAdmissionCompatible } from "../infra/agent-run-registry.js";
import { createAgentExecutionAttribution } from "./agent-execution-attribution.js";
import type { AgentCommandGatewayIngressOpts, AgentCommandOpts } from "./command/types.js";
@@ -74,6 +75,11 @@ function resolveAgentCommandExecutionAttribution(
opts.executionAttribution?.lifecycleGeneration ??
opts.lifecycleGeneration ??
captureAgentRunLifecycleGeneration(params.runId);
assertAgentRunAttributionAdmissionCompatible(
params.runId,
lifecycleGeneration,
opts.executionAttribution,
);
return {
attribution:
opts.executionAttribution ??
@@ -4,6 +4,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import * as agentRunRegistry from "../infra/agent-run-registry.js";
import { createUserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.js";
import {
deliveryContextFromSession,
@@ -387,7 +388,10 @@ vi.mock("../infra/agent-events.js", () => ({
registerAgentEventLifecycleRotationHandler: vi.fn(),
withAgentRunLifecycleGeneration: (_generation: string, run: () => unknown) => run(),
}));
vi.mock("../infra/agent-run-registry.js", () => ({
vi.mock("../infra/agent-run-registry.js", async () => ({
...(await vi.importActual<typeof import("../infra/agent-run-registry.js")>(
"../infra/agent-run-registry.js",
)),
clearAgentRunContext: (...args: unknown[]) => state.clearAgentRunContextMock(...args),
registerAgentRunContext: (...args: unknown[]) => state.registerAgentRunContextMock(...args),
}));
@@ -1043,6 +1047,7 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
});
afterEach(() => {
agentRunRegistry.resetAgentRunRegistryForTest();
vi.restoreAllMocks();
});
@@ -4570,6 +4575,31 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
);
});
it("rejects a colliding public ACP run id before recording execution identity", async () => {
setupAcpSession();
const runId = "session-1";
agentRunRegistry.claimAgentRunContext(runId, {
attribution: createAgentExecutionAttribution({
runId,
lifecycleGeneration: "test-generation",
}),
lifecycleGeneration: "test-generation",
});
await expect(
agentCommandFromIngress({
message: "colliding public ACP turn",
sessionKey: "agent:main:main",
runId,
allowModelOverride: false,
}),
).rejects.toThrow("Agent run ID is already bound to host-owned execution attribution.");
expect(state.enqueueExecutionIdentityContextAtAdmissionMock).not.toHaveBeenCalled();
expect(state.registerAgentRunContextMock).not.toHaveBeenCalled();
expect(state.acpRunTurnMock).not.toHaveBeenCalled();
});
it("allows manual ACP spawn turns when ACP dispatch is disabled", async () => {
setupAcpSession();
state.resolveAcpDispatchPolicyErrorMock.mockReturnValue(
@@ -9,6 +9,7 @@ import {
type ExecutionIdentityAdmissionFacts,
} from "../../audit/execution-identity-admission.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertAgentRunAttributionAdmissionCompatible } from "../../infra/agent-run-registry.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
type AutoReplyExecutionIdentityContext = {
@@ -133,6 +134,11 @@ export function admitAutoReplyExecutionAttribution(params: {
lifecycleGeneration: string;
runId: string;
}): AgentExecutionAttribution {
assertAgentRunAttributionAdmissionCompatible(
params.runId,
params.lifecycleGeneration,
params.attribution,
);
if (params.attribution) {
return params.attribution;
}
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createAgentExecutionAttribution } from "../../agents/agent-execution-attribution.js";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
import { installSessionPlacementAdmissionProvider } from "../../agents/session-placement-admission.js";
import { configureExecutionIdentityAdmissionSink } from "../../audit/execution-identity-admission.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
getAgentEventLifecycleGeneration,
@@ -13,6 +14,7 @@ import {
getExecuteAgentTurnForTest,
createMockTypingSignaler,
createFollowupRun,
GENERIC_RUN_FAILURE_TEXT,
requireRecord,
requireMockCall,
expectMockCallArgFields,
@@ -320,12 +322,58 @@ describe("executeAgentTurn: runtime selection", () => {
kind: "final",
payload: {
isError: true,
text: "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.",
text: GENERIC_RUN_FAILURE_TEXT,
},
});
expect(state.runCliAgentMock).not.toHaveBeenCalled();
expect(agentRunRegistry.getAgentRunContext(runId)?.attribution).toBe(existingAttribution);
agentRunRegistry.resetAgentRunRegistryForTest();
});
it("rejects a fresh auto-reply run id collision before recording execution identity", async () => {
const agentRunRegistry = await import("../../infra/agent-run-registry.js");
const lifecycleGeneration = getAgentEventLifecycleGeneration();
const runId = "fresh-auto-reply-attribution-collision";
const existingAttribution = createAgentExecutionAttribution({
runId,
lifecycleGeneration,
});
agentRunRegistry.claimAgentRunContext(runId, {
attribution: existingAttribution,
lifecycleGeneration,
});
const sink = vi.fn(() => true);
const restoreSink = configureExecutionIdentityAdmissionSink(sink);
const followupRun = createFollowupRun();
followupRun.run.config = {
logging: { audit: { enabled: true, executionIdentity: true } },
};
try {
const executeAgentTurn = await getExecuteAgentTurnForTest();
await expect(
executeAgentTurn(
createMinimalRunAgentTurnParams({
followupRun,
opts: { runId },
}),
),
).resolves.toEqual({
kind: "final",
payload: {
isError: true,
text: GENERIC_RUN_FAILURE_TEXT,
},
});
expect(sink).not.toHaveBeenCalled();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(agentRunRegistry.getAgentRunContext(runId)?.attribution).toBe(existingAttribution);
} finally {
restoreSink();
agentRunRegistry.resetAgentRunRegistryForTest();
}
});
it("rejects queued heartbeat CLI fallback after placement crosses a lifecycle rotation", async () => {
+32 -2
View File
@@ -25,7 +25,11 @@ import {
captureAgentRunLifecycleGeneration,
withAgentRunLifecycleGeneration,
} from "../../infra/agent-events.js";
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
import {
AgentRunAttributionCollisionError,
clearAgentRunContext,
registerAgentRunContext,
} from "../../infra/agent-run-registry.js";
import { emitAgentRunStatusEvent } from "../../infra/agent-run-status-events.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -49,6 +53,7 @@ import type {
AgentTurnParams,
RuntimeFallbackAttempt,
} from "./agent-runner-execution.types.js";
import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
import {
buildTerminalAgentRunFailureReplyPayload,
markAgentRunFailureReplyPayload,
@@ -505,13 +510,28 @@ function resolveAgentTurnRunId(params: AgentTurnParams): string {
return attributedRunId ?? requestedRunId ?? crypto.randomUUID();
}
function tryAdmitAgentTurnExecutionAttribution(
params: Parameters<typeof admitAutoReplyExecutionAttribution>[0],
):
| { kind: "admitted"; attribution: ReturnType<typeof admitAutoReplyExecutionAttribution> }
| { kind: "collision" } {
try {
return { kind: "admitted", attribution: admitAutoReplyExecutionAttribution(params) };
} catch (error) {
if (error instanceof AgentRunAttributionCollisionError) {
return { kind: "collision" };
}
throw error;
}
}
/** Runs the agent turn with provider/model fallback, retry, and closed settlement. */
export async function executeAgentTurn(params: AgentTurnParams): Promise<AgentTurnExecutionResult> {
const runId = resolveAgentTurnRunId(params);
const baseExecutionParams =
params.opts?.runId === runId ? params : { ...params, opts: { ...params.opts, runId } };
const lifecycleGeneration = captureAgentRunLifecycleGeneration(runId);
const attribution = admitAutoReplyExecutionAttribution({
const attributionAdmission = tryAdmitAgentTurnExecutionAttribution({
attribution: baseExecutionParams.attribution,
config: resolveQueuedReplyRuntimeConfig(baseExecutionParams.followupRun.run.config),
lifecycleGeneration,
@@ -542,6 +562,16 @@ export async function executeAgentTurn(params: AgentTurnParams): Promise<AgentTu
baseExecutionParams.sessionCtx.MessageThreadId,
},
});
if (attributionAdmission.kind === "collision") {
return {
runId,
outcome: {
kind: "rejected",
payload: { text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, isError: true },
},
};
}
const attribution = attributionAdmission.attribution;
const executionParams =
baseExecutionParams.attribution === attribution
? baseExecutionParams
+4 -1
View File
@@ -52,7 +52,10 @@ const attemptExecutionMocks = vi.hoisted(() => ({
}));
vi.mock("../infra/agent-events.js", () => agentEventMocks);
vi.mock("../infra/agent-run-registry.js", () => ({
vi.mock("../infra/agent-run-registry.js", async () => ({
...(await vi.importActual<typeof import("../infra/agent-run-registry.js")>(
"../infra/agent-run-registry.js",
)),
clearAgentRunContext: agentEventMocks.clearAgentRunContext,
registerAgentRunContext: agentEventMocks.registerAgentRunContext,
}));
+21 -2
View File
@@ -53,6 +53,8 @@ type AgentRunRegistryState = {
const AGENT_RUN_REGISTRY_STATE_KEY = Symbol.for("openclaw.agentRunRegistry.state");
export class AgentRunAttributionCollisionError extends TypeError {}
function getAgentRunRegistryState(): AgentRunRegistryState {
return resolveGlobalSingleton<AgentRunRegistryState>(AGENT_RUN_REGISTRY_STATE_KEY, () => ({
contexts: new Map<string, AgentRunContext>(),
@@ -87,7 +89,9 @@ export function assertAgentRunAttributionCompatible(
attribution: AgentExecutionAttribution | undefined,
): void {
if (existingAttribution && !attribution) {
throw new TypeError("Agent run ID is already bound to host-owned execution attribution.");
throw new AgentRunAttributionCollisionError(
"Agent run ID is already bound to host-owned execution attribution.",
);
}
if (
existingAttribution &&
@@ -96,10 +100,25 @@ export function assertAgentRunAttributionCompatible(
existingAttribution.executionId !== attribution.executionId ||
existingAttribution.createdAt !== attribution.createdAt)
) {
throw new TypeError("Agent run ID is already bound to different execution attribution.");
throw new AgentRunAttributionCollisionError(
"Agent run ID is already bound to different execution attribution.",
);
}
}
/** Rejects attribution collisions before admission-owned audit capture can observe them. */
export function assertAgentRunAttributionAdmissionCompatible(
runId: string,
lifecycleGeneration: string,
attribution: AgentExecutionAttribution | undefined,
): void {
const existing = getAgentRunRegistryState().contexts.get(runId);
if (existing?.lifecycleGeneration !== lifecycleGeneration) {
return;
}
assertAgentRunAttributionCompatible(existing.attribution, attribution);
}
function createAgentRunContext(
context: AgentRunContext,
lifecycleGeneration: string,