mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
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:
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user