fix(audit): show authenticated users for Gateway runs (#122484)

* fix(audit): show authenticated users for Gateway runs

* fix(audit): keep profile labels out of sessions

* test(qa): verify session label retention at storage

* fix(sessions): preserve canonical profile ownership

* test(qa): require full identity inspection proof

* docs(agents): preserve execution identity ownership boundary
This commit is contained in:
Josh Avant
2026-08-12 03:50:37 -05:00
committed by GitHub
parent 6d973d114f
commit 3b01ea7905
15 changed files with 1080 additions and 40 deletions
+1
View File
@@ -170,6 +170,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m
## Execution Identity Audit
- Execution identity is opt-in diagnostic provenance, never authorization or enforcement. Unknown facts stay unknown; record ingress or invoker facts only at their authoritative producer. Never infer identity from session keys, `runId`, or routing metadata.
- Frozen ingress identity facts are diagnostic audit input, not session-ownership state. Session provenance must use the current canonical authenticated profile ID and never retain a profile display label; only explicitly enabled execution-identity audit storage may retain its bounded, redacted form.
- Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence.
- Each outer admitted turn owns one immutable `executionId` and `contextId`; `runId` is non-unique correlation. Retries, fallbacks, and recovery reuse the original admission identity. Only byte-identical canonical replay is idempotent.
- Admission may only validate, bound, freeze, and enqueue through the shared audit writer. Admission validates only a recursively owned, enumerable, accessor-free data snapshot constructed from descriptors before schema checks or ordinary property reads; inherited properties are absent and accessors never run. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution.
+10
View File
@@ -124,6 +124,16 @@ ingress, an absent invoker, and
`unattributed` coverage. Its admission receipt says `not-applicable` because no
identity-aware policy or grant evaluation was proven.
For Gateway runs, a resolved authenticated profile can make the invoker
`present` and coverage `attribution-only`. Paired devices and shared credentials
do not establish a person: without a durable profile the invoker stays absent,
or `unknown` when authenticated user evidence promised a profile that could not
be resolved. Session creation retains the live canonical durable profile id so
profile linking does not orphan ownership, while run inspection consumes the
immutable connection-time audit fact. Ordinary session provenance stores no
display label. An optional bounded, secret-redacted label can be retained only
in execution identity after that audit storage is explicitly enabled.
JSON output is the Gateway result without lossy reformatting. An exact result contains one
bounded V1 context (maximum 16 KiB), up to 100 decision receipts, coverage and
missing-evidence codes, and an optional `nextDecisionCursor`. An ambiguous run
+14
View File
@@ -91,6 +91,20 @@ this boundary. A run becomes
`attribution-only` only when an authoritative ingress supplies an invoker fact.
Neither state means that identity affected an allow or deny decision.
Authenticated Gateway attach records immutable audit facts once. Session
creation separately reads the live canonical durable profile id so a profile
link performed after attach cannot orphan session ownership. Ordinary session
provenance retains that id only; it does not retain a profile display label.
When execution identity recording is explicitly enabled, its audit context may
also retain the prepared display label after secret redaction and the
128-character bound. A resolved durable profile, including one established by
verified trusted-proxy or Tailscale identity, supplies a pseudonymized person
invoker. A paired device adds device assurance but never becomes a person.
Shared tokens, passwords, auth-none connections, and other profileless clients
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.
Each present context currently 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,20 @@
import type { ExecutionIdentityAdmissionFacts } from "../audit/execution-identity-admission.js";
type AgentCommandAdmissionFacts = Readonly<
Pick<ExecutionIdentityAdmissionFacts, "assurance" | "ingress" | "invoker">
>;
const factsByIngress = new WeakMap<object, AgentCommandAdmissionFacts>();
export function attachAgentCommandAdmissionFacts(
ingress: object,
facts: AgentCommandAdmissionFacts,
): void {
factsByIngress.set(ingress, facts);
}
export function getAgentCommandAdmissionFacts(
ingress: object,
): AgentCommandAdmissionFacts | undefined {
return factsByIngress.get(ingress);
}
@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import { sanitizePublicAgentCommandIngressOpts } from "./agent-command-execution-identity.js";
import { afterEach, describe, expect, it } from "vitest";
import {
configureExecutionIdentityAdmissionSink,
type ExecutionIdentityAdmissionWork,
} from "../audit/execution-identity-admission.js";
import { attachAgentCommandAdmissionFacts } from "./agent-command-admission-facts.js";
import {
prepareAgentCommandExecutionIdentity,
sanitizePublicAgentCommandIngressOpts,
} from "./agent-command-execution-identity.js";
import type { AgentCommandIngressOpts } from "./command/types.js";
let cleanupSink: (() => void) | undefined;
afterEach(() => {
cleanupSink?.();
cleanupSink = undefined;
});
describe("sanitizePublicAgentCommandIngressOpts", () => {
it("removes a forged cron creator authority capability from plain-JavaScript ingress", () => {
const forgedCapability = {
@@ -22,3 +37,123 @@ describe("sanitizePublicAgentCommandIngressOpts", () => {
});
});
});
describe("Gateway agent command execution identity", () => {
it("carries only the prepared bounded, redacted label into opt-in run admission", async () => {
let work: ExecutionIdentityAdmissionWork | undefined;
const displayLabel = "Operator OPENAI_API_KEY=***".padEnd(128, "x");
cleanupSink = configureExecutionIdentityAdmissionSink((candidate) => {
work = candidate;
return true;
});
const opts: AgentCommandIngressOpts = {
message: "attribute this run",
allowModelOverride: false,
};
attachAgentCommandAdmissionFacts(opts, {
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
rawSourceRef: "profile-ada",
},
invoker: {
state: "present",
kind: "person",
rawPrincipalRef: "profile-ada",
displayLabel,
},
assurance: [
{
kind: "durable-profile",
rawEvidenceRef: "profile-ada",
strength: "boundary-verified",
},
],
});
const prepared = prepareAgentCommandExecutionIdentity({
opts,
prepared: {
cfg: { logging: { audit: { enabled: true, executionIdentity: true } } },
runId: "run-profiled",
sessionAgentId: "main",
sessionId: "session-profiled",
},
ingress: { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" },
lifecycleGeneration: "generation-1",
});
await prepared.admit("embedded");
expect(work).toMatchObject({
kind: "capture",
envelope: {
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
},
invoker: {
state: "present",
kind: "person",
rawPrincipalRef: "profile-ada",
displayLabel: "Operator OPENAI_API_KEY=***",
},
assurance: [
{
kind: "durable-profile",
rawEvidenceRef: "profile-ada",
strength: "boundary-verified",
},
],
},
});
if (work?.kind !== "capture" || work.envelope.invoker?.state !== "present") {
throw new Error("expected captured present invoker");
}
expect(work.envelope.invoker.displayLabel).toBe("Operator OPENAI_API_KEY=***");
expect(work.envelope.invoker.displayLabel?.length).toBeLessThanOrEqual(128);
});
it("does not offer the prepared profile label to storage without execution audit opt-in", async () => {
let work: ExecutionIdentityAdmissionWork | undefined;
cleanupSink = configureExecutionIdentityAdmissionSink((candidate) => {
work = candidate;
return true;
});
const opts: AgentCommandIngressOpts = {
message: "do not retain this label",
allowModelOverride: false,
};
attachAgentCommandAdmissionFacts(opts, {
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
},
invoker: {
state: "present",
kind: "person",
rawPrincipalRef: "profile-ada",
displayLabel: "Ada",
},
});
const prepared = prepareAgentCommandExecutionIdentity({
opts,
prepared: {
cfg: { logging: { audit: { enabled: true, executionIdentity: false } } },
runId: "run-profiled-disabled",
sessionAgentId: "main",
sessionId: "session-profiled-disabled",
},
ingress: { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" },
lifecycleGeneration: "generation-1",
});
await prepared.admit("embedded");
expect(work).toBeUndefined();
});
});
+15 -3
View File
@@ -8,6 +8,10 @@ import {
prepareAgentRunAdmission,
type OperationalRunInstanceRef,
} from "./admitted-run-context.js";
import {
attachAgentCommandAdmissionFacts,
getAgentCommandAdmissionFacts,
} from "./agent-command-admission-facts.js";
import type {
AgentCommandGatewayIngressOpts,
AgentCommandIngressOpts,
@@ -38,13 +42,16 @@ function prepareAgentCommandRunAdmission(params: {
runId: string;
onAdmitted?: Parameters<typeof prepareAgentRunAdmission>[0]["onAdmitted"];
}) {
const admissionFacts = getAgentCommandAdmissionFacts(params.operationalRunInstance) ?? {
ingress: params.ingress,
};
return prepareAgentRunAdmission({
cfg: params.cfg,
operationalRunInstance: params.operationalRunInstance,
facts: {
runId: params.runId,
agentId: params.agentId,
ingress: params.ingress,
...admissionFacts,
},
...(params.admission ? { recovery: params.admission } : {}),
...(params.onAdmitted ? { onAdmitted: params.onAdmitted } : {}),
@@ -96,13 +103,18 @@ export function prepareAgentCommandExecutionIdentity(params: {
lifecycleGeneration: string;
}) {
const { opts, prepared } = params;
const operationalRunInstance =
opts.operationalRunInstance ?? createOperationalRunInstanceRef(prepared.runId);
const admissionFacts = getAgentCommandAdmissionFacts(params.opts.runContext ?? params.opts);
if (admissionFacts) {
attachAgentCommandAdmissionFacts(operationalRunInstance, admissionFacts);
}
return executionIdentity.prepare({
admission: opts.executionIdentityAdmission,
agentId: prepared.sessionAgentId,
cfg: prepared.cfg,
ingress: params.ingress,
operationalRunInstance:
opts.operationalRunInstance ?? createOperationalRunInstanceRef(prepared.runId),
operationalRunInstance,
runId: prepared.runId,
onAdmitted: async (admittedRunContext) => {
await opts.onAdmittedRunContext?.(admittedRunContext);
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { getAdmittedRunDelegatedAuthority } from "../../agents/admitted-run-context.js";
import { attachAgentCommandAdmissionFacts } from "../../agents/agent-command-admission-facts.js";
import type { AgentRunTerminalOutcome } from "../../agents/agent-run-terminal-outcome.js";
import {
claimExecApprovalFollowupRuntimeHandoff,
@@ -41,6 +42,7 @@ import {
buildRunUserTurnIdempotencyKey,
createUserTurnTranscriptRecorder,
} from "../../sessions/user-turn-transcript.js";
import { getGatewayLocalUserIngress } from "../local-user-ingress.js";
import type { AgentRunRequest } from "../server-methods/agent-request-types.js";
import { createAgentRunModelSelectionHandler } from "../server-methods/agent-run-model-selection.js";
import { resolveSessionRuntimeCwd } from "../server-methods/agent-session-reset.js";
@@ -366,6 +368,10 @@ export function startAgentRunExecution(params: {
restartRecoveryChannelContext?.sameChannelThreadRequired,
);
const localUserIngress = getGatewayLocalUserIngress(params.client);
if (localUserIngress) {
attachAgentCommandAdmissionFacts(runContext, localUserIngress.facts);
}
dispatchAgentRunFromGateway({
cronCreatorAuthority: prepared.cronCreatorAuthority,
ingressOpts: {
+4 -1
View File
@@ -2,6 +2,7 @@ import {
GATEWAY_CLIENT_CAPS,
hasGatewayClientCap,
} from "../../../packages/gateway-protocol/src/client-info.js";
import { transferGatewayLocalUserIngress } from "../local-user-ingress.js";
import type { GatewayClient, GatewayRequestContext } from "../server-methods/shared-types.js";
import type { AgentTurnPrincipal } from "./types.js";
@@ -10,7 +11,7 @@ export function captureAgentTurnPrincipal(client: GatewayClient | null): AgentTu
if (!client) {
return null;
}
return {
const principal: AgentTurnPrincipal = {
authenticatedUserId: client.authenticatedUserId,
authenticatedUserProfile: client.authenticatedUserProfile,
connId: client.connId,
@@ -18,6 +19,8 @@ export function captureAgentTurnPrincipal(client: GatewayClient | null): AgentTu
internal: client.internal,
isDeviceTokenAuth: client.isDeviceTokenAuth,
};
transferGatewayLocalUserIngress(client, principal);
return principal;
}
/** Preserve capability-gated tool-event observation across agent turn entry paths. */
+124
View File
@@ -0,0 +1,124 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { ExecutionIdentityAdmissionFacts } from "../audit/execution-identity-admission.js";
import { redactSensitiveText } from "../logging/redact.js";
import type { GatewayAuthResult } from "./auth.js";
type GatewayLocalUserIngressFacts = Readonly<
Pick<ExecutionIdentityAdmissionFacts, "assurance" | "ingress" | "invoker">
>;
type GatewayLocalUserIngress = Readonly<{
facts: GatewayLocalUserIngressFacts;
}>;
const ingressByOwner = new WeakMap<object, GatewayLocalUserIngress>();
function freezeLocalUserIngress(facts: GatewayLocalUserIngressFacts): GatewayLocalUserIngress {
Object.freeze(facts.ingress);
Object.freeze(facts.invoker);
for (const item of facts.assurance ?? []) {
Object.freeze(item);
}
Object.freeze(facts.assurance);
return Object.freeze({ facts: Object.freeze(facts) });
}
function safeDisplayLabel(value: string | null | undefined): string | undefined {
const label = value?.trim();
return label
? truncateUtf16Safe(
redactSensitiveText(redactSensitiveText(label, { mode: "tools" }), { mode: "tools" }),
128,
)
: undefined;
}
/** Prepare attribution once from authenticated connection facts; credentials never become people. */
export function prepareGatewayLocalUserIngress(params: {
authMethod?: GatewayAuthResult["method"];
authenticatedUserExpected: boolean;
profile?: { profileId: string; displayName?: string | null };
pairedDeviceId?: string;
isLocalClient: boolean;
}): GatewayLocalUserIngress {
const profileId = params.profile?.profileId.trim();
const pairedDeviceId = params.pairedDeviceId?.trim();
const displayLabel = safeDisplayLabel(params.profile?.displayName);
const assurance: NonNullable<GatewayLocalUserIngressFacts["assurance"]> = [];
if (profileId) {
assurance.push({
kind: "durable-profile",
rawEvidenceRef: profileId,
strength: "boundary-verified",
});
}
if (params.authMethod === "trusted-proxy") {
assurance.push({
kind: "trusted-proxy",
rawEvidenceRef: profileId ?? "gateway-auth:trusted-proxy",
strength: "boundary-verified",
});
} else if (params.authMethod === "tailscale") {
assurance.push({
kind: "tailscale-whois",
rawEvidenceRef: profileId ?? "gateway-auth:tailscale",
strength: "boundary-verified",
});
}
if (pairedDeviceId) {
assurance.push({
kind: "device-proof",
rawEvidenceRef: pairedDeviceId,
strength: "cryptographic",
});
}
if (params.isLocalClient) {
assurance.push({
kind: "local-process",
rawEvidenceRef: "gateway-transport:local",
strength: "boundary-verified",
});
}
const rawSourceRef = profileId ?? pairedDeviceId;
return freezeLocalUserIngress({
ingress: {
kind: "gateway-client",
boundary: "gateway.ws.authenticated-connect",
state: "present",
...(rawSourceRef ? { rawSourceRef } : {}),
},
...(profileId
? {
invoker: {
state: "present",
kind: "person",
rawPrincipalRef: profileId,
...(displayLabel ? { displayLabel } : {}),
},
}
: params.authenticatedUserExpected
? { invoker: { state: "unknown" } }
: {}),
...(assurance.length > 0 ? { assurance } : {}),
});
}
export function attachGatewayLocalUserIngress(
owner: object,
ingress: GatewayLocalUserIngress,
): void {
ingressByOwner.set(owner, ingress);
}
export function getGatewayLocalUserIngress(
owner: object | null | undefined,
): GatewayLocalUserIngress | undefined {
return owner ? ingressByOwner.get(owner) : undefined;
}
export function transferGatewayLocalUserIngress(source: object, target: object): void {
const ingress = ingressByOwner.get(source);
if (ingress) {
ingressByOwner.set(target, ingress);
}
}
@@ -1,16 +1,162 @@
import { describe, expect, it } from "vitest";
import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "../local-user-ingress.js";
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
function resolveWithIngress(
localUserIngress: ReturnType<typeof prepareGatewayLocalUserIngress>,
profileId?: string,
) {
const client = profileId ? { authenticatedUserProfile: { profileId } } : {};
attachGatewayLocalUserIngress(client, localUserIngress);
return resolveAgentRunSessionCreation(client);
}
describe("agent run session creation provenance", () => {
it("uses a proven Gateway profile id", () => {
expect(
resolveAgentRunSessionCreation({
authenticatedUserProfile: { profileId: "profile-ada" },
}),
).toEqual({ via: "run", actor: { type: "human", id: "profile-ada" } });
it("uses a proven Gateway profile id without retaining its display label", () => {
const localUserIngress = prepareGatewayLocalUserIngress({
authenticatedUserExpected: true,
profile: { profileId: "profile-ada", displayName: "Ada" },
isLocalClient: false,
});
expect(resolveWithIngress(localUserIngress, "profile-ada")).toEqual({
via: "run",
actor: { type: "human", id: "profile-ada" },
});
expect(localUserIngress.facts.invoker).toEqual({
state: "present",
kind: "person",
rawPrincipalRef: "profile-ada",
displayLabel: "Ada",
});
});
it("uses the live canonical profile id after a connection profile merge", () => {
const localUserIngress = prepareGatewayLocalUserIngress({
authenticatedUserExpected: true,
profile: { profileId: "profile-before-merge", displayName: "Ada" },
isLocalClient: false,
});
expect(resolveWithIngress(localUserIngress, "profile-after-merge")).toEqual({
via: "run",
actor: { type: "human", id: "profile-after-merge" },
});
expect(localUserIngress.facts.invoker).toMatchObject({
state: "present",
rawPrincipalRef: "profile-before-merge",
});
});
it("does not infer an actor for a profile-less wire client", () => {
expect(resolveAgentRunSessionCreation({})).toEqual({ via: "run" });
});
it.each([
{
name: "paired device",
input: {
authMethod: "device-token" as const,
authenticatedUserExpected: false,
pairedDeviceId: "device-browser",
isLocalClient: false,
},
expected: {
ingress: expect.objectContaining({ rawSourceRef: "device-browser" }),
assurance: [
{
kind: "device-proof",
rawEvidenceRef: "device-browser",
strength: "cryptographic",
},
],
},
},
{
name: "shared secret",
input: {
authMethod: "token" as const,
authenticatedUserExpected: false,
isLocalClient: false,
},
expected: { ingress: expect.not.objectContaining({ rawSourceRef: expect.anything() }) },
},
])("keeps a $name profile-less and unattributed", ({ input, expected }) => {
const localUserIngress = prepareGatewayLocalUserIngress(input);
expect(localUserIngress.facts).toEqual(expect.objectContaining(expected));
expect(localUserIngress.facts.invoker).toBeUndefined();
expect(resolveWithIngress(localUserIngress)).toEqual({ via: "run" });
});
it("keeps a trusted-proxy identity unknown when durable profile resolution is missing", () => {
const localUserIngress = prepareGatewayLocalUserIngress({
authMethod: "trusted-proxy",
authenticatedUserExpected: true,
isLocalClient: false,
});
expect(localUserIngress.facts).toMatchObject({
ingress: { kind: "gateway-client", state: "present" },
invoker: { state: "unknown" },
assurance: [
{
kind: "trusted-proxy",
rawEvidenceRef: "gateway-auth:trusted-proxy",
strength: "boundary-verified",
},
],
});
expect(resolveWithIngress(localUserIngress)).toEqual({ via: "run" });
});
it("records both durable-profile and trusted-proxy assurance for a profiled proxy user", () => {
const localUserIngress = prepareGatewayLocalUserIngress({
authMethod: "trusted-proxy",
authenticatedUserExpected: true,
profile: { profileId: "profile-proxy", displayName: "Proxy User" },
isLocalClient: false,
});
expect(localUserIngress.facts.assurance).toEqual([
{
kind: "durable-profile",
rawEvidenceRef: "profile-proxy",
strength: "boundary-verified",
},
{
kind: "trusted-proxy",
rawEvidenceRef: "profile-proxy",
strength: "boundary-verified",
},
]);
});
it("keeps a bounded, redacted profile label transient for opt-in run auditing", () => {
const secret = "sk-1234567890abcdef";
const localUserIngress = prepareGatewayLocalUserIngress({
authenticatedUserExpected: true,
profile: {
profileId: "profile-redacted",
displayName: `Operator OPENAI_API_KEY=${secret} ${"x".repeat(256)}`,
},
isLocalClient: false,
});
const invoker = localUserIngress.facts.invoker;
expect(invoker).toMatchObject({ state: "present" });
if (invoker?.state !== "present") {
throw new Error("expected present invoker");
}
expect(invoker.displayLabel).toContain("OPENAI_API_KEY=***");
expect(invoker.displayLabel).not.toContain(secret);
expect(invoker.displayLabel?.length).toBeLessThanOrEqual(128);
expect(resolveWithIngress(localUserIngress, "profile-redacted")).toEqual({
via: "run",
actor: { type: "human", id: "profile-redacted" },
});
});
});
@@ -52,9 +52,8 @@ export function resolveOperatorSessionCreation(
};
}
const profileId = client?.authenticatedUserProfile?.profileId;
// Actor only when proven: a profile-less wire connection may be an agent-tool
// client on a remote topology, so claiming a human actor would misattribute
// agent-caused creations. Absent actor means unknown, never inferred.
// Profile linking can canonicalize this id after connection attach, so session
// ownership follows the live trusted profile while audit keeps its frozen facts.
return {
via: "operator",
...(profileId ? { actor: { type: "human" as const, id: profileId } } : {}),
+25 -16
View File
@@ -38,6 +38,10 @@ import {
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "./local-user-ingress.js";
import { resolveGatewaySessionStoreTarget } from "./session-utils.js";
import {
agentCommandMock,
@@ -3088,8 +3092,25 @@ test("sessions.create preserves write-scoped fresh keyed model selection but gat
});
test("sessions.create stamps trusted operator provenance and records created", async () => {
await createSessionStoreDir();
const { storePath } = await createSessionStoreDir();
const profileId = "profile-session-creator";
const client = {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId,
displayName: "Test Operator",
hasAvatar: false,
updatedAt: 1,
},
};
attachGatewayLocalUserIngress(
client,
prepareGatewayLocalUserIngress({
authenticatedUserExpected: true,
profile: { profileId, displayName: "Test Operator" },
isLocalClient: false,
}),
);
const created = await directSessionReq<{
key?: string;
entry?: {
@@ -3097,21 +3118,7 @@ test("sessions.create stamps trusted operator provenance and records created", a
createdActor?: { type: string; id?: string };
createdAt?: number;
};
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId,
displayName: "Test Operator",
hasAvatar: false,
updatedAt: 1,
},
} as never,
},
);
}>("sessions.create", { agentId: "main" }, { client: client as never });
expect(created.ok).toBe(true);
expect(created.payload?.entry).toMatchObject({
@@ -3119,7 +3126,9 @@ test("sessions.create stamps trusted operator provenance and records created", a
createdActor: { type: "human", id: profileId },
createdAt: expect.any(Number),
});
expect(created.payload?.entry).not.toHaveProperty("createdActor.label");
const key = requireNonEmptyString(created.payload?.key, "created session key");
expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("createdActor.label");
expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
@@ -33,6 +33,10 @@ import {
import { resolveRuntimeServiceVersion } from "../../../version.js";
import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
import { buildAuthenticatedPresenceUser } from "../../authenticated-presence-user.js";
import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "../../local-user-ingress.js";
import { APPROVALS_SCOPE } from "../../method-scopes.js";
import { serializeEventPayload } from "../../node-registry.js";
import { isOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
@@ -317,6 +321,20 @@ export async function attachAuthenticatedGatewayConnect(
: {}),
}
: undefined;
const localUserIngress = prepareGatewayLocalUserIngress({
authMethod,
authenticatedUserExpected: Boolean(authenticatedUserId),
...(authenticatedUserProfile
? {
profile: {
profileId: authenticatedUserProfile.profileId,
displayName: authenticatedUserProfile.displayName,
},
}
: {}),
...(device?.id ? { pairedDeviceId: device.id } : {}),
isLocalClient,
});
if (usesLegacyNodeProtocol) {
logWsControl.warn(
`legacy node protocol accepted conn=${connId} client=${formatForLog(clientLabel)} v${formatForLog(connectParams.client.version)} min=${minProtocol} max=${maxProtocol} current=${PROTOCOL_VERSION}; upgrade recommended`,
@@ -352,6 +370,7 @@ export async function attachAuthenticatedGatewayConnect(
? { pluginNodeCapabilitySurfaces }
: {}),
};
attachGatewayLocalUserIngress(nextClient, localUserIngress);
for (const entry of pendingPluginNodeCapabilities) {
setClientPluginNodeCapability({
client: nextClient,
@@ -20,6 +20,7 @@ import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-toke
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import type { HealthSummary } from "../../health/types.js";
import { getGatewayLocalUserIngress } from "../../local-user-ingress.js";
import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
import { handleGatewayRequest } from "../../server-methods.js";
import { resolveGatewayCronCreatorAuthorityAdmission } from "../../server-methods/cron-creator-authority-admission.js";
@@ -89,6 +90,12 @@ vi.mock("../../../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
function localUserIngressFor(client: unknown) {
return typeof client === "object" && client !== null
? getGatewayLocalUserIngress(client)
: undefined;
}
vi.mock("../../../config/io.js", () => ({
getRuntimeConfig: loadConfigMock,
}));
@@ -726,6 +733,25 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
hasAvatar: false,
},
});
expect(localUserIngressFor(first.harness.client)).toMatchObject({
facts: {
ingress: {
kind: "gateway-client",
rawSourceRef: profileId,
state: "present",
},
invoker: {
state: "present",
kind: "person",
rawPrincipalRef: profileId,
displayLabel: "alice",
},
assurance: expect.arrayContaining([
expect.objectContaining({ kind: "durable-profile" }),
expect.objectContaining({ kind: "trusted-proxy" }),
]),
},
});
expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true);
const second = await connect("second");
@@ -817,6 +843,15 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
authenticatedUserIsTailscaleProvider: true,
authenticatedUserProfile: { displayName: "Ada Lovelace", hasAvatar: false },
});
expect(localUserIngressFor(harness.client)).toMatchObject({
facts: {
invoker: { state: "present", kind: "person", displayLabel: "Ada Lovelace" },
assurance: expect.arrayContaining([
expect.objectContaining({ kind: "durable-profile" }),
expect.objectContaining({ kind: "tailscale-whois" }),
]),
},
});
expect(adoptTailscaleProfileAvatarMock).toHaveBeenCalledOnce();
});
expect(harness.socketSend).toHaveBeenCalled();
@@ -843,7 +878,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
});
});
it("falls back to email identity when durable profile resolution fails", async () => {
it("keeps presence fallback but records unknown invoker when profile resolution fails", async () => {
ensureProfileForEmailMock.mockImplementationOnce(() => {
throw new Error("profile store unavailable");
});
@@ -858,6 +893,19 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
);
});
expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" });
expect(localUserIngressFor(harness.client)).toMatchObject({
facts: {
ingress: expect.not.objectContaining({ rawSourceRef: expect.anything() }),
invoker: { state: "unknown" },
assurance: [
{
kind: "trusted-proxy",
rawEvidenceRef: "gateway-auth:trusted-proxy",
strength: "boundary-verified",
},
],
},
});
expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() });
expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1);
expect(harness.logWsControl.warn).toHaveBeenCalledWith(
@@ -903,6 +951,11 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
});
expect(upsertPresenceMock).not.toHaveBeenCalled();
expect(harness.client).not.toMatchObject({ authenticatedUserId: expect.anything() });
const localUserIngress = localUserIngressFor(harness.client);
expect(localUserIngress).toMatchObject({
facts: { ingress: expect.not.objectContaining({ rawSourceRef: expect.anything() }) },
});
expect(localUserIngress?.facts.invoker).toBeUndefined();
expect(ensureProfileForEmailMock).not.toHaveBeenCalled();
});
@@ -2,16 +2,35 @@
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { setTimeout as sleep } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { WebSocket, type ClientOptions, type RawData } from "ws";
import {
QA_EVIDENCE_FILENAME,
type QaEvidenceSummaryJson,
} from "../../../../extensions/qa-lab/src/evidence-summary.js";
import { startQaGatewayChild } from "../../../../extensions/qa-lab/src/gateway-child.js";
import { startQaMockOpenAiServer } from "../../../../extensions/qa-lab/src/providers/mock-openai/server.js";
import { buildDeviceAuthPayloadV3 } from "../../../../packages/gateway-client/src/device-auth.js";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../../../packages/gateway-protocol/src/client-info.js";
import type { AuditRunInspectResult } from "../../../../packages/gateway-protocol/src/index.js";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../../../../packages/gateway-protocol/src/index.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
type DeviceIdentity,
} from "../../../../src/infra/device-identity.js";
import { formatErrorMessage } from "../../../../src/infra/errors.js";
import { createQaScriptEvidenceWriter, type QaScriptEvidenceStatus } from "./script-evidence.js";
@@ -37,6 +56,13 @@ const IDENTITY_FIELDS = [
"Applicable grants",
"Assurance",
] as const;
const FRAME_TIMEOUT_MS = 20_000;
const GATEWAY_SCOPES = [
"operator.admin",
"operator.pairing",
"operator.read",
"operator.write",
] as const;
type ProducerOptions = {
artifactBase: string;
@@ -50,6 +76,211 @@ type ProofResult = {
status: QaScriptEvidenceStatus;
};
type RawGatewayClient = {
frames: unknown[];
socket: WebSocket;
};
function rawDataText(data: RawData): string {
if (Array.isArray(data)) {
return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8");
}
return Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
}
async function openRawGatewayClient(
url: string,
headers?: Record<string, string>,
): Promise<RawGatewayClient> {
const socket = new WebSocket(url, headers ? ({ headers } satisfies ClientOptions) : undefined);
const frames: unknown[] = [];
socket.on("message", (data) => frames.push(parseJson(rawDataText(data), "Gateway frame")));
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
return { frames, socket };
}
async function waitForFrame(
client: RawGatewayClient,
predicate: (frame: unknown) => boolean,
startIndex = 0,
): Promise<Record<string, unknown>> {
const deadline = Date.now() + FRAME_TIMEOUT_MS;
while (Date.now() < deadline) {
const frame = client.frames.slice(startIndex).find(predicate);
if (isRecord(frame)) {
return frame;
}
await sleep(20);
}
throw new Error(`timed out waiting for Gateway frame: ${JSON.stringify(client.frames)}`);
}
function responseFor(id: string) {
return (frame: unknown) => isRecord(frame) && frame.type === "res" && frame.id === id;
}
async function closeRawGatewayClient(client: RawGatewayClient): Promise<void> {
if (client.socket.readyState === WebSocket.CLOSED) {
return;
}
await new Promise<void>((resolve) => {
client.socket.once("close", () => resolve());
client.socket.close();
setTimeout(resolve, 1_000).unref();
});
}
async function connectRawDevice(params: {
device: DeviceIdentity;
headers?: Record<string, string>;
token?: string;
wsUrl: string;
}): Promise<{ client: RawGatewayClient; connected: boolean }> {
const client = await openRawGatewayClient(params.wsUrl, params.headers);
const challenge = await waitForFrame(
client,
(frame) => isRecord(frame) && frame.type === "event" && frame.event === "connect.challenge",
);
const challengePayload = challenge.payload;
if (!isRecord(challengePayload) || typeof challengePayload.nonce !== "string") {
throw new Error("Gateway connect challenge omitted its nonce");
}
const clientInfo = {
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
platform: "linux",
version: "qa-local-user-ingress",
} as const;
const signedAt = Date.now();
const devicePayload = buildDeviceAuthPayloadV3({
deviceId: params.device.deviceId,
clientId: clientInfo.id,
clientMode: clientInfo.mode,
role: "operator",
scopes: [...GATEWAY_SCOPES],
signedAtMs: signedAt,
token: params.token,
nonce: challengePayload.nonce,
platform: clientInfo.platform,
});
const requestId = `connect-${randomUUID()}`;
const startIndex = client.frames.length;
client.socket.send(
JSON.stringify({
type: "req",
id: requestId,
method: "connect",
params: {
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: clientInfo,
role: "operator",
scopes: [...GATEWAY_SCOPES],
caps: [],
...(params.token ? { auth: { token: params.token } } : {}),
device: {
id: params.device.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(params.device.publicKeyPem),
signature: signDevicePayload(params.device.privateKeyPem, devicePayload),
signedAt,
nonce: challengePayload.nonce,
},
},
}),
);
const response = await waitForFrame(client, responseFor(requestId), startIndex);
return { client, connected: response.ok === true };
}
async function rawGatewayRequest<T>(
client: RawGatewayClient,
method: string,
params: unknown,
): Promise<T> {
const requestId = `request-${randomUUID()}`;
const startIndex = client.frames.length;
client.socket.send(JSON.stringify({ type: "req", id: requestId, method, params }));
const response = await waitForFrame(client, responseFor(requestId), startIndex);
if (response.ok !== true) {
throw new Error(`${method} failed: ${JSON.stringify(response.error)}`);
}
return response.payload as T;
}
async function approveDeviceIfNeeded(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
deviceId: string,
): Promise<void> {
const deadline = Date.now() + FRAME_TIMEOUT_MS;
while (Date.now() < deadline) {
const pairings = (await gateway.call("device.pair.list", {})) as {
pending?: Array<{ deviceId?: string; requestId?: string }>;
};
const pending = pairings.pending?.find((candidate) => candidate.deviceId === deviceId);
if (pending?.requestId) {
await gateway.call("device.pair.approve", { requestId: pending.requestId });
return;
}
await sleep(50);
}
throw new Error(`device pairing request was not visible for ${deviceId}`);
}
async function createFakeTailscaleBinary(): Promise<{
binaryDir: string;
cleanup: () => Promise<void>;
}> {
const binaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-i1-tailscale-"));
try {
const binaryPath = path.join(binaryDir, "tailscale");
await fs.writeFile(
binaryPath,
`#!/bin/sh
if [ "$1" = "--version" ]; then
echo "qa-tailscale 1.0"
exit 0
fi
echo '{"UserProfile":{"LoginName":"operator@example.com","DisplayName":"Operator"}}'
`,
{ encoding: "utf8", mode: 0o755 },
);
return {
binaryDir,
cleanup: async () => await fs.rm(binaryDir, { force: true, recursive: true }),
};
} catch (error) {
await fs.rm(binaryDir, { force: true, recursive: true });
throw error;
}
}
async function runGatewayTurn(
client: RawGatewayClient,
message: string,
sessionKey: string,
): Promise<string> {
const started = await rawGatewayRequest<{ runId?: unknown; status?: unknown }>(client, "agent", {
sessionKey,
message,
deliver: false,
idempotencyKey: randomUUID(),
});
if (started.status !== "accepted" || typeof started.runId !== "string") {
throw new Error(`profiled Gateway run did not start: ${JSON.stringify(started)}`);
}
const terminal = await rawGatewayRequest<{ status?: unknown }>(client, "agent.wait", {
runId: started.runId,
timeoutMs: 60_000,
});
if (terminal.status !== "ok") {
throw new Error(`profiled Gateway run did not finish: ${JSON.stringify(terminal)}`);
}
return started.runId;
}
async function updateExecutionIdentityConfig(
configPath: string,
values: { enabled?: boolean; executionIdentity: boolean },
@@ -143,6 +374,41 @@ function assertJsonProjection(result: AuditRunInspectResult, runId: string) {
}
}
function assertGatewayIdentityProjection(
result: AuditRunInspectResult,
expected: { coverage: "attribution-only" | "unattributed"; invoker: "absent" | "present" },
) {
const context = requireIdentityContext(result);
if (
context.ingress.kind !== "gateway-client" ||
context.ingress.state !== "present" ||
context.ingress.boundary !== "gateway.ws.authenticated-connect"
) {
throw new Error("Gateway run did not retain its authenticated connection ingress");
}
if (
context.invoker.state !== expected.invoker ||
context.coverageState !== expected.coverage ||
context.representedSubject !== undefined
) {
throw new Error(
`Gateway identity projection fabricated or lost a subject: ${JSON.stringify(context)}`,
);
}
if (expected.invoker === "present") {
if (
context.invoker.principal?.kind !== "person" ||
context.invoker.principal.displayLabel !== "Operator" ||
!context.assurance.some((item) => item.kind === "durable-profile") ||
!context.assurance.some((item) => item.kind === "tailscale-whois")
) {
throw new Error("profiled Gateway run omitted its durable Tailscale attribution");
}
} else if (context.assurance.some((item) => item.kind === "durable-profile")) {
throw new Error("profileless Gateway run fabricated durable profile assurance");
}
}
function findLocalRunId(gateway: Awaited<ReturnType<typeof startQaGatewayChild>>) {
const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR;
if (!stateDir) {
@@ -198,6 +464,42 @@ function inspectExecutionIdentityStorage(gateway: Awaited<ReturnType<typeof star
}
}
function inspectPersistedSessionCreator(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
sessionKey: string,
) {
const stateDir = gateway.runtimeEnv.OPENCLAW_STATE_DIR;
const agentId = sessionKey.split(":")[1];
if (!stateDir || !agentId) {
throw new Error("QA Gateway did not expose the session creator database owner");
}
const database = new DatabaseSync(
path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite"),
{ readOnly: true },
);
try {
const row = database
.prepare(
"SELECT created_actor_type, created_actor_id, entry_json FROM session_nodes WHERE session_key = ?",
)
.get(sessionKey) as
| { created_actor_id: string | null; created_actor_type: string | null; entry_json: string }
| undefined;
if (!row) {
throw new Error(`persisted session creator row is missing: ${sessionKey}`);
}
const entry = parseJson(row.entry_json, `persisted session ${sessionKey}`);
const actor = isRecord(entry) && isRecord(entry.createdActor) ? entry.createdActor : undefined;
return {
id: row.created_actor_id,
labelPersisted: actor ? Object.hasOwn(actor, "label") : false,
type: row.created_actor_type,
};
} finally {
database.close();
}
}
async function runLocalTurn(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
message: string,
@@ -246,6 +548,17 @@ function findRunExecutions(
}
}
function assertPersistedContextBytes(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
runId: string,
expectedContext: string,
): void {
const rows = findRunExecutions(gateway, runId);
if (rows.length !== 1 || rows[0]?.context_json !== expectedContext) {
throw new Error(`RPC context bytes differ from persisted bytes: ${runId}`);
}
}
async function runRepeatedIngressTurns(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
repoRoot: string,
@@ -291,8 +604,10 @@ async function runRepeatedIngressTurns(
async function runProof(options: ProducerOptions): Promise<string> {
const mock = await startQaMockOpenAiServer();
let fakeTailscale: Awaited<ReturnType<typeof createFakeTailscaleBinary>> | undefined;
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
try {
fakeTailscale = await createFakeTailscaleBinary();
gateway = await startQaGatewayChild({
repoRoot: options.repoRoot,
useRepoCli: true,
@@ -300,20 +615,33 @@ async function runProof(options: ProducerOptions): Promise<string> {
providerMode: "mock-openai",
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
mutateConfig: (cfg) => ({
...cfg,
gateway: {
...cfg.gateway,
auth: { ...cfg.gateway?.auth, allowTailscale: true },
},
}),
runtimeEnvPatch: {
PATH: `${fakeTailscale.binaryDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
});
await gateway.restartAfterStateMutation(async () => {
await runLocalTurn(gateway!, "Reply exactly: IDENTITY-DISABLED-FRESH");
});
await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-FRESH");
if (inspectExecutionIdentityStorage(gateway).tablePresent) {
throw new Error("fresh-install default unexpectedly created execution identity storage");
}
await gateway.restartAfterStateMutation(async () => {});
await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-UPGRADE");
await gateway.restartAfterStateMutation(async () => {
await runLocalTurn(gateway!, "Reply exactly: IDENTITY-DISABLED-UPGRADE");
});
if (inspectExecutionIdentityStorage(gateway).tablePresent) {
throw new Error("existing-install restart unexpectedly created execution identity storage");
}
await gateway.restartAfterStateMutation(async ({ configPath }) => {
await updateExecutionIdentityConfig(configPath, { executionIdentity: true });
await runLocalTurn(gateway!, "Reply exactly: IDENTITY-INSPECTION-OK");
});
await runLocalTurn(gateway, "Reply exactly: IDENTITY-INSPECTION-OK");
const runId = findLocalRunId(gateway);
const beforeText = await gateway.runCli(["audit", "--run", runId, "--explain"]);
assertTextProjection(beforeText);
@@ -323,10 +651,150 @@ async function runProof(options: ProducerOptions): Promise<string> {
) as AuditRunInspectResult;
assertJsonProjection(before, runId);
const beforeContext = normalizedContextJson(before);
assertPersistedContextBytes(gateway, runId, beforeContext);
const profilelessSessionKey = `agent:qa:i1-profileless-${randomUUID()}`;
const profilelessStarted = (await gateway.call("agent", {
sessionKey: profilelessSessionKey,
message: "Reply exactly: I1-PROFILELESS",
deliver: false,
idempotencyKey: randomUUID(),
})) as { runId?: unknown; status?: unknown };
if (profilelessStarted.status !== "accepted" || typeof profilelessStarted.runId !== "string") {
throw new Error(
`profileless Gateway run did not start: ${JSON.stringify(profilelessStarted)}`,
);
}
const profilelessTerminal = (await gateway.call("agent.wait", {
runId: profilelessStarted.runId,
timeoutMs: 60_000,
})) as { status?: unknown };
if (profilelessTerminal.status !== "ok") {
throw new Error(
`profileless Gateway run did not finish: ${JSON.stringify(profilelessTerminal)}`,
);
}
const profilelessRunId = profilelessStarted.runId;
const device = loadOrCreateDeviceIdentity({
path: path.join(gateway.tempRoot, "i1-profiled-device.sqlite"),
});
const tailscaleHeaders = {
"tailscale-user-login": "operator@example.com",
"tailscale-user-name": "Operator",
"x-forwarded-for": "100.64.0.11",
"x-forwarded-host": "gateway.qa.test",
"x-forwarded-proto": "https",
};
let profiled = await connectRawDevice({
device,
headers: tailscaleHeaders,
wsUrl: gateway.wsUrl,
});
if (!profiled.connected) {
await approveDeviceIfNeeded(gateway, device.deviceId);
await closeRawGatewayClient(profiled.client);
profiled = await connectRawDevice({
device,
headers: tailscaleHeaders,
wsUrl: gateway.wsUrl,
});
}
if (!profiled.connected) {
throw new Error(
`Tailscale-profiled Gateway client failed: ${JSON.stringify(profiled.client.frames)}`,
);
}
const profiledSessionKey = `agent:qa:i1-profiled-${randomUUID()}`;
const profiledRunId = await runGatewayTurn(
profiled.client,
"Reply exactly: I1-PROFILED",
profiledSessionKey,
);
await closeRawGatewayClient(profiled.client);
const profilelessText = await gateway.runCli(["audit", "--run", profilelessRunId, "--explain"]);
const profiledText = await gateway.runCli(["audit", "--run", profiledRunId, "--explain"]);
assertTextProjection(profilelessText);
assertTextProjection(profiledText);
if (
!profilelessText.includes("Invoker [absent]") ||
!profilelessText.includes("Represented subject [absent]") ||
profilelessText.includes("Operator")
) {
throw new Error("profileless text inspection fabricated an operator subject");
}
if (
!profiledText.includes("Invoker [present]") ||
!profiledText.includes("Represented subject [absent]")
) {
throw new Error(
`profiled text inspection omitted durable operator attribution: ${profiledText}`,
);
}
const profilelessBefore = parseJson(
await gateway.runCli(["audit", "--run", profilelessRunId, "--explain", "--json"]),
"profileless Gateway inspection",
) as AuditRunInspectResult;
const profiledBefore = parseJson(
await gateway.runCli(["audit", "--run", profiledRunId, "--explain", "--json"]),
"profiled Gateway inspection",
) as AuditRunInspectResult;
assertGatewayIdentityProjection(profilelessBefore, {
coverage: "unattributed",
invoker: "absent",
});
assertGatewayIdentityProjection(profiledBefore, {
coverage: "attribution-only",
invoker: "present",
});
const profilelessContext = normalizedContextJson(profilelessBefore);
const profiledContext = normalizedContextJson(profiledBefore);
assertPersistedContextBytes(gateway, profilelessRunId, profilelessContext);
assertPersistedContextBytes(gateway, profiledRunId, profiledContext);
const listed = (await gateway.call("sessions.list", {})) as {
sessions?: Array<{
key?: string;
createdActor?: { id?: string; label?: string; type?: string };
}>;
};
const profilelessSession = listed.sessions?.find(
(session) => session.key === profilelessSessionKey,
);
const profiledSession = listed.sessions?.find((session) => session.key === profiledSessionKey);
if (profilelessSession?.createdActor !== undefined) {
throw new Error("profileless Gateway session fabricated a human creator");
}
const profilelessCreator = inspectPersistedSessionCreator(gateway, profilelessSessionKey);
if (
profilelessCreator.type !== null ||
profilelessCreator.id !== null ||
profilelessCreator.labelPersisted
) {
throw new Error("profileless Gateway session persisted a fabricated creator");
}
if (
profiledSession?.createdActor?.type !== "human" ||
!profiledSession.createdActor.id ||
profiledSession.createdActor.label !== "Operator"
) {
throw new Error("profiled Gateway session lost its current profile display projection");
}
const profiledCreator = inspectPersistedSessionCreator(gateway, profiledSessionKey);
if (
profiledCreator.type !== "human" ||
profiledCreator.id !== profiledSession.createdActor.id ||
profiledCreator.labelPersisted
) {
throw new Error("profiled Gateway session did not persist only its authenticated profile id");
}
const repeatedRunId = `identity-repeated-${randomUUID()}`;
let repeatedRows: ReturnType<typeof findRunExecutions> = [];
const repeatedBeforeRestart = new Map<string, string>();
await runRepeatedIngressTurns(gateway, options.repoRoot, repeatedRunId);
const repeatedRows = findRunExecutions(gateway, repeatedRunId);
repeatedRows = findRunExecutions(gateway, repeatedRunId);
if (
repeatedRows.length !== 2 ||
new Set(repeatedRows.map((row) => row.execution_id)).size !== 2 ||
@@ -350,7 +818,6 @@ async function runProof(options: ProducerOptions): Promise<string> {
if (discovery.identity.state !== "ambiguous" || discovery.identity.candidates.length !== 2) {
throw new Error("repeated same-session run was not reported as two ambiguous executions");
}
const repeatedBeforeRestart = new Map<string, string>();
for (const row of repeatedRows) {
const text = await gateway.runCli(["audit", "--execution", row.execution_id, "--explain"]);
assertTextProjection(text);
@@ -389,6 +856,19 @@ async function runProof(options: ProducerOptions): Promise<string> {
if (afterContext !== beforeContext) {
throw new Error("normalized execution identity context bytes changed across Gateway restart");
}
for (const [gatewayRunId, expectedContext, expectedIdentity] of [
[profilelessRunId, profilelessContext, { coverage: "unattributed", invoker: "absent" }],
[profiledRunId, profiledContext, { coverage: "attribution-only", invoker: "present" }],
] as const) {
const afterGateway = parseJson(
await gateway.runCli(["audit", "--run", gatewayRunId, "--explain", "--json"]),
`post-restart Gateway run ${gatewayRunId}`,
) as AuditRunInspectResult;
assertGatewayIdentityProjection(afterGateway, expectedIdentity);
if (normalizedContextJson(afterGateway) !== expectedContext) {
throw new Error(`Gateway execution changed across restart: ${gatewayRunId}`);
}
}
for (const [executionId, expectedContext] of repeatedBeforeRestart) {
const afterExact = parseJson(
await gateway.runCli(["audit", "--execution", executionId, "--explain", "--json"]),
@@ -404,8 +884,8 @@ async function runProof(options: ProducerOptions): Promise<string> {
enabled: false,
executionIdentity: true,
});
await runLocalTurn(gateway!, "Reply exactly: IDENTITY-DISABLED-GLOBAL");
});
await runLocalTurn(gateway, "Reply exactly: IDENTITY-DISABLED-GLOBAL");
if (inspectExecutionIdentityStorage(gateway).rowCount !== retainedBeforeGlobalDisable) {
throw new Error("global audit disable unexpectedly retained a new execution context");
}
@@ -424,6 +904,13 @@ async function runProof(options: ProducerOptions): Promise<string> {
`${JSON.stringify(
{
runId,
gatewayRuns: {
profiled: { runId: profiledRunId, contextSha256: sha256(profiledContext) },
profileless: {
runId: profilelessRunId,
contextSha256: sha256(profilelessContext),
},
},
repeatedRunId,
repeatedExecutions: repeatedRows.map((row) => ({
executionId: row.execution_id,
@@ -450,10 +937,12 @@ async function runProof(options: ProducerOptions): Promise<string> {
)}\n`,
"utf8",
);
return `local run=${runId}; repeated run=${repeatedRunId} executions=${repeatedRows.map((row) => row.execution_id).join(",")}; Gateway pid=${gateway.pid ?? "unknown"}; text+JSON exact selection passed before/after replacement; normalized context sha256=${sha256(beforeContext)}`;
const repeatedDetails = `repeated run=${repeatedRunId} executions=${repeatedRows.map((row) => row.execution_id).join(",")}; exact selection passed`;
return `local run=${runId}; profiled Gateway run=${profiledRunId}; profileless Gateway run=${profilelessRunId}; ${repeatedDetails}; Gateway pid=${gateway.pid ?? "unknown"}; text+JSON and persisted bytes passed before/after replacement; normalized context sha256=${sha256(beforeContext)}`;
} finally {
await gateway?.stop().catch(() => undefined);
await mock.stop();
await fakeTailscale?.cleanup();
}
}