diff --git a/src/agents/agent-command-execution-identity.ts b/src/agents/agent-command-execution-identity.ts index b195b6746412..9e1605eed7da 100644 --- a/src/agents/agent-command-execution-identity.ts +++ b/src/agents/agent-command-execution-identity.ts @@ -19,7 +19,7 @@ function systemIngress(boundary: string): AgentCommandAdmissionIngress { } function recordAgentCommandExecutionIdentity(params: { - attribution?: AgentCommandOpts["executionAttribution"]; + admission?: AgentCommandOpts["executionIdentityAdmission"]; agentId: string; cfg: OpenClawConfig; ingress: AgentCommandAdmissionIngress; @@ -37,17 +37,8 @@ function recordAgentCommandExecutionIdentity(params: { }, { enabled: isExecutionIdentityCollectionEnabled(params.cfg), - ...(params.attribution - ? params.attribution.executionIdentityAdmission - ? { - token: params.attribution.executionIdentityAdmission.token, - retryOnly: params.attribution.executionIdentityAdmission.retryOnly, - } - : { - contextId: params.attribution.contextId, - executionId: params.attribution.executionId, - now: params.attribution.createdAt, - } + ...(params.admission + ? { token: params.admission.token, retryOnly: params.admission.retryOnly } : {}), }, ); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 276e87b4717b..32ab0af45821 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -231,7 +231,7 @@ async function agentCommandInternal( }); return await sessionWorkAdmission.run(async () => { executionIdentity.record({ - attribution: opts.executionAttribution, + admission: opts.executionIdentityAdmission, agentId: sessionAgentId, cfg, ingress: admissionIngress, @@ -674,10 +674,10 @@ export async function agentCommandFromIngress( runtime: RuntimeEnv = defaultRuntime, deps?: CliDeps, ) { - // Plugin SDK callers may be plain JavaScript. Enforce the private execution + // Plugin SDK callers may be plain JavaScript. Enforce the private recovery // boundary at runtime so extra or inherited properties cannot author audit identity. return await agentCommandFromIngressInternal( - { ...opts, executionAttribution: undefined }, + { ...opts, executionIdentityAdmission: undefined }, runtime, deps, ); diff --git a/src/agents/agent-execution-attribution.test.ts b/src/agents/agent-execution-attribution.test.ts deleted file mode 100644 index 158b37d45bb8..000000000000 --- a/src/agents/agent-execution-attribution.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createExecutionIdentityAdmissionToken } from "../audit/execution-identity-admission.js"; -import { - createAgentExecutionAttribution, - rebindAgentExecutionAttribution, -} from "./agent-execution-attribution.js"; - -describe("createAgentExecutionAttribution", () => { - it("preserves required identities, normalizes optional correlation, and freezes the record", () => { - const token = createExecutionIdentityAdmissionToken(" run-1 ", { - contextId: "context-1", - executionId: "execution-1", - now: 123, - }); - const attribution = createAgentExecutionAttribution({ - runId: " run-1 ", - lifecycleGeneration: " generation-1 ", - sessionKey: " agent:main:main ", - sessionId: " session-1 ", - agentId: " main ", - executionIdentityAdmission: { token, retryOnly: true }, - }); - - expect(attribution).toEqual({ - runId: " run-1 ", - contextId: "context-1", - executionId: "execution-1", - createdAt: 123, - lifecycleGeneration: " generation-1 ", - executionIdentityAdmission: { token, retryOnly: true }, - sessionKey: "agent:main:main", - sessionId: "session-1", - agentId: "main", - }); - expect(Object.isFrozen(attribution)).toBe(true); - expect(Reflect.set(attribution, "sessionId", "replacement")).toBe(false); - }); - - it("leaves unknown optional correlation absent", () => { - const attribution = createAgentExecutionAttribution({ - runId: "run-1", - lifecycleGeneration: "generation-1", - sessionKey: " ", - sessionId: "", - }); - expect(attribution).toMatchObject({ - runId: "run-1", - lifecycleGeneration: "generation-1", - }); - expect(attribution).not.toHaveProperty("sessionKey"); - expect(attribution).not.toHaveProperty("sessionId"); - expect(attribution).not.toHaveProperty("executionIdentityAdmission"); - expect(attribution.contextId).toBeTruthy(); - expect(attribution.executionId).toBeTruthy(); - expect(attribution.createdAt).toBeGreaterThan(0); - }); - - it("does not apply the opt-in audit token bounds to default runtime attribution", () => { - const runId = "r".repeat(1_024); - const attribution = createAgentExecutionAttribution({ - runId, - lifecycleGeneration: "generation-1", - }); - - expect(attribution.runId).toBe(runId); - expect(attribution).not.toHaveProperty("executionIdentityAdmission"); - }); - - it.each([false, true])( - "changes only lifecycle ownership when rebound (token=%s)", - (withToken) => { - const runId = "run-rebound"; - const attribution = createAgentExecutionAttribution({ - runId, - lifecycleGeneration: "generation-1", - ...(withToken - ? { - executionIdentityAdmission: { - token: createExecutionIdentityAdmissionToken(runId, { - contextId: "context-1", - executionId: "execution-1", - now: 123, - }), - retryOnly: true, - }, - } - : {}), - }); - - const rebound = rebindAgentExecutionAttribution(attribution, "generation-2"); - - expect(rebound).toEqual({ - ...attribution, - lifecycleGeneration: "generation-2", - }); - expect(rebound.contextId).toBe(attribution.contextId); - expect(rebound.executionId).toBe(attribution.executionId); - expect(rebound.createdAt).toBe(attribution.createdAt); - expect(rebound.executionIdentityAdmission).toBe(attribution.executionIdentityAdmission); - expect(Object.isFrozen(rebound)).toBe(true); - }, - ); - - it.each([ - ["runId", { runId: " ", lifecycleGeneration: "generation-1" }], - ["lifecycleGeneration", { runId: "run-1", lifecycleGeneration: "" }], - ])("rejects a missing required %s", (field, params) => { - expect(() => createAgentExecutionAttribution(params)).toThrow( - `Agent execution attribution requires ${field}`, - ); - }); - - it("rejects an admission token owned by another run", () => { - expect(() => - createAgentExecutionAttribution({ - runId: "run-1", - lifecycleGeneration: "generation-1", - executionIdentityAdmission: { - token: createExecutionIdentityAdmissionToken("run-2"), - retryOnly: false, - }, - }), - ).toThrow("Agent execution attribution token disagrees with runId"); - }); -}); diff --git a/src/agents/agent-execution-attribution.ts b/src/agents/agent-execution-attribution.ts deleted file mode 100644 index e5c0dc11eb6f..000000000000 --- a/src/agents/agent-execution-attribution.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - parseExecutionIdentityAdmissionToken, - type ExecutionIdentityAdmissionToken, -} from "../audit/execution-identity-admission.js"; - -export type AgentExecutionIdentityAdmission = Readonly<{ - token: ExecutionIdentityAdmissionToken; - retryOnly: boolean; -}>; - -/** Host-owned correlation captured once for an admitted agent execution. */ -export type AgentExecutionAttribution = Readonly<{ - runId: string; - contextId: string; - executionId: string; - createdAt: number; - lifecycleGeneration: string; - executionIdentityAdmission?: AgentExecutionIdentityAdmission; - sessionKey?: string; - sessionId?: string; - agentId?: string; -}>; - -function requireAttributionField(value: string, field: "runId" | "lifecycleGeneration"): string { - if (!value.trim()) { - throw new TypeError(`Agent execution attribution requires ${field}`); - } - return value; -} - -export function createAgentExecutionAttribution(params: { - runId: string; - lifecycleGeneration: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - executionIdentityAdmission?: AgentExecutionIdentityAdmission; -}): AgentExecutionAttribution { - const runId = requireAttributionField(params.runId, "runId"); - const token = params.executionIdentityAdmission - ? parseExecutionIdentityAdmissionToken(params.executionIdentityAdmission.token) - : undefined; - if (token && token.runId !== runId) { - throw new TypeError("Agent execution attribution token disagrees with runId"); - } - const sessionKey = normalizeOptionalString(params.sessionKey); - const sessionId = normalizeOptionalString(params.sessionId); - const agentId = normalizeOptionalString(params.agentId); - return Object.freeze({ - runId, - contextId: token?.contextId ?? randomUUID(), - executionId: token?.executionId ?? randomUUID(), - createdAt: token?.createdAt ?? Date.now(), - lifecycleGeneration: requireAttributionField(params.lifecycleGeneration, "lifecycleGeneration"), - ...(token - ? { - executionIdentityAdmission: Object.freeze({ - token, - retryOnly: params.executionIdentityAdmission?.retryOnly === true, - }), - } - : {}), - ...(sessionKey ? { sessionKey } : {}), - ...(sessionId ? { sessionId } : {}), - ...(agentId ? { agentId } : {}), - }); -} - -export function rebindAgentExecutionAttribution( - attribution: AgentExecutionAttribution, - lifecycleGeneration: string, -): AgentExecutionAttribution { - return Object.freeze({ - ...attribution, - lifecycleGeneration: requireAttributionField(lifecycleGeneration, "lifecycleGeneration"), - }); -} diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 71a3d01614ff..5eccb85f4dc4 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -5,6 +5,7 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; import type { SpawnedRunMetadata } from "../../agents/spawned-context.js"; import type { PromptMode } from "../../agents/system-prompt.types.js"; +import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js"; import type { MediaFact } from "../../media/media-facts.js"; @@ -16,7 +17,6 @@ import type { UserTurnInput, UserTurnTranscriptRecorder, } from "../../sessions/user-turn-transcript.types.js"; -import type { AgentExecutionAttribution } from "../agent-execution-attribution.js"; import type { ExecApprovalContinuationPromptRange } from "../bash-tools.exec-approval-output.js"; import type { ExecElevatedDefaults } from "../bash-tools.exec-types.js"; import type { BootstrapContextRunKind } from "../bootstrap-mode.js"; @@ -193,8 +193,11 @@ export type AgentCommandOpts = { mainRestartRecoveryOwnerLease?: MainSessionRecoveryOwnerLease; /** Gateway already consumed this automatic recovery run's durable reservation. */ mainRestartRecoveryAdmitted?: boolean; - /** Private host-owned execution identity; public ingress callers cannot author it. */ - executionAttribution?: AgentExecutionAttribution; + /** Private recovery correlation; public ingress callers cannot author identity evidence. */ + executionIdentityAdmission?: { + token: ExecutionIdentityAdmissionToken; + retryOnly: boolean; + }; /** Called when the actual run model is selected, including fallback retries. */ onActiveModelSelected?: (ctx: { provider: string; model: string }) => void | Promise; /** Called when every candidate in the run's model fallback chain failed. */ @@ -218,7 +221,7 @@ export type AgentCommandOpts = { /** Restricted option surface for external ingress callsites. */ export type AgentCommandIngressOpts = Omit< AgentCommandOpts, - "senderIsOwner" | "allowModelOverride" | "executionAttribution" + "senderIsOwner" | "allowModelOverride" | "executionIdentityAdmission" > & { /** Trusted sender identity bit for command/channel-action auth; defaults false for ingress. */ senderIsOwner?: boolean; @@ -226,6 +229,6 @@ export type AgentCommandIngressOpts = Omit< allowModelOverride: boolean; }; -/** Gateway-only ingress extends the public Plugin SDK surface with private execution correlation. */ +/** Gateway-only ingress extends the public Plugin SDK surface with private recovery correlation. */ export type AgentCommandGatewayIngressOpts = AgentCommandIngressOpts & - Pick; + Pick; diff --git a/src/agents/embedded-agent-runner/run/lane-controller.lifecycle.test.ts b/src/agents/embedded-agent-runner/run/lane-controller.lifecycle.test.ts index b14c0425c5e9..9f0ce2250c06 100644 --- a/src/agents/embedded-agent-runner/run/lane-controller.lifecycle.test.ts +++ b/src/agents/embedded-agent-runner/run/lane-controller.lifecycle.test.ts @@ -6,7 +6,6 @@ import { } from "../../../infra/agent-events.js"; import { claimAgentRunContext, getAgentRunContext } from "../../../infra/agent-run-registry.js"; import type { CommandQueueEnqueueOptions } from "../../../process/command-queue.types.js"; -import { createAgentExecutionAttribution } from "../../agent-execution-attribution.js"; import type { EmbeddedAgentRunResult } from "../types.js"; import { createEmbeddedRunLaneController } from "./lane-controller.js"; import type { RunEmbeddedAgentParams } from "./params.js"; @@ -130,41 +129,6 @@ describe("createEmbeddedRunLaneController lifecycle admission", () => { }); }); - it("rebinds admitted attribution with foreground work across lifecycle rotation", async () => { - const queue = deferredTaskQueue(); - const generation = getAgentEventLifecycleGeneration(); - const attribution = createAgentExecutionAttribution({ - runId: "attributed-across-restart", - lifecycleGeneration: generation, - sessionKey: "agent:main:session-1", - sessionId: "session-1", - agentId: "main", - }); - claimAgentRunContext(attribution.runId, { - attribution, - ...(attribution.sessionKey ? { sessionKey: attribution.sessionKey } : {}), - ...(attribution.sessionId ? { sessionId: attribution.sessionId } : {}), - ...(attribution.agentId ? { agentId: attribution.agentId } : {}), - lifecycleGeneration: generation, - }); - const state = createController({ - lifecycleGeneration: generation, - enqueue: queue.enqueue as LaneParams["enqueue"], - trigger: "user", - runId: attribution.runId, - }); - const run = state.controller.enqueueGlobal(async () => completedResult); - - const currentGeneration = rotateAgentEventLifecycleGeneration(); - queue.release(); - await run; - - expect(getAgentRunContext(attribution.runId)?.attribution).toEqual({ - ...attribution, - lifecycleGeneration: currentGeneration, - }); - }); - it("rejects background work queued across lifecycle rotation", async () => { const queue = deferredTaskQueue(); const generation = getAgentEventLifecycleGeneration(); diff --git a/src/agents/embedded-agent-runner/run/lane-controller.ts b/src/agents/embedded-agent-runner/run/lane-controller.ts index 8e37438f4239..914ab4f1c4ce 100644 --- a/src/agents/embedded-agent-runner/run/lane-controller.ts +++ b/src/agents/embedded-agent-runner/run/lane-controller.ts @@ -10,7 +10,6 @@ import { } from "../../../infra/agent-run-registry.js"; import { enqueueCommandInLane, getCommandLaneSnapshot } from "../../../process/command-queue.js"; import type { CommandQueueEnqueueOptions } from "../../../process/command-queue.types.js"; -import { rebindAgentExecutionAttribution } from "../../agent-execution-attribution.js"; import { withSessionPlacementTurnAdmission } from "../../session-placement-admission.js"; import type { EmbeddedAgentRunResult } from "../types.js"; import { @@ -169,12 +168,8 @@ export function createEmbeddedRunLaneController(opti assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration); releaseQueuedContext("admitted"); // Queue-stage rotation may rebind, but placement admitted into a retired runtime must fail. - const attribution = existingContext?.attribution - ? rebindAgentExecutionAttribution(existingContext.attribution, lifecycleGeneration) - : undefined; claimAgentRunContext(params.runId, { ...existingContext, - ...(attribution ? { attribution } : {}), sessionKey: params.sessionKey ?? existingContext?.sessionKey, sessionId: params.sessionId ?? existingContext?.sessionId, lifecycleGeneration, diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 0cbfff64ea8d..82fe17ec813b 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -498,7 +498,7 @@ describe("agentCommand", () => { ); expect(record).toHaveBeenCalledWith( - expect.objectContaining({ attribution: undefined, runId: "public-ingress-run" }), + expect.objectContaining({ admission: undefined, runId: "public-ingress-run" }), ); } finally { record.mockRestore(); diff --git a/src/gateway/server-methods/agent-dedupe-lifecycle.ts b/src/gateway/server-methods/agent-dedupe-lifecycle.ts index 236d280aeb0b..d5dd826e9acd 100644 --- a/src/gateway/server-methods/agent-dedupe-lifecycle.ts +++ b/src/gateway/server-methods/agent-dedupe-lifecycle.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { AGENT_RUN_RESTART_ABORT_STOP_REASON } from "../../agents/run-termination.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; @@ -104,27 +103,6 @@ export function createAgentDedupeLifecycle(params: { reserved = false; }; - const failBeforeDispatch = (summary: string) => { - const error = errorShape(ErrorCodes.UNAVAILABLE, summary); - const payload = { - runId: params.runId, - status: "error" as const, - summary, - }; - // A reserved request owns its idempotency keys even when admission fails. - // Persist the terminal failure so retries replay it instead of re-entering admission. - accepted = true; - setGatewayDedupeEntries({ - dedupe: params.context.dedupe, - keys: params.agentDedupeKeys, - entry: { ts: Date.now(), ok: false, payload, error }, - }); - params.respond(false, payload, error, { - runId: params.runId, - error: summary, - }); - }; - const abortForLifecycleRotation = (target?: { sessionKey?: string; agentId?: string }) => { if (params.lifecycleGeneration === getAgentEventLifecycleGeneration()) { return false; @@ -186,7 +164,6 @@ export function createAgentDedupeLifecycle(params: { reservationId, reserve, clearUnaccepted, - failBeforeDispatch, abortForLifecycleRotation, isReserved: () => reserved, isAccepted: () => accepted, diff --git a/src/gateway/server-methods/agent-dedupe.test.ts b/src/gateway/server-methods/agent-dedupe.test.ts deleted file mode 100644 index 0fb2ecf3dfc8..000000000000 --- a/src/gateway/server-methods/agent-dedupe.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isPreRegistrationAbortedAgentDedupeEntryForSession } from "./agent-dedupe.js"; - -describe("agent dedupe", () => { - it("compares admitted run identities without normalizing them", () => { - const entry = { - ts: 1, - ok: true, - payload: { - runId: " padded-agent-run ", - status: "timeout", - stopReason: "rpc", - }, - } as const; - - expect( - isPreRegistrationAbortedAgentDedupeEntryForSession({ - entry, - runId: " padded-agent-run ", - }), - ).toBe(true); - expect( - isPreRegistrationAbortedAgentDedupeEntryForSession({ - entry, - runId: "padded-agent-run", - }), - ).toBe(false); - }); -}); diff --git a/src/gateway/server-methods/agent-dedupe.ts b/src/gateway/server-methods/agent-dedupe.ts index 5f7394416347..2fa80d055885 100644 --- a/src/gateway/server-methods/agent-dedupe.ts +++ b/src/gateway/server-methods/agent-dedupe.ts @@ -72,8 +72,7 @@ export function isPreRegistrationAbortedAgentDedupeEntryForSession(params: { return false; } const payload = params.entry.payload; - const payloadRunId = - typeof payload.runId === "string" && payload.runId.trim() ? payload.runId : ""; + const payloadRunId = typeof payload.runId === "string" ? payload.runId.trim() : ""; if (payloadRunId && payloadRunId !== params.runId) { return false; } diff --git a/src/gateway/server-methods/agent-request-preflight.ts b/src/gateway/server-methods/agent-request-preflight.ts index 86c896a5bb8c..096c636f9087 100644 --- a/src/gateway/server-methods/agent-request-preflight.ts +++ b/src/gateway/server-methods/agent-request-preflight.ts @@ -73,14 +73,6 @@ export function prepareAgentRequestPreflight( return undefined; } const request = params.params as AgentRunRequest; - if (!request.idempotencyKey.trim()) { - params.respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "idempotencyKey must not be blank"), - ); - return undefined; - } const cfg = params.context.getRuntimeConfig(); const canUseInternalRuntimeHandoff = resolveCanUseInternalRuntimeHandoff(params.client); const requestSessionKey = request.sessionKey?.trim(); @@ -278,7 +270,7 @@ export function prepareAgentRequestPreflight( if (cached.ok && isAcceptedAgentDedupePayload(cached.payload)) { const cachedRunId = typeof cached.payload.runId === "string" && cached.payload.runId.trim() - ? cached.payload.runId + ? cached.payload.runId.trim() : runId; const cachedSessionKey = typeof cached.payload.sessionKey === "string" && cached.payload.sessionKey.trim() diff --git a/src/gateway/server-methods/agent-run-admission-phase.ts b/src/gateway/server-methods/agent-run-admission-phase.ts index 975ccf225eec..79ac7a7b436a 100644 --- a/src/gateway/server-methods/agent-run-admission-phase.ts +++ b/src/gateway/server-methods/agent-run-admission-phase.ts @@ -1,10 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { - createAgentExecutionAttribution, - type AgentExecutionIdentityAdmission, - type AgentExecutionAttribution, -} from "../../agents/agent-execution-attribution.js"; import { clearEmbeddedAgentRunAbortabilityForRunId, isEmbeddedAgentRunAbortableForRunId, @@ -50,7 +45,6 @@ import type { GatewayRequestHandlerOptions } from "./types.js"; export type PreparedAgentRunDispatch = { activeGatewayWorkAdmission: SessionWorkAdmissionLease; activeRunAbort: ReturnType; - attribution: AgentExecutionAttribution; effectiveProviderOverride?: string; effectiveModelOverride?: string; effectiveThinking?: string; @@ -92,7 +86,6 @@ export async function prepareAgentRunDispatch(params: { inputProvenance?: InputProvenance; isOneShotModelRun: boolean; isRestartRecoveryResumeRun: boolean; - executionIdentityAdmission?: AgentExecutionIdentityAdmission; runId: string; agentDedupeKeys: readonly string[]; context: GatewayRequestHandlerOptions["context"]; @@ -259,16 +252,6 @@ export async function prepareAgentRunDispatch(params: { }); return undefined; } - const attribution = createAgentExecutionAttribution({ - runId: params.runId, - lifecycleGeneration: params.lifecycleGeneration, - sessionKey: params.resolvedSessionKey, - sessionId: params.getAdmittedSessionId(), - agentId: params.activeSessionAgentId, - ...(params.executionIdentityAdmission - ? { executionIdentityAdmission: params.executionIdentityAdmission } - : {}), - }); if (!activeRunAbort.registered) { activeGatewayWorkAdmission.release(); } else { @@ -280,14 +263,15 @@ export async function prepareAgentRunDispatch(params: { }); } if (params.resolvedSessionKey) { - claimAgentRunContext(params.runId, { - attribution, - ...(attribution.sessionKey ? { sessionKey: attribution.sessionKey } : {}), - ...(attribution.sessionId ? { sessionId: attribution.sessionId } : {}), - ...(attribution.agentId ? { agentId: attribution.agentId } : {}), - ...(params.suppressVisibleSessionEffects ? { isControlUiVisible: false } : {}), - lifecycleGeneration: attribution.lifecycleGeneration, - }); + claimAgentRunContext( + params.runId, + params.suppressVisibleSessionEffects + ? { isControlUiVisible: false, lifecycleGeneration: params.lifecycleGeneration } + : { + sessionKey: params.resolvedSessionKey, + lifecycleGeneration: params.lifecycleGeneration, + }, + ); } } @@ -450,7 +434,6 @@ export async function prepareAgentRunDispatch(params: { return { activeGatewayWorkAdmission, activeRunAbort, - attribution, effectiveProviderOverride, effectiveModelOverride, effectiveThinking, diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index e616388e706f..d6bf0b94f9f0 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -21,6 +21,7 @@ import { } from "../../agents/main-session-recovery-store.js"; import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js"; import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; +import { isExecutionIdentityCollectionEnabled } from "../../audit/audit-config.js"; import { setChannelSourceTurnId, setChannelSourceTurnSameThreadRequired, @@ -51,7 +52,10 @@ import { type RestoredCronContinuation, } from "./agent-handler-helpers.js"; import type { AgentRunRequest } from "./agent-request-types.js"; -import { resolveAgentRestartRecoveryChannelContext } from "./agent-restart-recovery-context.js"; +import { + resolveAgentRestartRecoveryChannelContext, + resolveAgentRestartRecoveryExecutionIdentityAdmission, +} from "./agent-restart-recovery-context.js"; import type { PreparedAgentRunDispatch } from "./agent-run-admission-phase.js"; import { resolveAbortedAgentStopReason, @@ -325,6 +329,13 @@ export function startAgentRunExecution(params: { params.client.internal.runtimePluginToolGrant?.pluginId ? params.client.internal.runtimePluginToolGrant : undefined; + const executionIdentityAdmission = resolveAgentRestartRecoveryExecutionIdentityAdmission({ + collectionEnabled: isExecutionIdentityCollectionEnabled(params.cfg), + isRestartRecoveryResumeRun: params.isRestartRecoveryResumeRun, + retryOnly: params.request.internalExecutionIdentityRetry, + runId: params.runId, + sessionEntry: params.sessionEntry, + }); const restartRecoveryChannelContext = resolveAgentRestartRecoveryChannelContext({ canUseInternalRuntimeHandoff: params.canUseInternalRuntimeHandoff, expectedExistingSessionId: params.request.expectedExistingSessionId, @@ -421,7 +432,7 @@ export function startAgentRunExecution(params: { swarmOutputSchema: params.request.swarmOutputSchema, forceRestartSafeTools: params.request.forceRestartSafeTools, forceCodeModeTools: params.request.forceCodeModeTools, - executionAttribution: prepared.attribution, + ...(executionIdentityAdmission ? { executionIdentityAdmission } : {}), internalDeliveryMediaUrls: params.client?.internal?.internalDeliveryMediaUrls, internalDeliverySuppressText: params.client?.internal?.internalDeliverySuppressText, suppressPromptPersistence: diff --git a/src/gateway/server-methods/agent-run-handler.ts b/src/gateway/server-methods/agent-run-handler.ts index ea24b5d328ee..eb21fde5fa84 100644 --- a/src/gateway/server-methods/agent-run-handler.ts +++ b/src/gateway/server-methods/agent-run-handler.ts @@ -5,7 +5,6 @@ import { releaseMainSessionRecoveryOwner, type MainSessionRecoveryOwnerLease, } from "../../agents/main-session-recovery-store.js"; -import { isExecutionIdentityCollectionEnabled } from "../../audit/audit-config.js"; import { mergeSessionEntry, type SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; @@ -20,7 +19,6 @@ import type { RestoredCronContinuation } from "./agent-handler-helpers.js"; import { prepareAgentRequestPreflight } from "./agent-request-preflight.js"; import { prepareAgentRequestRouting } from "./agent-request-routing.js"; import { runAgentResetPhase } from "./agent-reset-phase.js"; -import { resolveAgentRestartRecoveryExecutionIdentityAdmission } from "./agent-restart-recovery-context.js"; import { prepareAgentRunDispatch } from "./agent-run-admission-phase.js"; import { startAgentRunExecution } from "./agent-run-execution-phase.js"; import { buildAgentSessionPatch } from "./agent-session-patch.js"; @@ -434,19 +432,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ return; } const { activeSessionAgentId } = delivery; - let executionIdentityAdmission; - try { - executionIdentityAdmission = resolveAgentRestartRecoveryExecutionIdentityAdmission({ - collectionEnabled: isExecutionIdentityCollectionEnabled(cfg), - isRestartRecoveryResumeRun, - retryOnly: request.internalExecutionIdentityRetry, - runId, - sessionEntry, - }); - } catch (error) { - dedupeLifecycle.failBeforeDispatch(formatForLog(error)); - return; - } const preparedDispatch = await prepareAgentRunDispatch({ request, @@ -472,7 +457,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ inputProvenance, isOneShotModelRun, isRestartRecoveryResumeRun, - executionIdentityAdmission, runId, agentDedupeKeys, context, diff --git a/src/gateway/server-methods/agent.abort-integration.test-utils.ts b/src/gateway/server-methods/agent.abort-integration.test-utils.ts index 1c5528c8ac91..ed95a50fa14c 100644 --- a/src/gateway/server-methods/agent.abort-integration.test-utils.ts +++ b/src/gateway/server-methods/agent.abort-integration.test-utils.ts @@ -2309,99 +2309,6 @@ describe("gateway agent handler chat.abort integration", () => { ).toBe(true); }); - it("replays restart recovery identity admission failures for the same idempotency key", async () => { - const sessionKey = "agent:main:main"; - const sessionId = "recovery-session"; - const runId = "recovery-identity-token-unavailable"; - const storePath = "/tmp/sessions.json"; - const store: Record = { - [sessionKey]: { - sessionId, - updatedAt: Date.now() - 10_000, - status: "running", - abortedLastRun: true, - mainRestartRecovery: { - cycleId: "cycle-1", - revision: 1, - chargedAttempts: 1, - reservation: { - runId, - attempt: 1, - lifecycleGeneration: "test-generation", - }, - }, - }, - }; - mocks.loadConfigReturn = { - logging: { - audit: { - enabled: true, - executionIdentity: true, - }, - }, - }; - mocks.loadSessionEntry.mockImplementation(() => ({ - cfg: mocks.loadConfigReturn, - storePath, - entry: structuredClone(store[sessionKey]), - canonicalKey: sessionKey, - })); - mocks.updateSessionStore.mockImplementation(async (_path, updater) => await updater(store)); - - const context = makeContext(); - const request = { - message: "resume after restart", - agentId: "main", - sessionKey, - sessionId, - expectedExistingSessionId: sessionId, - idempotencyKey: runId, - internalExecutionIdentityRetry: false, - inputProvenance: { - kind: "internal_system" as const, - sourceSessionKey: sessionKey, - sourceTool: "main_session_restart_recovery", - }, - }; - const firstRespond = vi.fn(); - await invokeAgent(request, { - client: backendGatewayClient(), - context, - reqId: runId, - respond: firstRespond, - }); - - const summary = "Error: restart recovery execution identity token is unavailable"; - expect(firstRespond).toHaveBeenCalledWith( - false, - { runId, status: "error", summary }, - expect.objectContaining({ code: "UNAVAILABLE", message: summary }), - { runId, error: summary }, - ); - expect(context.dedupe.get(`agent:${runId}`)).toMatchObject({ - ok: false, - payload: { runId, status: "error", summary }, - error: { code: "UNAVAILABLE", message: summary }, - }); - expect(mocks.agentCommand).not.toHaveBeenCalled(); - - const secondRespond = vi.fn(); - await invokeAgent(request, { - client: backendGatewayClient(), - context, - reqId: `${runId}-retry`, - respond: secondRespond, - }); - - expect(secondRespond).toHaveBeenCalledWith( - false, - { runId, status: "error", summary }, - expect.objectContaining({ code: "UNAVAILABLE", message: summary }), - { cached: true }, - ); - expect(mocks.agentCommand).not.toHaveBeenCalled(); - }); - it("releases a foreground recovery owner if pre-dispatch reactivation fails", async () => { const sessionKey = "agent:main:main"; const sessionId = "interrupted-session"; diff --git a/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts b/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts index af33c64d1e8d..98c78c44e5c7 100644 --- a/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts +++ b/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts @@ -301,71 +301,10 @@ describe("gateway agent handler", () => { expect(callArgs.suppressPromptPersistence).toBe(true); expect(mocks.updateSessionStore).not.toHaveBeenCalled(); expect(context.addChatRun).not.toHaveBeenCalled(); - const runContext = mockCallArg(mocks.registerAgentRunContext, 0, 1) as { - attribution: Record; - }; - expect(runContext).toEqual({ - attribution: expect.objectContaining({ - runId: "test-backend-internal-effects", - contextId: expect.any(String), - executionId: expect.any(String), - createdAt: expect.any(Number), - lifecycleGeneration: "test-generation", - sessionKey: "agent:main:main", - sessionId: "existing-session-id", - agentId: "main", - }), - sessionKey: "agent:main:main", - sessionId: "existing-session-id", - agentId: "main", + expect(mocks.registerAgentRunContext).toHaveBeenCalledWith("test-backend-internal-effects", { isControlUiVisible: false, lifecycleGeneration: "test-generation", }); - expect(Object.isFrozen(runContext.attribution)).toBe(true); - }); - - it("preserves the admitted idempotency key exactly in execution attribution", async () => { - primeMainAgentRun({ cfg: mocks.loadConfigReturn }); - mocks.registerAgentRunContext.mockClear(); - const runId = " padded-agent-run "; - - await invokeAgent({ - message: "preserve exact run identity", - agentId: "main", - sessionKey: "agent:main:main", - idempotencyKey: runId, - }); - - await waitForAgentCommandCall(); - expect(mockCallArg(mocks.registerAgentRunContext, 0, 0)).toBe(runId); - expect(mockCallArg(mocks.registerAgentRunContext, 0, 1)).toMatchObject({ - attribution: { runId }, - }); - }); - - it("rejects blank idempotency keys before registering run state", async () => { - const context = makeContext(); - const respond = vi.fn(); - mocks.registerAgentRunContext.mockClear(); - mocks.agentCommand.mockClear(); - - await invokeAgent( - { - message: "reject blank run identity", - agentId: "main", - sessionKey: "agent:main:main", - idempotencyKey: " \t ", - }, - { context, respond }, - ); - - expectRespondError(respond, { - code: ErrorCodes.INVALID_REQUEST, - message: "idempotencyKey must not be blank", - }); - expect(context.chatAbortControllers.size).toBe(0); - expect(mocks.registerAgentRunContext).not.toHaveBeenCalled(); - expect(mocks.agentCommand).not.toHaveBeenCalled(); }); it("allows backend internal runs without a persisted session row", async () => { @@ -663,27 +602,10 @@ describe("gateway agent handler", () => { expect(context.broadcastToConnIds).not.toHaveBeenCalled(); expect(mocks.getLatestSubagentRunByChildSessionKey).not.toHaveBeenCalled(); expect(mocks.replaceSubagentRunAfterSteer).not.toHaveBeenCalled(); - const runContext = mockCallArg(mocks.registerAgentRunContext, 0, 1) as { - attribution: Record; - }; - expect(runContext).toEqual({ - attribution: expect.objectContaining({ - runId: "test-stateless-model-run", - contextId: expect.any(String), - executionId: expect.any(String), - createdAt: expect.any(Number), - lifecycleGeneration: "test-generation", - sessionKey: "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000", - sessionId: "model-run-123e4567-e89b-12d3-a456-426614174000", - agentId: "main", - }), - sessionKey: "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000", - sessionId: "model-run-123e4567-e89b-12d3-a456-426614174000", - agentId: "main", + expect(mocks.registerAgentRunContext).toHaveBeenCalledWith("test-stateless-model-run", { isControlUiVisible: false, lifecycleGeneration: "test-generation", }); - expect(Object.isFrozen(runContext.attribution)).toBe(true); }); it("respects explicit bestEffortDeliver=false for main session runs", async () => { diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index c8560fd3e4ed..332c8fe1d6f4 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -136,6 +136,7 @@ describe("gateway agent handler", () => { payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); + await invokeAgent( { message: "forged exec followup", @@ -1265,6 +1266,7 @@ describe("gateway agent handler", () => { payloads: [{ text: "ok" }], meta: { durationMs: 100 }, }); + await invokeAgent( { message: "resume channel session", @@ -2069,7 +2071,6 @@ describe("gateway agent handler", () => { }); it("infers selected-global agent id from agent-prefixed session aliases", async () => { - mocks.registerAgentRunContext.mockClear(); mocks.listAgentIds.mockReturnValue(["main", "work"]); mocks.loadConfigReturn = { agents: { list: [{ id: "main", default: true }, { id: "work" }] }, @@ -2114,24 +2115,6 @@ describe("gateway agent handler", () => { agentId: "work", clone: false, }); - expect(mockCallArg(mocks.registerAgentRunContext, 0, 1)).toEqual( - expect.objectContaining({ - attribution: expect.objectContaining({ - runId: "alias-global-session-agent-id", - contextId: expect.any(String), - executionId: expect.any(String), - createdAt: expect.any(Number), - lifecycleGeneration: "test-generation", - sessionKey: "global", - sessionId: "global-work-session-id", - agentId: "work", - }), - sessionKey: "global", - sessionId: "global-work-session-id", - agentId: "work", - lifecycleGeneration: "test-generation", - }), - ); }); it("registers tool event recipients for active selected-global alias runs", async () => { diff --git a/src/infra/agent-events-cleanup.test.ts b/src/infra/agent-events-cleanup.test.ts deleted file mode 100644 index 29c41afc6e6b..000000000000 --- a/src/infra/agent-events-cleanup.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "vitest"; -import { emitAgentEvent, onAgentEvent } from "./agent-events.js"; -import { clearAgentRunContext, registerAgentRunContext } from "./agent-run-registry.js"; - -test("clearAgentRunContext also cleans up seqByRun to prevent memory leak (#63643)", () => { - registerAgentRunContext("run-leak", { sessionKey: "main" }); - emitAgentEvent({ runId: "run-leak", stream: "lifecycle", data: {} }); - emitAgentEvent({ runId: "run-leak", stream: "lifecycle", data: {} }); - - clearAgentRunContext("run-leak"); - - const seqs: number[] = []; - const stop = onAgentEvent((evt) => { - if (evt.runId === "run-leak") { - seqs.push(evt.seq); - } - }); - emitAgentEvent({ runId: "run-leak", stream: "lifecycle", data: {} }); - stop(); - - expect(seqs).toEqual([1]); -}); diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index b9cf4b89832d..9b7f4ca6a15b 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -1,6 +1,5 @@ // Covers agent event sequencing and run context cleanup. import { beforeEach, describe, expect, test, vi } from "vitest"; -import { createAgentExecutionAttribution } from "../agents/agent-execution-attribution.js"; import { type AgentEventPayload, captureAgentRunLifecycleGeneration, @@ -898,45 +897,6 @@ describe("agent-events sequencing", () => { expect(context?.lastActiveAt).toBe(12_345); }); - test("keeps the first same-generation attribution private and immutable", () => { - const attribution = createAgentExecutionAttribution({ - runId: "run-ctx", - lifecycleGeneration: getAgentEventLifecycleGeneration(), - sessionKey: "agent:main:main", - sessionId: "session-1", - agentId: "main", - }); - const replacement = createAgentExecutionAttribution({ - runId: "run-ctx", - lifecycleGeneration: attribution.lifecycleGeneration, - sessionKey: "agent:main:other", - sessionId: "session-2", - agentId: "main", - }); - registerAgentRunContext("run-ctx", { - attribution, - lifecycleGeneration: attribution.lifecycleGeneration, - }); - registerAgentRunContext("run-ctx", { - attribution: replacement, - lifecycleGeneration: attribution.lifecycleGeneration, - verboseLevel: "full", - }); - - expect(getAgentRunContext("run-ctx")?.attribution).toBe(attribution); - expect(getAgentRunContext("run-ctx")?.verboseLevel).toBe("full"); - expect(Reflect.set(getAgentRunContext("run-ctx")!, "attribution", replacement)).toBe(false); - - let received: AgentEventPayload | undefined; - const stop = onAgentEvent((event) => { - received = event; - }); - emitAgentEvent({ runId: "run-ctx", stream: "lifecycle", data: { phase: "end" } }); - stop(); - - expect(JSON.stringify(received)).not.toContain("attribution"); - }); - test("falls back to registered sessionKey when event sessionKey is blank", () => { registerAgentRunContext("run-ctx", { sessionKey: "session-main" }); @@ -1157,3 +1117,27 @@ describe("agent-events sequencing", () => { clock.mockRestore(); }); }); + +test("clearAgentRunContext also cleans up seqByRun to prevent memory leak (#63643)", () => { + // Regression test: seqByRun entries were never deleted when a run ended, + // causing unbounded growth over time. + registerAgentRunContext("run-leak", { sessionKey: "main" }); + emitAgentEvent({ runId: "run-leak", stream: "lifecycle", data: {} }); + emitAgentEvent({ runId: "run-leak", stream: "lifecycle", data: {} }); + + // After clearing run context, the sequence counter should also be removed. + clearAgentRunContext("run-leak"); + + // Emitting a new event on the same runId should start seq from 1 again, + // proving the old entry was deleted. + const seqs: number[] = []; + const stop = onAgentEvent((evt) => { + if (evt.runId === "run-leak") { + seqs.push(evt.seq); + } + }); + emitAgentEvent({ runId: "run-leak", stream: "lifecycle", data: {} }); + stop(); + + expect(seqs).toEqual([1]); +}); diff --git a/src/infra/agent-run-registry.ts b/src/infra/agent-run-registry.ts index d924dd9f617f..1b7146c02855 100644 --- a/src/infra/agent-run-registry.ts +++ b/src/infra/agent-run-registry.ts @@ -1,14 +1,11 @@ // Owns process-local agent run context, ownership, and projection state. import { randomUUID } from "node:crypto"; -import type { AgentExecutionAttribution } from "../agents/agent-execution-attribution.js"; import type { VerboseLevel } from "../auto-reply/thinking.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { clearAgentRunUsage, resetAgentRunUsageForTest } from "./agent-run-usage.js"; /** Per-run metadata used to stamp events and gate Control UI visibility. */ type AgentRunContext = { - /** Immutable admission-owned correlation; later context merges cannot replace it. */ - readonly attribution?: AgentExecutionAttribution; sessionKey?: string; /** Resolved agent owner, including for unscoped session keys. */ agentId?: string; @@ -66,35 +63,6 @@ function bumpAgentRunIndexVersion(): void { getAgentRunRegistryState().version += 1; } -function attachAgentExecutionAttribution( - context: AgentRunContext, - attribution: AgentExecutionAttribution | undefined, -): void { - if (!attribution || context.attribution) { - return; - } - Object.defineProperty(context, "attribution", { - value: attribution, - enumerable: true, - configurable: false, - writable: false, - }); -} - -function createAgentRunContext( - context: AgentRunContext, - lifecycleGeneration: string, -): AgentRunContext { - const { attribution, ...fields } = context; - const stored: AgentRunContext = { - ...fields, - lifecycleGeneration, - registeredAt: context.registeredAt ?? Date.now(), - }; - attachAgentExecutionAttribution(stored, attribution); - return stored; -} - /** Reads the process-local version of the active-run projection inputs. */ export function readAgentRunIndexVersion(): number { return getAgentRunRegistryState().version; @@ -137,7 +105,11 @@ export function registerAgentRunContext( } const existing = state.contexts.get(runId); if (!existing) { - state.contexts.set(runId, createAgentRunContext(context, lifecycleGeneration)); + state.contexts.set(runId, { + ...context, + lifecycleGeneration, + registeredAt: context.registeredAt ?? Date.now(), + }); bumpAgentRunIndexVersion(); return; } @@ -148,7 +120,6 @@ export function registerAgentRunContext( ) { return; } - attachAgentExecutionAttribution(existing, context.attribution); let runIndexChanged = false; if (context.sessionKey && existing.sessionKey !== context.sessionKey) { existing.sessionKey = context.sessionKey; @@ -270,7 +241,11 @@ export function claimAgentRunContext( } return claimId; } - state.contexts.set(runId, createAgentRunContext(context, lifecycleGeneration)); + state.contexts.set(runId, { + ...context, + lifecycleGeneration, + registeredAt: context.registeredAt ?? Date.now(), + }); state.sequenceResetHandler?.(runId); clearAgentRunUsage(runId); bumpAgentRunIndexVersion(); diff --git a/src/plugin-sdk/agent-runtime-ingress-contract.test.ts b/src/plugin-sdk/agent-runtime-ingress-contract.test.ts index bf92249a6836..933a44ced109 100644 --- a/src/plugin-sdk/agent-runtime-ingress-contract.test.ts +++ b/src/plugin-sdk/agent-runtime-ingress-contract.test.ts @@ -7,13 +7,13 @@ const optionalRunIdCaller: PublicIngressOptions = { sessionKey: "agent:main:plugin-session", allowModelOverride: false, }; -const privateExecutionCorrelationIsHidden: "executionAttribution" extends keyof PublicIngressOptions +const privateRecoveryCorrelationIsHidden: "executionIdentityAdmission" extends keyof PublicIngressOptions ? false : true = true; describe("public agent ingress correlation contract", () => { it("keeps runId optional and private execution recovery state unavailable", () => { expect(optionalRunIdCaller).not.toHaveProperty("runId"); - expect(privateExecutionCorrelationIsHidden).toBe(true); + expect(privateRecoveryCorrelationIsHidden).toBe(true); }); });