fix: preserve authenticated chat identity in execution audits (#159843)

* fix: preserve authenticated chat identity in execution audits

* refactor: finish reply admission owner cutover
This commit is contained in:
Josh Avant
2026-09-27 15:37:02 -05:00
committed by GitHub
parent 877ea98750
commit bf636cc1bc
15 changed files with 734 additions and 82 deletions
+7
View File
@@ -203,6 +203,13 @@ remain unattributed. If authenticated user evidence promises a durable profile
but profile resolution fails, the invoker is `unknown` rather than guessed from
headers, device ids, connection ids, or credentials.
Control UI `chat.send`, including queued replies and ACP turns, carries the
original prepared attach facts into execution admission. Later profile changes
or reconnects do not rewrite an admitted context. Collected replies retain
attribution only when every source supplies equivalent attach facts; conflicting
or missing evidence remains unknown. These facts do not grant sender or tool
authority and do not create a channel-admission enforcement receipt.
Each present context projects one run-admission receipt. Its outcome
is `not-applicable`, its policy and grant references are empty, and its reason
states that no identity-aware policy or grant evaluation was proven. This is
@@ -0,0 +1,142 @@
import { beforeEach, expect, it, vi } from "vitest";
import type {
AcpRunTurnInput,
AcpSessionResolution,
} from "../../acp/control-plane/manager.types.js";
import {
configureExecutionIdentityAdmissionSink,
type ExecutionIdentityAdmissionWork,
} from "../../audit/execution-identity-admission.js";
import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "../../gateway/local-user-ingress.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { handleAcpCommand } from "./commands-acp.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
import { runDispatch } from "./dispatch-acp.test-support.js";
import { buildTestCtx } from "./test-ctx.js";
import { createAcpSessionMeta, createAcpTestConfig } from "./test-fixtures/acp-runtime.js";
const manager = vi.hoisted(() => ({
resolveSessionAsync: vi.fn<() => Promise<AcpSessionResolution>>(),
runTurn: vi.fn<(input: AcpRunTurnInput) => Promise<void>>(),
getObservabilitySnapshot: () => ({
turns: { queueDepth: 0 },
runtimeCache: { activeSessions: 0 },
}),
}));
vi.mock("../../acp/control-plane/manager.js", () => ({
getAcpSessionManager: () => manager,
}));
vi.mock("./dispatch-acp-transcript.runtime.js", () => ({
persistAcpDispatchTranscript: async () => undefined,
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({ resolveByConversationAsync: async () => null }),
}));
const sessionKey = "agent:main:acp:attach-identity";
const cfg = createAcpTestConfig({ logging: { audit: { executionIdentity: true } } });
beforeEach(() => {
manager.resolveSessionAsync.mockReset().mockResolvedValue({
kind: "ready",
sessionKey,
agentId: "main",
meta: createAcpSessionMeta(),
});
manager.runTurn.mockReset().mockImplementation(async ({ onEvent }) => {
await onEvent?.({ type: "done" });
});
});
it.each(["message", "steer"] as const)(
"retains the original Gateway attach identity through ACP %s admission",
async (mode) => {
const captured: ExecutionIdentityAdmissionWork[] = [];
const clearSink = configureExecutionIdentityAdmissionSink((work) => {
captured.push(work);
return true;
});
const body = mode === "message" ? "keep going" : "/acp steer keep going";
const ctx = buildTestCtx({
Body: body,
CommandBody: body,
CommandAuthorized: true,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,
SessionKey: sessionKey,
GatewayClientScopes: ["operator.admin"],
});
attachGatewayLocalUserIngress(
ctx,
prepareGatewayLocalUserIngress({
authMethod: "token",
authenticatedUserExpected: true,
profile: { profileId: "original-person", displayName: "Original Person" },
isLocalClient: false,
}),
);
ctx.SenderId = "spoofed-person";
ctx.SenderName = "Spoofed Person";
try {
if (mode === "message") {
const recordProcessed = vi.fn();
await runDispatch({
bodyForAgent: body,
cfg,
ctx,
sessionKeyOverride: sessionKey,
recordProcessed,
});
expect(recordProcessed).toHaveBeenCalledWith("completed", { reason: "acp_dispatch" });
} else {
const params = buildCommandTestParams(body, cfg, ctx);
params.sessionKey = sessionKey;
// Command processing copies public fields; attach evidence remains on the original context.
params.rootCtx = ctx;
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain(`ACP steer sent to ${sessionKey}`);
}
expect(manager.runTurn).toHaveBeenCalledTimes(1);
expect(manager.runTurn.mock.calls[0]?.[0]).toMatchObject({
mode: mode === "message" ? "prompt" : "steer",
text: "keep going",
});
expect(captured).toMatchObject([
{
kind: "capture",
envelope: {
runtime: { kind: "acp" },
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
},
invoker: {
state: "present",
kind: "person",
rawPrincipalRef: "original-person",
displayLabel: "Original Person",
},
assurance: [
{
kind: "durable-profile",
rawEvidenceRef: "original-person",
strength: "boundary-verified",
},
],
},
},
]);
} finally {
clearSink();
}
},
);
@@ -497,6 +497,7 @@ async function executeAgentTurnInternal(
boundary: "auto-reply.agent-runner",
operatorAuthority: params.followupRun.operatorAuthority,
evidence: params.followupRun.channelAdmissionEvidence,
gatewayLocalUserIngress: params.followupRun.gatewayLocalUserIngress,
assertSourceCurrent:
params.followupRun.run.senderIsOwner === true
? captureCommandOwnerAssertion(params.followupRun.run)
@@ -6,20 +6,88 @@ import {
} from "../../agents/admitted-run-context.js";
import { createAgentHarnessHostCapabilities } from "../../agents/harness/host-capability.js";
import { getGatewayToolCallerIdentity } from "../../agents/tools/gateway-caller-context.js";
import { configureExecutionIdentityAdmissionSink } from "../../audit/execution-identity-admission.js";
import {
configureExecutionIdentityAdmissionSink,
type ExecutionIdentityAdmissionWork,
} from "../../audit/execution-identity-admission.js";
import {
combineChannelAdmissionEvidence,
createChannelAdmissionAudit,
consumeChannelAdmissionEvidence,
} from "../../channels/message-access/admission-evidence.js";
import { prepareGatewayLocalUserIngress } from "../../gateway/local-user-ingress.js";
import type { GatewayRequestContext } from "../../gateway/server-methods/types.js";
import { resetAgentRunRegistryForTest } from "../../infra/agent-run-registry.js";
import { bindGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
import { consumeChannelRunAdmission, prepareChannelRunAdmission } from "./channel-run-admission.js";
import { prepareChannelRunAdmission } from "./channel-run-admission.js";
const identityConfig = { logging: { audit: { executionIdentity: true } } } as const;
describe("channel run admission", () => {
it.each(["profileless", "unresolved", "copied", "forged"] as const)(
"records only owner-prepared Gateway facts for a %s carrier",
async (kind) => {
const identityWork: ExecutionIdentityAdmissionWork[] = [];
const clearIdentitySink = configureExecutionIdentityAdmissionSink((work) => {
identityWork.push(work);
return true;
});
const ingress = prepareGatewayLocalUserIngress({
authMethod: "token",
authenticatedUserExpected: kind !== "profileless",
...(kind === "copied" ? { profile: { profileId: "copied-person" } } : {}),
isLocalClient: false,
});
const gatewayLocalUserIngress =
kind === "copied"
? { ...ingress }
: kind === "forged"
? {
get facts(): typeof ingress.facts {
throw new Error("Unminted Gateway facts must not be read");
},
}
: ingress;
const prepared = prepareChannelRunAdmission({
cfg: identityConfig,
runId: `gateway-${kind}`,
agentId: "main",
ingressKind: "channel",
boundary: "auto-reply.agent-runner",
gatewayLocalUserIngress,
});
try {
await prepared.admit("embedded");
expect(identityWork).toHaveLength(1);
const captured = identityWork[0];
expect(captured?.kind).toBe("capture");
if (captured?.kind !== "capture") {
throw new Error("Expected the admitted identity envelope");
}
expect(captured.envelope.ingress).toEqual(
kind === "profileless" || kind === "unresolved"
? {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
}
: { kind: "channel", boundary: "auto-reply.agent-runner", state: "unknown" },
);
if (kind === "profileless") {
expect(captured.envelope).not.toHaveProperty("invoker");
} else {
expect(captured.envelope.invoker).toEqual({ state: "unknown" });
}
expect(captured.envelope.assurance).not.toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "durable-profile" })]),
);
} finally {
prepared.close();
clearIdentitySink();
}
},
);
it("rejects a retired Gateway binding before host tool I/O", async () => {
const current: { value?: GatewayRequestContext } = {};
const prepared = prepareChannelRunAdmission({
@@ -94,31 +162,6 @@ describe("channel run admission", () => {
}
});
it("projects a hardened channel handoff as boundary-verified assurance", () => {
const audit = createChannelAdmissionAudit({ enabled: true });
const clearCollection = () => audit.close();
try {
const evidence = createChannelParticipantAdmissionEvidence({
audit,
channelId: "test",
participantId: "person-1",
});
expect(consumeChannelRunAdmission(evidence).facts).toMatchObject({
invoker: { state: "present", kind: "person" },
assurance: [
{
kind: "channel-admission",
rawEvidenceRef: "channel-admission",
strength: "boundary-verified",
},
],
});
} finally {
clearCollection();
}
});
it("consumes once across fallback admission and closes the exact prepared owner", async () => {
const identityWork: unknown[] = [];
const decisions: unknown[] = [];
@@ -157,7 +200,21 @@ describe("channel run admission", () => {
const fallback = await prepared.admit("embedded");
expect(fallback).toBe(first);
expect(identityWork).toHaveLength(1);
expect(identityWork).toMatchObject([
{
kind: "capture",
envelope: {
invoker: { state: "present", kind: "person" },
assurance: [
{
kind: "channel-admission",
rawEvidenceRef: "channel-admission",
strength: "boundary-verified",
},
],
},
},
]);
expect(decisions).toHaveLength(1);
expect(admittedContexts).toEqual([first]);
expect(decisions).toMatchObject([
+24 -4
View File
@@ -12,13 +12,29 @@ import {
type ChannelAdmissionEvidence,
} from "../../channels/message-access/admission-evidence.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
readGatewayLocalUserIngressFacts,
type GatewayLocalUserIngress,
} from "../../gateway/local-user-ingress.js";
/** Adapt one opaque channel carrier to the canonical admitted-run facts and decision FIFO. */
export function consumeChannelRunAdmission(evidence: ChannelAdmissionEvidence | undefined): {
/** Adapt reply ingress to admission; authenticated Gateway attach has no plugin-channel decision. */
function consumeChannelRunAdmission(
evidence: ChannelAdmissionEvidence | undefined,
gatewayLocalUserIngress?: GatewayLocalUserIngress,
): {
ingressState: ExecutionIdentityAdmissionFacts["ingress"]["state"];
facts: Pick<ExecutionIdentityAdmissionFacts, "invoker" | "assurance">;
facts: Pick<ExecutionIdentityAdmissionFacts, "invoker" | "assurance"> &
Partial<Pick<ExecutionIdentityAdmissionFacts, "ingress">>;
onAdmitted: (context: AdmittedRunContext) => void;
} {
const gatewayFacts = readGatewayLocalUserIngressFacts(gatewayLocalUserIngress);
if (gatewayFacts) {
return Object.freeze({
ingressState: gatewayFacts.ingress.state,
facts: gatewayFacts,
onAdmitted: () => undefined,
});
}
const admission = consumeChannelAdmissionEvidence(evidence);
return Object.freeze({
ingressState: admission.ingressState,
@@ -62,6 +78,7 @@ export function prepareChannelRunAdmission(params: {
ingressKind: ExecutionIdentityAdmissionFacts["ingress"]["kind"];
boundary: string;
evidence?: ChannelAdmissionEvidence;
gatewayLocalUserIngress?: GatewayLocalUserIngress;
assertSourceCurrent?: () => void;
operatorAuthority?: AdmittedRunOperatorAuthority;
onAdmitted?: (context: AdmittedRunContext) => void;
@@ -92,7 +109,10 @@ export function prepareChannelRunAdmission(params: {
return Promise.reject(new Error("prepared execution context is already closed"));
}
if (!prepared) {
const channelAdmission = consumeChannelRunAdmission(params.evidence);
const channelAdmission = consumeChannelRunAdmission(
params.evidence,
params.gatewayLocalUserIngress,
);
prepared = prepareAgentRunAdmission({
cfg: params.cfg,
assertSourceCurrent: params.assertSourceCurrent,
+15 -20
View File
@@ -14,11 +14,7 @@ import {
} from "../../../acp/policy.js";
import { toAcpRuntimeErrorText } from "../../../acp/runtime/errors.js";
import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta.js";
import {
closeAdmittedRunDelegatedAuthority,
createOperationalRunInstanceRef,
prepareAgentRunAdmission,
} from "../../../agents/admitted-run-context.js";
import { closeAdmittedRunDelegatedAuthority } from "../../../agents/admitted-run-context.js";
import { resolveSpawnedWorkspaceInheritance } from "../../../agents/spawned-context.js";
import {
resolveAcpSpawnRuntimePolicyError,
@@ -31,9 +27,13 @@ import {
import { updateSessionEntry } from "../../../config/sessions/session-accessor.js";
import type { SessionAcpMeta, SessionEntry } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
getGatewayLocalUserIngress,
type GatewayLocalUserIngress,
} from "../../../gateway/local-user-ingress.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { consumeChannelRunAdmission } from "../channel-run-admission.js";
import { prepareChannelRunAdmission } from "../channel-run-admission.js";
import { commandReply } from "../command-gates.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
@@ -373,25 +373,19 @@ async function runAcpSteer(params: {
instruction: string;
requestId: string;
channelAdmissionEvidence?: ChannelAdmissionEvidence;
gatewayLocalUserIngress?: GatewayLocalUserIngress;
}): Promise<string> {
const acpManager = getAcpSessionManager();
let output = "";
const channelAdmission = consumeChannelRunAdmission(params.channelAdmissionEvidence);
const admittedRunContext = await prepareAgentRunAdmission({
const admittedRunContext = await prepareChannelRunAdmission({
assertSourceCurrent: params.assertOwnerCurrent,
cfg: params.cfg,
operationalRunInstance: createOperationalRunInstanceRef(params.requestId),
facts: {
runId: params.requestId,
agentId: params.agentId,
ingress: {
kind: "acp",
boundary: "acp.command.steer",
state: channelAdmission.ingressState,
},
...channelAdmission.facts,
},
onAdmitted: channelAdmission.onAdmitted,
runId: params.requestId,
agentId: params.agentId,
ingressKind: "acp",
boundary: "acp.command.steer",
evidence: params.channelAdmissionEvidence,
gatewayLocalUserIngress: params.gatewayLocalUserIngress,
}).admit("acp");
try {
@@ -474,6 +468,7 @@ export async function handleAcpSteerAction(
instruction: parsed.value.instruction,
requestId: `${resolveCommandRequestId(params)}:steer`,
channelAdmissionEvidence: readChannelContextAdmissionEvidence(params.rootCtx ?? params.ctx),
gatewayLocalUserIngress: getGatewayLocalUserIngress(params.rootCtx ?? params.ctx),
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP steer failed before completion.",
+9 -19
View File
@@ -17,9 +17,7 @@ import {
} from "../../acp/runtime/errors.js";
import {
closeAdmittedRunDelegatedAuthority,
createOperationalRunInstanceRef,
getAdmittedRunDelegatedAuthority,
prepareAgentRunAdmission,
type AdmittedRunContext,
} from "../../agents/admitted-run-context.js";
import { buildAgentRunTerminalOutcomeFromLifecycleEvent } from "../../agents/agent-run-terminal-outcome.js";
@@ -41,6 +39,7 @@ import { resolveSessionStorePathCore } from "../../config/sessions/paths.js";
import type { PrepareAssistantTranscriptMessage } from "../../config/sessions/transcript-assistant-delivery.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { getGatewayLocalUserIngress } from "../../gateway/local-user-ingress.js";
import { logVerbose } from "../../globals.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -72,7 +71,7 @@ import {
resolveAgentTurnAttachments,
resolveInlineAgentImageAttachments,
} from "./agent-turn-attachments.js";
import { consumeChannelRunAdmission } from "./channel-run-admission.js";
import { prepareChannelRunAdmission } from "./channel-run-admission.js";
import {
createAcpDispatchDeliveryCoordinator,
type AcpDispatchDeliveryCoordinator,
@@ -763,23 +762,14 @@ export async function tryDispatchAcpReplyCore(params: {
logVerbose(`dispatch-acp: start reply lifecycle failed: ${formatErrorMessage(error)}`);
}
const channelAdmission = consumeChannelRunAdmission(
readChannelContextAdmissionEvidence(params.ctx),
);
admittedRunContext = await prepareAgentRunAdmission({
admittedRunContext = await prepareChannelRunAdmission({
cfg: params.cfg,
operationalRunInstance: createOperationalRunInstanceRef(requestId),
facts: {
runId: requestId,
agentId: acpAgentId,
ingress: {
kind: "acp",
boundary: "auto-reply.acp",
state: channelAdmission.ingressState,
},
...channelAdmission.facts,
},
onAdmitted: channelAdmission.onAdmitted,
runId: requestId,
agentId: acpAgentId,
ingressKind: "acp",
boundary: "auto-reply.acp",
evidence: readChannelContextAdmissionEvidence(params.ctx),
gatewayLocalUserIngress: getGatewayLocalUserIngress(params.ctx),
}).admit("acp");
recordAcceptedSessionParticipantInput(params.ctx, participantTarget);
const turnAdmission = admittedRunContext;
@@ -24,6 +24,7 @@ import { getRuntimeConfig } from "../../config/config.js";
import { conversationIdentityFromMsgContext } from "../../config/sessions/conversation-identity.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
import { sessionPersonalProfileId } from "../../config/sessions/session-entry-provenance.js";
import { getGatewayLocalUserIngress } from "../../gateway/local-user-ingress.js";
import { normalizeMediaFacts } from "../../media/media-facts.js";
import { normalizeAccountId } from "../../routing/account-id.js";
import { isSessionPersonalBootstrapTurn } from "../../sessions/session-participant-input.js";
@@ -400,6 +401,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
...(userTurnTranscriptRecorder ? { userTurnTranscriptRecorder } : {}),
currentInboundEventKind: inboundEventKind,
currentInboundAudio: hasInboundAudio(sessionCtx),
gatewayLocalUserIngress: getGatewayLocalUserIngress(ctx),
channelAdmissionEvidence:
readChannelContextAdmissionEvidence(ctx) ?? readChannelContextAdmissionEvidence(sessionCtx),
currentInboundContext,
@@ -1,21 +1,31 @@
// Prompt metadata carrier tests cover collect batching, deferral, and retry identity.
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createChannelParticipantAdmissionEvidence } from "../../../test/helpers/channel-admission-evidence.js";
import { createDeferred } from "../../../test/helpers/promise.js";
import {
attachToolAllowlistIntersection,
readToolAllowlistIntersection,
} from "../../agents/tool-policy.js";
import {
configureExecutionIdentityAdmissionSink,
type ExecutionIdentityAdmissionWork,
} from "../../audit/execution-identity-admission.js";
import {
compareChannelAdmissionParticipants,
createChannelAdmissionAudit,
consumeChannelAdmissionEvidence,
} from "../../channels/message-access/admission-evidence.js";
import {
attachGatewayLocalUserIngress,
getGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "../../gateway/local-user-ingress.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import { runActiveReplySteer } from "./agent-runner-steer-adoption.js";
import { prepareChannelRunAdmission } from "./channel-run-admission.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { enqueueFollowupRun, FollowupRunDeferredError, scheduleFollowupDrain } from "./queue.js";
import { createQueueTestRun } from "./queue.test-helpers.js";
import { createQueueTestRun, installQueueRuntimeErrorSilencer } from "./queue.test-helpers.js";
import {
createOverflowSummaryRetrySource,
resolveFollowupDeliveryContextKey,
@@ -457,3 +467,151 @@ describe("followup prompt metadata carrier", () => {
expectCombinedCarrierFacts(retry);
});
});
describe("queued Gateway attach evidence", () => {
installQueueRuntimeErrorSilencer();
const admissions: ExecutionIdentityAdmissionWork[] = [];
beforeEach(() => {
admissions.length = 0;
evidenceCleanups.add(
configureExecutionIdentityAdmissionSink((work) => {
admissions.push(work);
return true;
}),
);
});
async function admit(run: FollowupRun) {
const prepared = prepareChannelRunAdmission({
cfg: { logging: { audit: { executionIdentity: true } } },
runId: "queued-attach",
agentId: "main",
ingressKind: "channel",
boundary: "auto-reply.agent-runner",
evidence: run.channelAdmissionEvidence,
gatewayLocalUserIngress: run.gatewayLocalUserIngress,
});
try {
await prepared.admit("embedded");
} finally {
prepared.close();
}
}
const prepareIngress = (profileId: string) =>
prepareGatewayLocalUserIngress({
authMethod: "token",
authenticatedUserExpected: true,
profile: { profileId },
isLocalClient: false,
});
it.each(["matching", "mixed", "missing"] as const)(
"collects %s attach snapshots without changing sender authority",
async (kind) => {
const key = `gateway-attach-collect-${kind}`;
queueKeys.add(key);
const done = createDeferred<FollowupRun>();
for (const index of [0, 1]) {
const run = createQueueTestRun({ prompt: `queued ${index}` });
run.gatewayLocalUserIngress =
kind === "missing" && index === 1
? undefined
: prepareIngress(kind === "mixed" && index === 1 ? "person-2" : "person-1");
run.run = { ...run.run, senderId: "transport", senderIsOwner: true };
enqueueFollowupRun(key, run, { mode: "collect", debounceMs: 0 });
}
scheduleFollowupDrain(key, async (run) => {
done.resolve(run);
});
const collected = await done.promise;
expect(collected.prompt).toContain("queued 0");
expect(collected.prompt).toContain("queued 1");
await admit(collected);
expect(admissions).toMatchObject([
{
kind: "capture",
envelope:
kind === "matching"
? {
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
rawSourceRef: "person-1",
},
invoker: { state: "present", kind: "person", rawPrincipalRef: "person-1" },
assurance: [
{
kind: "durable-profile",
rawEvidenceRef: "person-1",
strength: "boundary-verified",
},
],
}
: {
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "unknown",
},
invoker: { state: "unknown" },
},
},
]);
const captured = admissions[0];
if (kind !== "matching" && captured?.kind === "capture") {
expect(captured.envelope.assurance).not.toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "durable-profile" })]),
);
}
expect(collected.run).toMatchObject({ senderId: "transport", senderIsOwner: true });
},
);
it("retains the original attach snapshot when overflow delivery retries after profile replacement", async () => {
const key = "gateway-attach-overflow-retry";
queueKeys.add(key);
const client = {};
attachGatewayLocalUserIngress(client, prepareIngress("original-person"));
const source = createQueueTestRun({ prompt: "overflow source" });
source.gatewayLocalUserIngress = getGatewayLocalUserIngress(client);
const settings: QueueSettings = {
mode: "collect",
debounceMs: 0,
cap: 1,
dropPolicy: "summarize",
};
enqueueFollowupRun(key, source, settings);
enqueueFollowupRun(key, createQueueTestRun({ prompt: "surviving source" }), settings);
const done = createDeferred();
let attempts = 0;
scheduleFollowupDrain(key, async (run) => {
if (!run.prompt.includes("overflow source")) {
done.resolve();
return;
}
attempts += 1;
if (attempts === 1) {
attachGatewayLocalUserIngress(client, prepareIngress("replacement-person"));
throw new Error("Synthetic pre-admission delivery failure");
}
await admit(run);
});
await done.promise;
expect(attempts).toBe(2);
expect(admissions).toHaveLength(1);
expect(admissions).toMatchObject([
{
kind: "capture",
envelope: {
ingress: { kind: "gateway-client", state: "present", rawSourceRef: "original-person" },
invoker: { state: "present", kind: "person", rawPrincipalRef: "original-person" },
assurance: [{ kind: "durable-profile", rawEvidenceRef: "original-person" }],
},
},
]);
});
});
@@ -3,6 +3,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import { readToolAllowlistIntersection } from "../../../agents/tool-policy.js";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { combineChannelAdmissionEvidence } from "../../../channels/message-access/admission-evidence.js";
import { combineGatewayLocalUserIngress } from "../../../gateway/local-user-ingress.js";
import { channelRouteDedupeKey } from "../../../plugin-sdk/channel-route.js";
import { resolveGlobalSingleton } from "../../../shared/global-singleton.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
@@ -189,6 +190,7 @@ type FollowupRuntimeMetadata = Pick<
| "currentInboundContext"
| "explicitSkillSelections"
| "channelAdmissionEvidence"
| "gatewayLocalUserIngress"
| "toolsAllow"
| "disableTools"
| "abortSignal"
@@ -274,6 +276,9 @@ export function collectRuntimeMetadata(
channelAdmissionEvidence: combineChannelAdmissionEvidence(
items.map((item) => item.channelAdmissionEvidence),
),
gatewayLocalUserIngress: combineGatewayLocalUserIngress(
items.map((item) => item.gatewayLocalUserIngress),
),
toolsAllow: authoritySource?.toolsAllow,
disableTools: authoritySource?.disableTools,
abortSignal,
@@ -285,6 +290,15 @@ export function collectRuntimeMetadata(
};
}
export function resolveOverflowSummaryInboundEventKind(
sources: FollowupRun[],
): "room_event" | undefined {
return sources.length > 0 &&
sources.every((source) => source.currentInboundEventKind === "room_event")
? "room_event"
: undefined;
}
export function createOverflowSummaryRetrySource(source: FollowupRun): FollowupRun {
return {
prompt: source.prompt,
@@ -302,6 +316,7 @@ export function createOverflowSummaryRetrySource(source: FollowupRun): FollowupR
imageOrder: source.imageOrder,
media: source.media,
channelAdmissionEvidence: source.channelAdmissionEvidence,
gatewayLocalUserIngress: source.gatewayLocalUserIngress,
messageId: source.messageId,
summaryLine: source.summaryLine,
enqueuedAt: source.enqueuedAt,
+2 -7
View File
@@ -46,6 +46,7 @@ import {
hasPreparedCurrentTurnImages,
resolveFollowupDeliveryContextKey,
resolveFollowupReplyAnchor,
resolveOverflowSummaryInboundEventKind,
} from "./delivery-context.js";
import {
admitFollowupRunLifecycle,
@@ -843,13 +844,6 @@ async function drainProtectedPriorityFollowup(
return true;
}
function resolveOverflowSummaryInboundEventKind(sources: FollowupRun[]): "room_event" | undefined {
return sources.length > 0 &&
sources.every((source) => source.currentInboundEventKind === "room_event")
? "room_event"
: undefined;
}
async function runSyntheticOverflowSummary(params: {
source: FollowupRun;
sources: FollowupRun[];
@@ -903,6 +897,7 @@ async function runSyntheticOverflowSummary(params: {
abortSignal: params.abortSignal,
explicitSkillSelections: runtimeMetadata.explicitSkillSelections,
channelAdmissionEvidence: runtimeMetadata.channelAdmissionEvidence,
gatewayLocalUserIngress: runtimeMetadata.gatewayLocalUserIngress,
operatorAuthority: runtimeMetadata.operatorAuthority,
personalBootstrapEligible: runtimeMetadata.personalBootstrapEligible,
toolsAllow: runtimeMetadata.toolsAllow,
+3
View File
@@ -20,6 +20,7 @@ import type { SessionEntry, SessionToolOverrides } from "../../../config/session
import type { ReplyToMode } from "../../../config/types.base.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { GroupToolPolicyConfig } from "../../../config/types.tools.js";
import type { GatewayLocalUserIngress } from "../../../gateway/local-user-ingress.js";
import type { GatewayUiCommandTarget } from "../../../gateway/ui-command-target.types.js";
import type { MediaFact } from "../../../media/media-facts.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
@@ -129,6 +130,8 @@ export type FollowupRun = {
currentInboundAudio?: boolean;
/** Host-minted participant evidence; raw channel identities never live on this object. */
channelAdmissionEvidence?: ChannelAdmissionEvidence;
/** Frozen original attach evidence; diagnostic only and never restored from durable queue state. */
gatewayLocalUserIngress?: GatewayLocalUserIngress;
/** Explicit current-turn context that should be visible for this run but not persisted as user text. */
currentInboundContext?: CurrentInboundPromptContext;
/** Explicit skills resolved from the authenticated inbound message. */
@@ -0,0 +1,227 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { expect, it, vi } from "vitest";
import type { AuditRunInspectResult } from "../../packages/gateway-protocol/src/schema/audit-run.js";
import type { UsersSelfResult } from "../../packages/gateway-protocol/src/schema/users.js";
import { runQaGatewayFixture } from "../../test/helpers/qa-gateway-cleanup.js";
import type { AgentWaitResult } from "../agents/run-wait.types.js";
import type { AuditEventWriter } from "../audit/audit-event-writer.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { reserveTestPortListener } from "../test-utils/port-claims.js";
import { disconnectGatewayClient, startGatewayWithClient } from "./test-helpers.e2e.js";
import { buildMockOpenAiResponsesProvider } from "./test-openai-responses-model.js";
async function startProvider(requests: string[], finalText: string) {
return await reserveTestPortListener({
offsets: [0],
createListener: () =>
createServer((request, response) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
requests.push(Buffer.concat(chunks).toString("utf8"));
const message = {
type: "message",
id: randomUUID(),
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: finalText, annotations: [] }],
};
response.writeHead(200, { "content-type": "text/event-stream" });
for (const event of [
{
type: "response.output_item.added",
item: { ...message, status: "in_progress", content: [] },
},
{ type: "response.output_item.done", item: message },
{
type: "response.completed",
response: {
id: randomUUID(),
status: "completed",
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
},
]) {
response.write(`data: ${JSON.stringify(event)}\n\n`);
}
response.end("data: [DONE]\n\n");
})().catch((error: unknown) => response.writeHead(500).end(String(error)));
}),
});
}
it(
"retains authenticated Control UI attach identity through chat.send execution and replay",
{ timeout: 90_000 },
async () => {
await withOpenClawTestState(
{
label: "chat-send-attach-identity",
env: {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
OPENCLAW_TEST_MINIMAL_GATEWAY: undefined,
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
},
},
async (state) => {
state.envVars.OPENCLAW_BUNDLED_PLUGINS_DIR = state.path("no-plugins");
state.applyEnv();
const requests: string[] = [];
const finalText = "attach-identity-provider-proof";
let providerServer: Awaited<ReturnType<typeof startProvider>> | undefined;
let gateway: Awaited<ReturnType<typeof startGatewayWithClient>> | undefined;
const audit = await import("../audit/audit-event-writer.js");
const createAuditWriter = audit.createAuditEventWriter;
const writers: AuditEventWriter[] = [];
const auditFactory = vi
.spyOn(audit, "createAuditEventWriter")
.mockImplementation((options) => {
const writer = createAuditWriter(options);
writers.push(writer);
return writer;
});
await runQaGatewayFixture(
async () => {
providerServer = await startProvider(requests, finalText);
const provider = buildMockOpenAiResponsesProvider(
`http://127.0.0.1:${providerServer.claim.port}/v1`,
"attach-identity-proof",
);
const token = "synthetic-attach-identity-token";
const cfg = {
agents: {
defaults: {
workspace: state.workspaceDir,
skipBootstrap: true,
model: { primary: provider.modelRef },
models: {
[provider.modelRef]: { params: { transport: "sse", openaiWsWarmup: false } },
},
},
},
gateway: {
auth: { mode: "token", token },
controlUi: { allowedOrigins: ["http://localhost"] },
},
models: { mode: "replace", providers: { [provider.providerId]: provider.config } },
plugins: { slots: { memory: "none" } },
tools: { profile: "minimal" },
logging: { audit: { executionIdentity: true } },
} satisfies OpenClawConfig;
gateway = await startGatewayWithClient({
cfg,
configPath: state.configPath,
token,
clientName: "openclaw-control-ui",
mode: "webchat",
origin: "http://localhost",
scopes: ["operator.admin", "operator.read", "operator.write"],
});
const client = gateway.client;
const self = await client.request<UsersSelfResult>("users.self", {});
expect(self.profile.id).toBe("gateway-owner");
const created = await client.request<{ key: string }>("sessions.create", {
agentId: "main",
label: "Attach identity proof",
});
const runId = randomUUID();
const params = {
sessionKey: created.key,
message: "attach-identity-user-proof",
idempotencyKey: runId,
};
expect(await client.request("chat.send", params)).toMatchObject({
runId,
status: "started",
});
const completed = await client.request<AgentWaitResult>(
"agent.wait",
{ runId, timeoutMs: 30_000 },
{ timeoutMs: 35_000 },
);
expect(completed).toMatchObject({
status: "ok",
terminalReply: { disposition: "visible", text: finalText },
});
expect(requests).toHaveLength(1);
expect(requests[0]).toContain(params.message);
const historyParams = { sessionKey: created.key, limit: 20 };
const history = await client.request<{ messages: unknown[] }>(
"chat.history",
historyParams,
);
expect(history.messages).toMatchObject([
{
role: "user",
content: params.message,
__openclaw: {
idempotencyKey: `${runId}:user`,
senderIdentity: { type: "profile", id: self.profile.id },
transport: {
clients: [{ id: "openclaw-control-ui", mode: "webchat" }],
},
},
},
{
role: "assistant",
content: [{ type: "text", text: finalText }],
__openclaw: { runId },
},
]);
expect(await client.request("chat.send", params)).toMatchObject({ runId });
const replayHistory = await client.request<{ messages: unknown[] }>(
"chat.history",
historyParams,
);
expect(replayHistory.messages).toEqual(history.messages);
expect(requests).toHaveLength(1);
expect(writers).toHaveLength(1);
// Turn completion does not drain diagnostic persistence. Keep the real
// writer live through replay, then join its FIFO before inspecting it.
await writers[0]!.stop();
const inspected = await client.request<AuditRunInspectResult>("audit.run.inspect", {
runId,
});
expect(inspected.identity).toMatchObject({
state: "present",
context: {
runId,
ingress: {
kind: "gateway-client",
state: "present",
boundary: "gateway.ws.authenticated-connect",
},
invoker: { state: "present", principal: { kind: "person" } },
assurance: expect.arrayContaining([
expect.objectContaining({
kind: "durable-profile",
strength: "boundary-verified",
}),
]),
},
});
},
() => (gateway ? disconnectGatewayClient(gateway.client) : undefined),
() => gateway?.server.close({ reason: "attach identity proof complete" }),
async () => {
providerServer?.listener.closeAllConnections();
await providerServer?.releaseListener();
},
() => providerServer?.claim.release(),
() => auditFactory.mockRestore(),
);
},
);
},
);
+38 -2
View File
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from "node:util";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { ExecutionIdentityAdmissionFacts } from "../audit/execution-identity-admission.js";
import { redactSensitiveText } from "../logging/redact.js";
@@ -7,11 +8,12 @@ type GatewayLocalUserIngressFacts = Readonly<
Pick<ExecutionIdentityAdmissionFacts, "assurance" | "ingress" | "invoker">
>;
type GatewayLocalUserIngress = Readonly<{
export type GatewayLocalUserIngress = Readonly<{
facts: GatewayLocalUserIngressFacts;
}>;
const ingressByOwner = new WeakMap<object, GatewayLocalUserIngress>();
const preparedIngress = new WeakSet<GatewayLocalUserIngress>();
function freezeLocalUserIngress(facts: GatewayLocalUserIngressFacts): GatewayLocalUserIngress {
Object.freeze(facts.ingress);
@@ -20,7 +22,41 @@ function freezeLocalUserIngress(facts: GatewayLocalUserIngressFacts): GatewayLoc
Object.freeze(item);
}
Object.freeze(facts.assurance);
return Object.freeze({ facts: Object.freeze(facts) });
const ingress = Object.freeze({ facts: Object.freeze(facts) });
preparedIngress.add(ingress);
return ingress;
}
/** Only the attach owner can supply facts; copied or public lookalikes carry none. */
export function readGatewayLocalUserIngressFacts(
ingress: GatewayLocalUserIngress | undefined,
): GatewayLocalUserIngressFacts | undefined {
return ingress && preparedIngress.has(ingress) ? ingress.facts : undefined;
}
/** Collected turns retain attribution only when every source supplies the same attach facts. */
export function combineGatewayLocalUserIngress(
sources: readonly (GatewayLocalUserIngress | undefined)[],
): GatewayLocalUserIngress | undefined {
const first = sources.find((source) => readGatewayLocalUserIngressFacts(source));
if (!first) {
return undefined;
}
if (
sources.every((source) =>
isDeepStrictEqual(readGatewayLocalUserIngressFacts(source), first.facts),
)
) {
return first;
}
return freezeLocalUserIngress({
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "unknown",
},
invoker: { state: "unknown" },
});
}
function safeDisplayLabel(value: string | null | undefined): string | undefined {
@@ -13,6 +13,7 @@ import {
discardPreparedInboundMedia,
persistInboundImagesForTranscript,
} from "../chat-attachments.js";
import { transferGatewayLocalUserIngress } from "../local-user-ingress.js";
import { resolveCreatorSandbox } from "../operator-role-policy.js";
import { resolveGatewayInputParticipant } from "../session-input-participant.js";
import { prepareSkillLibrarySessionCreation } from "../skill-library-session.js";
@@ -239,6 +240,9 @@ export function prepareChatSendUserTurn(params: {
GatewayRunToolBindings: request.toolBindings,
GatewayUiCommandTarget: gatewayUiCommandTarget,
};
if (client) {
transferGatewayLocalUserIngress(client, ctx);
}
if (attachments.mediaPathOffloads.length > 0) {
// Pre-staged offloads must use structured facts and marker text so the
// dispatch path renders their prompt note without staging them a second time.