mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } } : {}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user