diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index cd64b2c856bb..1ef830b3565d 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -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: () => { diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 59cd66be64bf..dd276985c4ec 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -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 } : {}), diff --git a/src/gateway/server-methods/gateway-client-identity.ts b/src/gateway/server-methods/gateway-client-identity.ts new file mode 100644 index 000000000000..85f7028a683a --- /dev/null +++ b/src/gateway/server-methods/gateway-client-identity.ts @@ -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 } } : {}; +} diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 944a67f4af4d..10854e2f2a2a 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -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; pluginNodeCapabilitySurfaces?: Record; pluginNodeCapabilities?: Record; diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 9da0578cfe06..4bcbbdfad2c6 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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({ diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index cf8740b792ae..bb35aed4a38d 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -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 = {}; 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", }); diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index afebb4f2790b..dc1ec248a79c 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -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(); + 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 () => { diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index de6a3163884d..c3f2df4c9de0 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -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;