mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
feat(gateway): resolve durable profile identity at connection setup (#111311)
This commit is contained in:
committed by
GitHub
parent
8c0e876e85
commit
2e8b042f9f
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user