feat(gateway): resolve durable profile identity at connection setup (#111311)

This commit is contained in:
Peter Steinberger
2026-07-19 01:41:35 -07:00
committed by GitHub
parent 8c0e876e85
commit 2e8b042f9f
8 changed files with 219 additions and 68 deletions
@@ -50,6 +50,7 @@ import {
} from "./agent-run-dispatch.js";
import { createAgentRunModelSelectionHandler } from "./agent-run-model-selection.js";
import { resolveSessionRuntimeCwd } from "./agent-session-reset.js";
import { gatewayClientSenderFields } from "./gateway-client-identity.js";
import { emitSessionsChanged } from "./session-change-event.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
@@ -187,9 +188,7 @@ export function startAgentRunExecution(params: {
text: params.effectiveTranscriptInputText,
timestamp: Date.now(),
idempotencyKey: buildRunUserTurnIdempotencyKey(params.runId),
...(params.client?.authenticatedUserId
? { sender: { id: params.client.authenticatedUserId } }
: {}),
...gatewayClientSenderFields(params.client),
...(params.inputProvenance ? { provenance: params.inputProvenance } : {}),
},
target: () => {
@@ -62,6 +62,7 @@ import {
} from "./chat-server-timing.js";
import { normalizeOptionalChatText as normalizeOptionalText } from "./chat-text-normalization.js";
import { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js";
import { gatewayClientSenderFields } from "./gateway-client-identity.js";
import { emitSessionsChanged } from "./session-change-event.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -191,7 +192,7 @@ export const handleChatSend: GatewayRequestHandlers["chat.send"] = async ({
...(systemInputProvenance ? { provenance: systemInputProvenance } : {}),
rawMessage,
...(restartSafeAdmission ? { restartAdmission: restartSafeAdmission } : {}),
...(client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {}),
...gatewayClientSenderFields(client),
senderIsOwner: hasGatewayAdminScope(client),
sessionKey,
...(sessionLoadOptions ? { sessionLoadOptions } : {}),
@@ -0,0 +1,19 @@
// Projects prepared connection identity into user-turn attribution fields.
import type { GatewayClient } from "./shared-types.js";
type GatewayClientSender = { id: string; name?: string };
export function gatewayClientSenderFields(client: GatewayClient | null): {
sender?: GatewayClientSender;
} {
const profile = client?.authenticatedUserProfile;
if (profile) {
return {
sender: {
id: profile.profileId,
...(profile.displayName ? { name: profile.displayName } : {}),
},
};
}
return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {};
}
@@ -70,6 +70,11 @@ export type GatewayClient = {
/** Client id verified against the server-approved device pairing record. */
pairedClientId?: string;
authenticatedUserId?: string;
authenticatedUserProfile?: {
profileId: string;
displayName: string | null;
hasAvatar: boolean;
};
pluginSurfaceUrls?: Record<string, string>;
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
@@ -252,6 +252,11 @@ function createDirectChatContext(): GatewayRequestContext {
async function sendControlUiChat(params: {
authenticatedUserId?: string;
authenticatedUserProfile?: {
profileId: string;
displayName: string | null;
hasAvatar: boolean;
};
context: GatewayRequestContext;
expectedSessionRoutingContract?: string;
idempotencyKey: string;
@@ -280,6 +285,9 @@ async function sendControlUiChat(params: {
params: requestParams,
client: {
...(params.authenticatedUserId ? { authenticatedUserId: params.authenticatedUserId } : {}),
...(params.authenticatedUserProfile
? { authenticatedUserProfile: params.authenticatedUserProfile }
: {}),
connect: {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
@@ -2495,6 +2503,11 @@ describe("gateway server chat", () => {
const context = createDirectChatContext();
const send = async (params: {
authenticatedUserId?: string;
authenticatedUserProfile?: {
profileId: string;
displayName: string | null;
hasAvatar: boolean;
};
idempotencyKey: string;
message: string;
}) => {
@@ -2512,11 +2525,21 @@ describe("gateway server chat", () => {
await send({
authenticatedUserId: "alice@example.com",
authenticatedUserProfile: {
profileId: "0d9f4c35-d221-49da-9a3f-b8c73921066b",
displayName: "Alice",
hasAvatar: false,
},
idempotencyKey: "idem-attributed-alice",
message: "prompt from alice",
});
await send({
authenticatedUserId: "bob@example.com",
authenticatedUserProfile: {
profileId: "77ad3957-b2c8-428a-83d3-fc09e696492e",
displayName: "Bob",
hasAvatar: true,
},
idempotencyKey: "idem-attributed-bob",
message: "prompt from bob",
});
@@ -2538,7 +2561,10 @@ describe("gateway server chat", () => {
message: expect.objectContaining({
role: "user",
content: "prompt from alice",
__openclaw: expect.objectContaining({ senderId: "alice@example.com" }),
__openclaw: expect.objectContaining({
senderId: "0d9f4c35-d221-49da-9a3f-b8c73921066b",
senderName: "Alice",
}),
}),
}),
expect.objectContaining({
@@ -2546,7 +2572,10 @@ describe("gateway server chat", () => {
message: expect.objectContaining({
role: "user",
content: "prompt from bob",
__openclaw: expect.objectContaining({ senderId: "bob@example.com" }),
__openclaw: expect.objectContaining({
senderId: "77ad3957-b2c8-428a-83d3-fc09e696492e",
senderName: "Bob",
}),
}),
}),
expect.objectContaining({
@@ -14,6 +14,7 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js"
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { loadNodeHostConfig } from "../../../node-host/config.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js";
import { ensureProfileForEmail } from "../../../state/user-profiles.js";
import {
isBrowserCopilotClient,
isEphemeralGatewayClient,
@@ -133,6 +134,24 @@ export async function attachAuthenticatedGatewayConnect(
return;
}
let authenticatedUserProfile: GatewayWsClient["authenticatedUserProfile"];
if (authenticatedUserId) {
try {
const profile = ensureProfileForEmail(authenticatedUserId);
// Profile metadata is a connect-time snapshot; edits become visible after reconnect.
authenticatedUserProfile = {
profileId: profile.id,
displayName: profile.displayName,
hasAvatar: profile.avatarMime !== null,
};
} catch (error) {
// Profile storage must not block login; retain the legacy email-only identity on failure.
logWsControl.warn(
`user profile resolution failed conn=${connId} user=${formatForLog(authenticatedUserId)}: ${formatForLog(error)}`,
);
}
}
const pluginSurfaceUrls: Record<string, string> = {};
const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities);
const pendingPluginNodeCapabilities: Array<{
@@ -226,6 +245,7 @@ export async function attachAuthenticatedGatewayConnect(
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
presenceKey,
...(authenticatedUserId ? { authenticatedUserId } : {}),
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
clientIp: reportedClientIp,
...(internal ? { internal } : {}),
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
@@ -325,7 +345,22 @@ export async function attachAuthenticatedGatewayConnect(
scopes,
instanceId: device?.id ?? instanceId,
...(authenticatedUserId
? { user: { id: authenticatedUserId, email: authenticatedUserId } }
? {
user: authenticatedUserProfile
? {
id: authenticatedUserProfile.profileId,
email: authenticatedUserId,
...(authenticatedUserProfile.displayName
? { name: authenticatedUserProfile.displayName }
: {}),
...(authenticatedUserProfile.hasAvatar
? {
avatarUrl: `/api/users/${authenticatedUserProfile.profileId}/avatar`,
}
: {}),
}
: { id: authenticatedUserId, email: authenticatedUserId },
}
: {}),
reason: "connect",
});
@@ -10,6 +10,8 @@ import {
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
} from "../../../infra/diagnostic-events.js";
import { setAvatar } from "../../../state/user-profiles.js";
import { withOpenClawTestState } from "../../../test-utils/openclaw-test-state.js";
import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
@@ -23,6 +25,7 @@ const {
getHealthVersionMock,
incrementPresenceVersionMock,
loadConfigMock,
ensureProfileForEmailMock,
upsertPresenceMock,
} = vi.hoisted(() => ({
buildGatewaySnapshotMock: vi.fn(() => ({
@@ -49,9 +52,16 @@ const {
},
},
})),
ensureProfileForEmailMock: vi.fn(),
upsertPresenceMock: vi.fn(),
}));
vi.mock("../../../state/user-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../state/user-profiles.js")>();
ensureProfileForEmailMock.mockImplementation(actual.ensureProfileForEmail);
return { ...actual, ensureProfileForEmail: ensureProfileForEmailMock };
});
vi.mock("../../../config/config.js", () => ({
getRuntimeConfig: loadConfigMock,
loadConfig: loadConfigMock,
@@ -299,6 +309,57 @@ function attachGatewayHarness(options: {
};
}
function connectTrustedProxyUser(connId: string) {
loadConfigMock.mockImplementationOnce(() => ({
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto"],
},
},
trustedProxies: ["10.0.0.1"],
controlUi: {
allowedOrigins: ["http://127.0.0.1:19001"],
dangerouslyDisableDeviceAuth: true,
},
},
}));
const harness = attachGatewayHarness({
connId,
connectNonce: `nonce-${connId}`,
requestHost: "gateway.example.com:18789",
requestOrigin: "http://127.0.0.1:19001",
remoteAddr: "10.0.0.1",
resolvedAuth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto"],
},
},
headers: {
"x-forwarded-user": "alice@example.com",
"x-forwarded-proto": "https",
},
});
harness.sendConnect(`connect-${connId}`, {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "openclaw-control-ui",
version: "dev",
platform: "test",
mode: "ui",
},
role: "operator",
caps: [],
});
return harness;
}
describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
beforeEach(() => {
resetDiagnosticEventsForTest();
@@ -533,80 +594,76 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
resolveRefresh?.();
});
it("projects trusted-proxy identity into presence and the connected client", async () => {
loadConfigMock.mockImplementationOnce(() => ({
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto"],
},
},
trustedProxies: ["10.0.0.1"],
controlUi: {
allowedOrigins: ["http://127.0.0.1:19001"],
dangerouslyDisableDeviceAuth: true,
},
},
}));
const harness = attachGatewayHarness({
connId: "conn-trusted-proxy-user",
connectNonce: "nonce-trusted-proxy-user",
requestHost: "gateway.example.com:18789",
requestOrigin: "http://127.0.0.1:19001",
remoteAddr: "10.0.0.1",
resolvedAuth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto"],
},
},
headers: {
"x-forwarded-user": "alice@example.com",
"x-forwarded-proto": "https",
},
});
it("projects a stable durable profile into presence and refreshes avatar state on reconnect", async () => {
await withOpenClawTestState({ label: "gateway-profile-presence" }, async () => {
const connect = async (suffix: string) => {
const connId = `conn-trusted-proxy-user-${suffix}`;
const harness = connectTrustedProxyUser(connId);
await waitForFast(() => {
expect(upsertPresenceMock).toHaveBeenCalledWith(connId, expect.anything());
});
const presence = upsertPresenceMock.mock.calls.find(([key]) => key === connId)?.[1] as {
user?: { id: string; email?: string; name?: string; avatarUrl?: string };
};
return { connId, harness, presence };
};
harness.sendConnect("connect-trusted-proxy-user", {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "openclaw-control-ui",
version: "dev",
platform: "test",
mode: "ui",
},
role: "operator",
caps: [],
});
const first = await connect("first");
const profileId = first.presence.user?.id;
expect(profileId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
);
expect(first.presence.user).toEqual({
id: profileId,
email: "alice@example.com",
name: "alice",
});
expect(first.harness.client).toMatchObject({
authenticatedUserId: "alice@example.com",
authenticatedUserProfile: {
profileId,
displayName: "alice",
hasAvatar: false,
},
});
await waitForFast(() => {
expect(harness.socketSend.mock.calls.length + harness.send.mock.calls.length).toBeGreaterThan(
0,
expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true);
const second = await connect("second");
expect(second.presence.user).toEqual({
id: profileId,
email: "alice@example.com",
name: "alice",
avatarUrl: `/api/users/${profileId}/avatar`,
});
expect(second.harness.client).toMatchObject({
authenticatedUserProfile: { profileId, hasAvatar: true },
});
expect(ensureProfileForEmailMock).toHaveBeenCalledTimes(2);
expect(first.harness.logWsControl.info).toHaveBeenCalledWith(
"authenticated user connected conn=conn-trusted-proxy-user-first user=alice@example.com",
);
});
const trustedProxyHello = harness.socketSend.mock.calls.at(0)?.[0];
expect(
typeof trustedProxyHello === "string"
? JSON.parse(trustedProxyHello)
: harness.send.mock.calls.at(0)?.[0],
).toMatchObject({
ok: true,
});
it("falls back to email identity when durable profile resolution fails", async () => {
ensureProfileForEmailMock.mockImplementationOnce(() => {
throw new Error("profile store unavailable");
});
const harness = connectTrustedProxyUser("conn-profile-store-failure");
await waitForFast(() => {
expect(upsertPresenceMock).toHaveBeenCalledWith(
"conn-trusted-proxy-user",
"conn-profile-store-failure",
expect.objectContaining({
user: { id: "alice@example.com", email: "alice@example.com" },
}),
);
});
expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" });
expect(harness.logWsControl.info).toHaveBeenCalledWith(
"authenticated user connected conn=conn-trusted-proxy-user user=alice@example.com",
expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() });
expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1);
expect(harness.logWsControl.warn).toHaveBeenCalledWith(
expect.stringContaining("profile store unavailable"),
);
});
@@ -656,6 +713,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
);
});
expect(harness.client).not.toMatchObject({ authenticatedUserId: expect.anything() });
expect(ensureProfileForEmailMock).not.toHaveBeenCalled();
});
it("emits a security event for rejected gateway auth", async () => {
+5
View File
@@ -33,6 +33,11 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
sharedGatewaySessionGeneration?: string;
presenceKey?: string;
authenticatedUserId?: string;
authenticatedUserProfile?: {
profileId: string;
displayName: string | null;
hasAvatar: boolean;
};
clientIp?: string;
internal?: {
approvalRuntime?: boolean;