From e9e67eacba9b8f62c2440d0943f8b2d9eb69e15d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:32:31 +0000 Subject: [PATCH 1/4] feat(telemetry): accept caller-declared user and org identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relaycast workspace is an API-key row — there is no user table — so server telemetry could only ever report workspaces, never people or companies. Callers that do know who is driving a request (the Agent Relay CLI and broker, after `agent-relay cloud login`) can now declare it: `X-Agent-Relay-User-Id` / `-Org-Id` / `-Org-Slug`, or the matching `agent_relay_*` query params on a WebSocket upgrade, since browsers can't set custom headers on a handshake. `emitServerEvent` records them as `actor_user_id` / `actor_org_id` / `actor_org_slug` and prefers the user id as the distinct id, so hosted events land on the same PostHog person as that user's CLI and broker events. These are analytics dimensions only and never affect authorization — malformed values are dropped rather than truncated. The TS SDK accepts `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`, and a supplied user id doubles as the distinct id so a host needs to set only one. Identity resolution is centralized in `resolveAgentRelayIdentity` rather than threaded field-by-field. Fixes a related gap: SDK identity reached HTTP requests but never the WebSocket, so `ws_session_started` was anonymous even for an identified caller. `RelayCast` now forwards it to the socket too. The Python, Rust, and Swift SDKs still send only `agentRelayDistinctId`; adding the user/org fields there is follow-up work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UEGpCHvkLVy7AhPgjrCbcG --- CHANGELOG.md | 2 + .../engine/src/lib/__tests__/origin.test.ts | 97 +++++++++++ packages/engine/src/lib/origin.ts | 86 +++++++++- packages/engine/src/lib/serverTelemetry.ts | 11 +- .../src/__tests__/identity.test.ts | 159 ++++++++++++++++++ .../src/__tests__/relay.test.ts | 55 ++++++ packages/sdk-typescript/src/client.ts | 46 ++++- packages/sdk-typescript/src/origin.ts | 102 +++++++++++ packages/sdk-typescript/src/relay.ts | 59 +++++-- packages/sdk-typescript/src/ws.ts | 25 ++- 10 files changed, 606 insertions(+), 36 deletions(-) create mode 100644 packages/sdk-typescript/src/__tests__/identity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b55bfba..d6ffb43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Packages without a separate changelog are covered by the cross-package notes bel - Durable `agent.exited` event when a node-hosted agent leaves (deregister, missing from an inventory sync, or release), carrying `agent_id`, `agent_name`, `node_id`, the spawn `invocation_id`, and a `reason`; the spawn's caller is notified directly. - Durable `node.status.online` / `node.status.offline` events on node liveness transitions (offline carries a `reason` like `liveness_timeout`). Wildcard webhook subscriptions (`events: ["*"]`) receive all three new events automatically. - `POST /v1/agents/disconnect` accepts an optional `deregister` flag; SDK `disconnect()` and `presence.markOffline()` take `{ deregister?: boolean }` to opt into full node teardown. +- Callers can declare who is behind a request with `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug` (or the matching `agent_relay_*` query params on a WebSocket upgrade). Server telemetry records them as `actor_user_id` / `actor_org_id` / `actor_org_slug` and keys events by the user instead of the workspace, so hosted usage can be reported per person and per organization. The SDK accepts `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`, and a supplied user id doubles as the distinct id. These are analytics dimensions only and never affect authorization. ### Changed @@ -36,6 +37,7 @@ Packages without a separate changelog are covered by the cross-package notes bel ### Fixed +- SDK telemetry identity now reaches the WebSocket connection, not just HTTP requests — `ws_session_started` events were anonymous even when the caller supplied `agentRelayDistinctId`. - Node enrollment (`POST /v1/nodes`) now keys on `node_id` when supplied: re-enrolling rotates (and can rename) the same node in place, and a name held by a different node is rejected with `node_name_conflict` (409) instead of silently rewriting the other node. - Stopped a same-connection node broker `node.register` re-register from silently gating deliveries: already-announced agents keep their delivery readiness, and readiness-gated skips stamp the delivery row with observable retry metadata instead of failing silently. - Recovered WebSocket node messages whose single live dispatch was lost or failed: instead of letting rows sit queued until the mailbox TTL dead-letters them, the periodic delivery sweep now redrives queued ws-node rows (not just `http_push`), replaying each agent's backlog in ascending order so a later message never outruns an earlier one. diff --git a/packages/engine/src/lib/__tests__/origin.test.ts b/packages/engine/src/lib/__tests__/origin.test.ts index 19eeac7c..3aa3f960 100644 --- a/packages/engine/src/lib/__tests__/origin.test.ts +++ b/packages/engine/src/lib/__tests__/origin.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + extractActorIdentity, extractAgentRelayDistinctId, extractOriginActor, UNKNOWN_ORIGIN_ACTOR, @@ -139,3 +140,99 @@ describe("extractAgentRelayDistinctId", () => { ).toBe("a".repeat(128)); }); }); + +function identityReq( + init: { + headers?: Record; + query?: Record; + } = {}, +): Request { + const url = new URL("https://cast.agentrelay.com/v1/activity"); + for (const [key, value] of Object.entries(init.query ?? {})) { + url.searchParams.set(key, value); + } + return new Request(url, { headers: new Headers(init.headers ?? {}) }); +} + +describe("extractActorIdentity", () => { + it("reads user, org, and slug from headers", () => { + expect( + extractActorIdentity( + identityReq({ + headers: { + "X-Agent-Relay-User-Id": "usr_abc123", + "X-Agent-Relay-Org-Id": "org_xyz789", + "X-Agent-Relay-Org-Slug": "agentworkforce", + }, + }), + ), + ).toEqual({ + actor_user_id: "usr_abc123", + actor_org_id: "org_xyz789", + actor_org_slug: "agentworkforce", + }); + }); + + it("falls back to query params for WebSocket upgrades", () => { + expect( + extractActorIdentity( + identityReq({ + query: { + agent_relay_user_id: "usr_abc123", + agent_relay_org_id: "org_xyz789", + agent_relay_org_slug: "agentworkforce", + }, + }), + ), + ).toEqual({ + actor_user_id: "usr_abc123", + actor_org_id: "org_xyz789", + actor_org_slug: "agentworkforce", + }); + }); + + it("prefers the header over the query param", () => { + expect( + extractActorIdentity( + identityReq({ + headers: { "X-Agent-Relay-User-Id": "usr_header" }, + query: { agent_relay_user_id: "usr_query" }, + }), + ).actor_user_id, + ).toBe("usr_header"); + }); + + it("omits fields entirely when absent, so no empty props are emitted", () => { + expect(extractActorIdentity(identityReq())).toEqual({}); + }); + + it("drops malformed values rather than forwarding them", () => { + expect( + extractActorIdentity( + identityReq({ + headers: { + "X-Agent-Relay-User-Id": "usr/abc", + "X-Agent-Relay-Org-Id": " ", + "X-Agent-Relay-Org-Slug": "fine-slug", + }, + }), + ), + ).toEqual({ actor_org_slug: "fine-slug" }); + }); + + it("caps ids at the wire contract length", () => { + expect( + extractActorIdentity( + identityReq({ headers: { "X-Agent-Relay-User-Id": "u".repeat(200) } }), + ).actor_user_id, + ).toHaveLength(128); + }); + + it("caps the org slug at 120 characters", () => { + expect( + extractActorIdentity( + identityReq({ headers: { "X-Agent-Relay-Org-Slug": "s".repeat(200) } }), + ).actor_org_slug, + ).toHaveLength(120); + }); +}); diff --git a/packages/engine/src/lib/origin.ts b/packages/engine/src/lib/origin.ts index 94bd85cc..4f452670 100644 --- a/packages/engine/src/lib/origin.ts +++ b/packages/engine/src/lib/origin.ts @@ -16,6 +16,25 @@ export const ORIGIN_ACTOR_QUERY = "origin_actor"; export const AGENT_RELAY_DISTINCT_ID_HEADER = "X-Agent-Relay-Distinct-Id"; export const AGENT_RELAY_DISTINCT_ID_QUERY = "agent_relay_distinct_id"; +/** + * Who — as a person and an organization — is behind this request. + * + * A relaycast workspace is an API-key row; it has no user table, so the gateway + * cannot derive a human identity on its own. Callers that *do* know one (the + * Agent Relay CLI and broker, after `agent-relay cloud login`) forward it here + * so server-side product events can be grouped by user and org instead of only + * by workspace. + * + * All three are optional and untrusted: they are analytics dimensions only and + * must never gate authorization. + */ +export const AGENT_RELAY_USER_ID_HEADER = "X-Agent-Relay-User-Id"; +export const AGENT_RELAY_USER_ID_QUERY = "agent_relay_user_id"; +export const AGENT_RELAY_ORG_ID_HEADER = "X-Agent-Relay-Org-Id"; +export const AGENT_RELAY_ORG_ID_QUERY = "agent_relay_org_id"; +export const AGENT_RELAY_ORG_SLUG_HEADER = "X-Agent-Relay-Org-Slug"; +export const AGENT_RELAY_ORG_SLUG_QUERY = "agent_relay_org_slug"; + /** Fallback value when the origin actor is missing or invalid. */ export const UNKNOWN_ORIGIN_ACTOR = "unknown"; @@ -61,17 +80,78 @@ export function extractOriginActor(request: Request): string { export function extractAgentRelayDistinctId( request: Request, +): string | undefined { + return readIdentityValue( + request, + AGENT_RELAY_DISTINCT_ID_HEADER, + AGENT_RELAY_DISTINCT_ID_QUERY, + ); +} + +/** + * Read one identity dimension from a header, falling back to a query param — + * WebSocket upgrades from browsers can't set custom headers, so the SDK forwards + * these on the query string (mirrors how `origin_actor` works). + * + * Rejects anything outside the distinct-id charset rather than truncating, which + * is what keeps a malformed upstream value from smuggling a header injection or + * a misleading id into analytics. + */ +function readIdentityValue( + request: Request, + header: string, + query: string, + maxLength = 128, ): string | undefined { const raw = - request.headers.get(AGENT_RELAY_DISTINCT_ID_HEADER) ?? - new URL(request.url).searchParams.get(AGENT_RELAY_DISTINCT_ID_QUERY); + request.headers.get(header) ?? + new URL(request.url).searchParams.get(query); if (!raw) return undefined; const trimmed = raw.trim(); if (!trimmed) return undefined; if (!AGENT_RELAY_DISTINCT_ID_ALLOWED.test(trimmed)) return undefined; - return trimmed.slice(0, 128); + return trimmed.slice(0, maxLength); +} + +export interface ActorIdentity { + /** Agent Relay Cloud user id of the operator behind the request. */ + actor_user_id?: string; + /** Agent Relay Cloud organization id, used for PostHog group analytics. */ + actor_org_id?: string; + /** Organization slug, for breakdowns that shouldn't show opaque ids. */ + actor_org_slug?: string; +} + +/** + * Extract the caller-declared user/org identity. Returns an object with only the + * fields that were present and well-formed, so it can be spread straight into + * telemetry properties without emitting empty values. + */ +export function extractActorIdentity(request: Request): ActorIdentity { + const userId = readIdentityValue( + request, + AGENT_RELAY_USER_ID_HEADER, + AGENT_RELAY_USER_ID_QUERY, + ); + const orgId = readIdentityValue( + request, + AGENT_RELAY_ORG_ID_HEADER, + AGENT_RELAY_ORG_ID_QUERY, + ); + const orgSlug = readIdentityValue( + request, + AGENT_RELAY_ORG_SLUG_HEADER, + AGENT_RELAY_ORG_SLUG_QUERY, + 120, + ); + + return { + ...(userId ? { actor_user_id: userId } : {}), + ...(orgId ? { actor_org_id: orgId } : {}), + ...(orgSlug ? { actor_org_slug: orgSlug } : {}), + }; } function sanitizeOriginPart( diff --git a/packages/engine/src/lib/serverTelemetry.ts b/packages/engine/src/lib/serverTelemetry.ts index 891dbd18..2d617adc 100644 --- a/packages/engine/src/lib/serverTelemetry.ts +++ b/packages/engine/src/lib/serverTelemetry.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import type { AppEnv } from "../env.js"; import { + extractActorIdentity, extractAgentRelayDistinctId, extractOriginActor, requiredOriginInfo, @@ -56,14 +57,22 @@ export function emitServerEvent( const origin = requiredOriginInfo(c.req.raw); const clientDistinctId = extractAgentRelayDistinctId(c.req.raw); + // Caller-declared user/org. Analytics dimensions only — never authorization. + const actor = extractActorIdentity(c.req.raw); c.get("engine").telemetry.capture({ name: event, - distinctId: clientDistinctId ?? workspaceId, + // Prefer the caller's user id: it puts these server events on the same + // PostHog person as that user's CLI/broker events. `client_distinct_id` is + // already the user id when the CLI is signed in; the explicit fallback + // chain also covers callers that send only one of the two headers. + distinctId: actor.actor_user_id ?? clientDistinctId ?? workspaceId, properties: { app: "relaycast-server", surface: "cloud", workspace_id: workspaceId, ...(clientDistinctId ? { client_distinct_id: clientDistinctId } : {}), + is_authenticated: Boolean(actor.actor_user_id), + ...actor, origin_actor: originActor, origin_client: origin.origin_client, origin_version: origin.origin_version, diff --git a/packages/sdk-typescript/src/__tests__/identity.test.ts b/packages/sdk-typescript/src/__tests__/identity.test.ts new file mode 100644 index 00000000..80a226bc --- /dev/null +++ b/packages/sdk-typescript/src/__tests__/identity.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + agentRelayIdentityHeaders, + applyAgentRelayIdentityQuery, + resolveAgentRelayIdentity, + sanitizeAgentRelayOrgSlug, +} from '../origin.js'; +import { HttpClient } from '../client.js'; + +describe('resolveAgentRelayIdentity', () => { + it('takes the first source that sets a field', () => { + expect( + resolveAgentRelayIdentity( + { agentRelayUserId: 'usr_internal' }, + { agentRelayUserId: 'usr_public', agentRelayOrgId: 'org_public' }, + ), + ).toEqual({ + distinctId: 'usr_internal', + userId: 'usr_internal', + orgId: 'org_public', + }); + }); + + it('uses the user id as the distinct id when none is given', () => { + expect(resolveAgentRelayIdentity({ agentRelayUserId: 'usr_abc123' })).toEqual({ + distinctId: 'usr_abc123', + userId: 'usr_abc123', + }); + }); + + it('keeps an explicit distinct id distinct from the user id', () => { + expect( + resolveAgentRelayIdentity({ + agentRelayDistinctId: 'abc123def4567890', + agentRelayUserId: 'usr_abc123', + }), + ).toMatchObject({ distinctId: 'abc123def4567890', userId: 'usr_abc123' }); + }); + + it('drops malformed values instead of forwarding them', () => { + expect( + resolveAgentRelayIdentity({ + agentRelayUserId: 'usr\r\nX-Inject: bad', + agentRelayOrgId: 'org/slash', + agentRelayOrgSlug: 'fine-slug', + }), + ).toEqual({ orgSlug: 'fine-slug' }); + }); + + it('is empty for an anonymous caller', () => { + expect(resolveAgentRelayIdentity({}, undefined)).toEqual({}); + }); + + it('caps the org slug at 120 characters', () => { + expect(sanitizeAgentRelayOrgSlug('s'.repeat(200))).toHaveLength(120); + }); +}); + +describe('agentRelayIdentityHeaders', () => { + it('emits only the fields that are set', () => { + expect(agentRelayIdentityHeaders({ userId: 'usr_1' })).toEqual({ + 'X-Agent-Relay-User-Id': 'usr_1', + }); + expect(agentRelayIdentityHeaders({})).toEqual({}); + }); +}); + +describe('applyAgentRelayIdentityQuery', () => { + it('mirrors identity onto the WS query string', () => { + const url = new URL('wss://cast.agentrelay.com/v1/ws'); + applyAgentRelayIdentityQuery(url, { + distinctId: 'usr_abc123', + userId: 'usr_abc123', + orgId: 'org_xyz789', + orgSlug: 'agentworkforce', + }); + + expect(url.searchParams.get('agent_relay_distinct_id')).toBe('usr_abc123'); + expect(url.searchParams.get('agent_relay_user_id')).toBe('usr_abc123'); + expect(url.searchParams.get('agent_relay_org_id')).toBe('org_xyz789'); + expect(url.searchParams.get('agent_relay_org_slug')).toBe('agentworkforce'); + }); + + it('adds nothing for an anonymous caller', () => { + const url = new URL('wss://cast.agentrelay.com/v1/ws'); + applyAgentRelayIdentityQuery(url, {}); + expect(url.search).toBe(''); + }); +}); + +describe('HttpClient identity', () => { + it('exposes the resolved identity and preserves it across key rotation', () => { + const client = new HttpClient({ + apiKey: 'rk_live_1', + agentRelayUserId: 'usr_abc123', + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'agentworkforce', + }); + + expect(client.agentRelayUserId).toBe('usr_abc123'); + expect(client.agentRelayOrgId).toBe('org_xyz789'); + expect(client.agentRelayOrgSlug).toBe('agentworkforce'); + expect(client.agentRelayDistinctId).toBe('usr_abc123'); + + const rotated = client.withApiKey('rk_live_2'); + expect(rotated.agentRelayUserId).toBe('usr_abc123'); + expect(rotated.agentRelayOrgId).toBe('org_xyz789'); + expect(rotated.agentRelayOrgSlug).toBe('agentworkforce'); + }); +}); + +describe('RelayCast WebSocket identity', () => { + class MockWebSocket { + static readonly OPEN = 1; + static instances: MockWebSocket[] = []; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + readyState = MockWebSocket.OPEN; + send = vi.fn(); + close = vi.fn(); + + constructor(readonly url: string) { + MockWebSocket.instances.push(this); + } + } + + beforeEach(() => { + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('forwards identity onto the socket, not just HTTP requests', async () => { + const { RelayCast } = await import('../relay.js'); + + const relay = new RelayCast({ + apiKey: 'rk_live_1', + baseUrl: 'http://localhost:8080', + agentRelayUserId: 'usr_abc123', + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'agentworkforce', + }); + relay.connect(); + + const url = new URL(MockWebSocket.instances[0]!.url); + expect(url.searchParams.get('agent_relay_user_id')).toBe('usr_abc123'); + expect(url.searchParams.get('agent_relay_org_id')).toBe('org_xyz789'); + expect(url.searchParams.get('agent_relay_org_slug')).toBe('agentworkforce'); + expect(url.searchParams.get('agent_relay_distinct_id')).toBe('usr_abc123'); + + relay.disconnect(); + }); +}); diff --git a/packages/sdk-typescript/src/__tests__/relay.test.ts b/packages/sdk-typescript/src/__tests__/relay.test.ts index dfa9526e..34d637f6 100644 --- a/packages/sdk-typescript/src/__tests__/relay.test.ts +++ b/packages/sdk-typescript/src/__tests__/relay.test.ts @@ -1012,6 +1012,61 @@ describe('RelayCast', () => { expect(init.headers['X-Agent-Relay-Distinct-Id']).toBe('abc123def4567890'); }); + it('forwards the cloud user and org identity headers', async () => { + const { RelayCast } = await import('../relay.js'); + + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + ok: true, + data: { workspace_id: 'ws_1', api_key: 'rk_live_new', created_at: '2024-01-01' }, + }), + }), + ); + + await RelayCast.createWorkspace('Test', { + baseUrl: 'http://localhost:3000', + agentRelayUserId: 'usr_abc123', + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'agentworkforce', + }); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.headers['X-Agent-Relay-User-Id']).toBe('usr_abc123'); + expect(init.headers['X-Agent-Relay-Org-Id']).toBe('org_xyz789'); + expect(init.headers['X-Agent-Relay-Org-Slug']).toBe('agentworkforce'); + // A signed-in user id doubles as the distinct id so callers need only one. + expect(init.headers['X-Agent-Relay-Distinct-Id']).toBe('usr_abc123'); + }); + + it('drops a malformed user id rather than forwarding it', async () => { + const { RelayCast } = await import('../relay.js'); + + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + ok: true, + data: { workspace_id: 'ws_1', api_key: 'rk_live_new', created_at: '2024-01-01' }, + }), + }), + ); + + await RelayCast.createWorkspace('Test', { + baseUrl: 'http://localhost:3000', + agentRelayUserId: 'usr\r\nX-Inject: bad', + }); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.headers['X-Agent-Relay-User-Id']).toBeUndefined(); + expect(init.headers['X-Agent-Relay-Distinct-Id']).toBeUndefined(); + }); + it('returns an existing workspace on idempotent duplicate create', async () => { const { RelayCast } = await import('../relay.js'); diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 01bbd5d0..190746f7 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -2,11 +2,12 @@ import { z } from 'zod'; import { ApiErrorSchema } from '@relaycast/types'; import { SDK_VERSION } from './version.js'; import { - AGENT_RELAY_DISTINCT_ID_HEADER, ORIGIN_ACTOR_HEADER, SDK_ORIGIN, - sanitizeAgentRelayDistinctId, + agentRelayIdentityHeaders, + resolveAgentRelayIdentity, sanitizeOriginActor, + type AgentRelayIdentity, type InternalOrigin, } from './origin.js'; import { camelizeKeys, decamelizeKey, decamelizeKeys, type Camelize } from './casing.js'; @@ -30,6 +31,16 @@ export interface ClientOptions { * values are dropped. */ agentRelayDistinctId?: string; + /** + * Optional Agent Relay Cloud user id of the signed-in operator. Sent as the + * `X-Agent-Relay-User-Id` header, and used as the distinct id when + * `agentRelayDistinctId` is unset so both sides report one PostHog person. + */ + agentRelayUserId?: string; + /** Optional Agent Relay Cloud organization id, for group analytics. */ + agentRelayOrgId?: string; + /** Optional organization slug, for readable analytics breakdowns. */ + agentRelayOrgSlug?: string; } export interface RequestOptions { @@ -146,7 +157,7 @@ export class HttpClient { private _originClient: string; private _originVersion: string; private _originActor?: string; - private _agentRelayDistinctId?: string; + private _identity: AgentRelayIdentity; private _retryPolicy: RetryPolicy; constructor(options: ClientOptions) { @@ -158,9 +169,8 @@ export class HttpClient { // A wrapping host's internal origin is authoritative about the originActor; // fall back to the public `originActor` option for plain consumers. this._originActor = sanitizeOriginActor(origin.originActor ?? options.originActor); - this._agentRelayDistinctId = sanitizeAgentRelayDistinctId( - origin.agentRelayDistinctId ?? options.agentRelayDistinctId, - ); + // A wrapping host's internal origin wins over the public options. + this._identity = resolveAgentRelayIdentity(origin, options); this._retryPolicy = normalizeRetryPolicy(options.retryPolicy); } @@ -187,7 +197,22 @@ export class HttpClient { /** Sanitized Agent Relay distinct id, or `undefined` when none was supplied. */ get agentRelayDistinctId(): string | undefined { - return this._agentRelayDistinctId; + return this._identity.distinctId; + } + + /** Sanitized Agent Relay Cloud user id, or `undefined` when none was supplied. */ + get agentRelayUserId(): string | undefined { + return this._identity.userId; + } + + /** Sanitized Agent Relay Cloud organization id, or `undefined`. */ + get agentRelayOrgId(): string | undefined { + return this._identity.orgId; + } + + /** Sanitized Agent Relay Cloud organization slug, or `undefined`. */ + get agentRelayOrgSlug(): string | undefined { + return this._identity.orgSlug; } get retryPolicy(): RetryPolicy { @@ -201,7 +226,10 @@ export class HttpClient { client: this._originClient, version: this._originVersion, ...(this._originActor ? { originActor: this._originActor } : {}), - ...(this._agentRelayDistinctId ? { agentRelayDistinctId: this._agentRelayDistinctId } : {}), + ...(this._identity.distinctId ? { agentRelayDistinctId: this._identity.distinctId } : {}), + ...(this._identity.userId ? { agentRelayUserId: this._identity.userId } : {}), + ...(this._identity.orgId ? { agentRelayOrgId: this._identity.orgId } : {}), + ...(this._identity.orgSlug ? { agentRelayOrgSlug: this._identity.orgSlug } : {}), }, )); } @@ -226,7 +254,7 @@ export class HttpClient { 'X-Relaycast-Origin-Client': this._originClient, 'X-Relaycast-Origin-Version': this._originVersion, ...(this._originActor ? { [ORIGIN_ACTOR_HEADER]: this._originActor } : {}), - ...(this._agentRelayDistinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: this._agentRelayDistinctId } : {}), + ...agentRelayIdentityHeaders(this._identity), ...(options?.headers || {}), }; diff --git a/packages/sdk-typescript/src/origin.ts b/packages/sdk-typescript/src/origin.ts index 3f824619..46d0b156 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -16,6 +16,17 @@ export interface InternalOrigin { * without sending user-identifying data. */ agentRelayDistinctId?: string; + /** + * Optional Agent Relay Cloud user id of the signed-in operator. Relaycast has + * no user table of its own, so hosts that know who is driving the process + * forward it here to let server-side telemetry report real users and orgs + * rather than only workspaces. + */ + agentRelayUserId?: string; + /** Optional Agent Relay Cloud organization id, for group analytics. */ + agentRelayOrgId?: string; + /** Optional organization slug, for readable analytics breakdowns. */ + agentRelayOrgSlug?: string; } export const SDK_ORIGIN: InternalOrigin = Object.freeze({ @@ -31,6 +42,12 @@ export const SDK_ORIGIN: InternalOrigin = Object.freeze({ export const ORIGIN_ACTOR_HEADER = 'X-Relaycast-Origin-Actor'; export const AGENT_RELAY_DISTINCT_ID_HEADER = 'X-Agent-Relay-Distinct-Id'; export const AGENT_RELAY_DISTINCT_ID_QUERY = 'agent_relay_distinct_id'; +export const AGENT_RELAY_USER_ID_HEADER = 'X-Agent-Relay-User-Id'; +export const AGENT_RELAY_USER_ID_QUERY = 'agent_relay_user_id'; +export const AGENT_RELAY_ORG_ID_HEADER = 'X-Agent-Relay-Org-Id'; +export const AGENT_RELAY_ORG_ID_QUERY = 'agent_relay_org_id'; +export const AGENT_RELAY_ORG_SLUG_HEADER = 'X-Agent-Relay-Org-Slug'; +export const AGENT_RELAY_ORG_SLUG_QUERY = 'agent_relay_org_slug'; /** Upper bound on the originActor identifier — generous enough for a UA-style token. */ const ORIGIN_ACTOR_MAX_LENGTH = 128; @@ -67,3 +84,88 @@ export function sanitizeAgentRelayDistinctId(raw: string | undefined): string | if (!AGENT_RELAY_DISTINCT_ID_ALLOWED.test(trimmed)) return undefined; return trimmed.slice(0, AGENT_RELAY_DISTINCT_ID_MAX_LENGTH); } + +/** Identity ids share the distinct-id contract: same charset, same length cap. */ +export const sanitizeAgentRelayUserId = sanitizeAgentRelayDistinctId; +export const sanitizeAgentRelayOrgId = sanitizeAgentRelayDistinctId; + +export function sanitizeAgentRelayOrgSlug(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (!AGENT_RELAY_DISTINCT_ID_ALLOWED.test(trimmed)) return undefined; + return trimmed.slice(0, 120); +} + +/** + * Resolved identity for a client, normalized once at construction. + * + * A signed-in user id doubles as the distinct id, so a host that knows the user + * doesn't have to set both — the two sides stay on one PostHog person either way. + */ +export interface AgentRelayIdentity { + distinctId?: string; + userId?: string; + orgId?: string; + orgSlug?: string; +} + +export function resolveAgentRelayIdentity( + ...sources: Array | undefined> +): AgentRelayIdentity { + const pick = (key: keyof InternalOrigin): string | undefined => { + for (const source of sources) { + const value = source?.[key]; + if (typeof value === 'string' && value.trim()) return value; + } + return undefined; + }; + + const userId = sanitizeAgentRelayUserId(pick('agentRelayUserId')); + const distinctId = sanitizeAgentRelayDistinctId(pick('agentRelayDistinctId')) ?? userId; + + return { + ...(distinctId ? { distinctId } : {}), + ...(userId ? { userId } : {}), + ...(sanitizeAgentRelayOrgId(pick('agentRelayOrgId')) + ? { orgId: sanitizeAgentRelayOrgId(pick('agentRelayOrgId')) } + : {}), + ...(sanitizeAgentRelayOrgSlug(pick('agentRelayOrgSlug')) + ? { orgSlug: sanitizeAgentRelayOrgSlug(pick('agentRelayOrgSlug')) } + : {}), + }; +} + +/** Identity headers for an HTTP request. Omits anything unset. */ +export function agentRelayIdentityHeaders( + identity: AgentRelayIdentity +): Record { + return { + ...(identity.distinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: identity.distinctId } : {}), + ...(identity.userId ? { [AGENT_RELAY_USER_ID_HEADER]: identity.userId } : {}), + ...(identity.orgId ? { [AGENT_RELAY_ORG_ID_HEADER]: identity.orgId } : {}), + ...(identity.orgSlug ? { [AGENT_RELAY_ORG_SLUG_HEADER]: identity.orgSlug } : {}), + }; +} + +/** + * Identity query params for a WebSocket upgrade — browsers can't set custom + * headers on a WS handshake, so the same values ride the query string. + */ +export function applyAgentRelayIdentityQuery( + url: URL, + identity: AgentRelayIdentity +): void { + if (identity.distinctId) { + url.searchParams.set(AGENT_RELAY_DISTINCT_ID_QUERY, identity.distinctId); + } + if (identity.userId) { + url.searchParams.set(AGENT_RELAY_USER_ID_QUERY, identity.userId); + } + if (identity.orgId) { + url.searchParams.set(AGENT_RELAY_ORG_ID_QUERY, identity.orgId); + } + if (identity.orgSlug) { + url.searchParams.set(AGENT_RELAY_ORG_SLUG_QUERY, identity.orgSlug); + } +} diff --git a/packages/sdk-typescript/src/relay.ts b/packages/sdk-typescript/src/relay.ts index 8360f9c8..8ad79593 100644 --- a/packages/sdk-typescript/src/relay.ts +++ b/packages/sdk-typescript/src/relay.ts @@ -126,7 +126,11 @@ import { type ResolvedIdentity, } from './identity.js'; import { SDK_VERSION } from './version.js'; -import { AGENT_RELAY_DISTINCT_ID_HEADER, SDK_ORIGIN, sanitizeAgentRelayDistinctId } from './origin.js'; +import { + SDK_ORIGIN, + agentRelayIdentityHeaders, + resolveAgentRelayIdentity, +} from './origin.js'; import { camelizeKeys } from './casing.js'; export interface RelayCastOptions { @@ -151,17 +155,33 @@ export interface RelayCastOptions { * joined to Agent Relay CLI telemetry without sending user-identifying data. */ agentRelayDistinctId?: string; + /** + * Optional Agent Relay Cloud user id of the signed-in operator, sent as + * `X-Agent-Relay-User-Id`. Doubles as the distinct id when + * `agentRelayDistinctId` is unset. + */ + agentRelayUserId?: string; + /** Optional Agent Relay Cloud organization id, for group analytics. */ + agentRelayOrgId?: string; + /** Optional organization slug, for readable analytics breakdowns. */ + agentRelayOrgSlug?: string; } -export interface WorkspaceBootstrapOptions { +/** Identity fields accepted by the unauthenticated workspace bootstrap calls. */ +export interface WorkspaceIdentityOptions { + agentRelayDistinctId?: string; + agentRelayUserId?: string; + agentRelayOrgId?: string; + agentRelayOrgSlug?: string; +} + +export interface WorkspaceBootstrapOptions extends WorkspaceIdentityOptions { apiKey?: string; baseUrl?: string; - agentRelayDistinctId?: string; } -export interface WorkspaceLookupOptions { +export interface WorkspaceLookupOptions extends WorkspaceIdentityOptions { baseUrl?: string; - agentRelayDistinctId?: string; } export interface AgentReconnectOptions { @@ -220,6 +240,18 @@ export class RelayCast { client: this.client.originClient, version: this.client.originVersion, ...(this.client.originActor ? { originActor: this.client.originActor } : {}), + // Identity was only reaching HTTP requests, leaving every + // `ws_session_started` event anonymous even for a signed-in caller. + ...(this.client.agentRelayDistinctId + ? { agentRelayDistinctId: this.client.agentRelayDistinctId } + : {}), + ...(this.client.agentRelayUserId + ? { agentRelayUserId: this.client.agentRelayUserId } + : {}), + ...(this.client.agentRelayOrgId ? { agentRelayOrgId: this.client.agentRelayOrgId } : {}), + ...(this.client.agentRelayOrgSlug + ? { agentRelayOrgSlug: this.client.agentRelayOrgSlug } + : {}), }, )); } @@ -292,10 +324,10 @@ export class RelayCast { name: string, options?: string | WorkspaceBootstrapOptions, ): Promise<{ data: CreateWorkspaceResponse; statusCode: number }> { - const { apiKey, baseUrl, agentRelayDistinctId: rawAgentRelayDistinctId } = - resolveWorkspaceBootstrapOptions(options); + const resolved = resolveWorkspaceBootstrapOptions(options); + const { apiKey, baseUrl } = resolved; const requestBaseUrl = baseUrl ?? 'https://cast.agentrelay.com'; - const agentRelayDistinctId = sanitizeAgentRelayDistinctId(rawAgentRelayDistinctId); + const identity = resolveAgentRelayIdentity(resolved); const url = new URL('/v1/workspaces', requestBaseUrl); const res = await fetch(url.toString(), { @@ -306,7 +338,7 @@ export class RelayCast { 'X-SDK-Version': SDK_VERSION, 'X-Relaycast-Origin-Client': SDK_ORIGIN.client, 'X-Relaycast-Origin-Version': SDK_ORIGIN.version, - ...(agentRelayDistinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: agentRelayDistinctId } : {}), + ...agentRelayIdentityHeaders(identity), }, body: JSON.stringify({ name }), }); @@ -345,10 +377,9 @@ export class RelayCast { name: string, options?: string | WorkspaceLookupOptions, ): Promise { - const { baseUrl, agentRelayDistinctId: rawAgentRelayDistinctId } = - resolveWorkspaceLookupOptions(options); - const requestBaseUrl = baseUrl ?? 'https://cast.agentrelay.com'; - const agentRelayDistinctId = sanitizeAgentRelayDistinctId(rawAgentRelayDistinctId); + const resolved = resolveWorkspaceLookupOptions(options); + const requestBaseUrl = resolved.baseUrl ?? 'https://cast.agentrelay.com'; + const identity = resolveAgentRelayIdentity(resolved); const url = new URL(`/v1/workspaces/by-name/${encodeURIComponent(name)}`, requestBaseUrl); const res = await fetch(url.toString(), { @@ -358,7 +389,7 @@ export class RelayCast { 'X-SDK-Version': SDK_VERSION, 'X-Relaycast-Origin-Client': SDK_ORIGIN.client, 'X-Relaycast-Origin-Version': SDK_ORIGIN.version, - ...(agentRelayDistinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: agentRelayDistinctId } : {}), + ...agentRelayIdentityHeaders(identity), }, }); diff --git a/packages/sdk-typescript/src/ws.ts b/packages/sdk-typescript/src/ws.ts index dcd0fad4..84c3fdf3 100644 --- a/packages/sdk-typescript/src/ws.ts +++ b/packages/sdk-typescript/src/ws.ts @@ -9,10 +9,11 @@ import type { } from './types.js'; import { ServerEventSchema } from '@relaycast/types'; import { - AGENT_RELAY_DISTINCT_ID_QUERY, SDK_ORIGIN, - sanitizeAgentRelayDistinctId, + applyAgentRelayIdentityQuery, + resolveAgentRelayIdentity, sanitizeOriginActor, + type AgentRelayIdentity, type InternalOrigin, } from './origin.js'; import { camelizeKeys, decamelizeKey } from './casing.js'; @@ -44,6 +45,16 @@ export interface WsClientOptions { * headers). Invalid values are dropped. */ agentRelayDistinctId?: string; + /** + * Optional Agent Relay Cloud user id of the signed-in operator, forwarded as + * the `agent_relay_user_id` query param. Doubles as the distinct id when + * `agentRelayDistinctId` is unset. + */ + agentRelayUserId?: string; + /** Optional Agent Relay Cloud organization id, for group analytics. */ + agentRelayOrgId?: string; + /** Optional organization slug, for readable analytics breakdowns. */ + agentRelayOrgSlug?: string; } /** @@ -108,7 +119,7 @@ export class WsClient { private originClient: string; private originVersion: string; private originActor?: string; - private agentRelayDistinctId?: string; + private identity: AgentRelayIdentity; /** Highest `agent_seq` observed across delivered events; null until the first stamped event. */ private lastSeenSeq: number | null = null; /** Receive time of the last seq-stamped event, used as `since` for DB-backed replay. */ @@ -142,9 +153,7 @@ export class WsClient { this.originClient = origin.client; this.originVersion = origin.version; this.originActor = sanitizeOriginActor(origin.originActor ?? options.originActor); - this.agentRelayDistinctId = sanitizeAgentRelayDistinctId( - origin.agentRelayDistinctId ?? options.agentRelayDistinctId, - ); + this.identity = resolveAgentRelayIdentity(origin, options); } connect(): void { @@ -178,9 +187,7 @@ export class WsClient { if (this.originActor) { wsUrl.searchParams.set('origin_actor', this.originActor); } - if (this.agentRelayDistinctId) { - wsUrl.searchParams.set(AGENT_RELAY_DISTINCT_ID_QUERY, this.agentRelayDistinctId); - } + applyAgentRelayIdentityQuery(wsUrl, this.identity); const ws = new WebSocket(wsUrl.toString()); this.ws = ws; From 94622a77c6f3e4e379f00138201c993eceacec51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:50:02 +0000 Subject: [PATCH 2/4] feat(telemetry): add actor_machine_id so machine cross-tabs survive login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keying events on the user id answered "how many people" but destroyed "how many machines": once a caller signs in, the distinct id IS the user id, so the machine dimension disappeared exactly when it got interesting. `X-Agent-Relay-Machine-Id` (and `agent_relay_machine_id` on a WS upgrade) is now read into `actor_machine_id`, reported alongside `actor_user_id` rather than instead of it. With `workspace_id` already on every server event, that makes three questions answerable: - machines per workspace — distinct actor_machine_id by workspace_id - accounts per machine — distinct actor_user_id by actor_machine_id - machines per account — distinct actor_machine_id by actor_user_id The second is the interesting one: several machines on one workspace signed into different cloud accounts is a shared team workspace; the same account everywhere is one person on several hosts. The machine id is also the distinct-id fallback for anonymous callers, so not-logged-in traffic is still attributable to a host rather than collapsing onto the workspace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UEGpCHvkLVy7AhPgjrCbcG --- CHANGELOG.md | 2 +- .../engine/src/lib/__tests__/origin.test.ts | 2 + packages/engine/src/lib/origin.ts | 26 +++++++++++-- .../src/__tests__/identity.test.ts | 38 +++++++++++++++++++ packages/sdk-typescript/src/client.ts | 11 ++++++ packages/sdk-typescript/src/origin.ts | 20 +++++++++- packages/sdk-typescript/src/relay.ts | 6 +++ packages/sdk-typescript/src/ws.ts | 2 + 8 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ffb43e..50d4a031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Packages without a separate changelog are covered by the cross-package notes bel - Durable `agent.exited` event when a node-hosted agent leaves (deregister, missing from an inventory sync, or release), carrying `agent_id`, `agent_name`, `node_id`, the spawn `invocation_id`, and a `reason`; the spawn's caller is notified directly. - Durable `node.status.online` / `node.status.offline` events on node liveness transitions (offline carries a `reason` like `liveness_timeout`). Wildcard webhook subscriptions (`events: ["*"]`) receive all three new events automatically. - `POST /v1/agents/disconnect` accepts an optional `deregister` flag; SDK `disconnect()` and `presence.markOffline()` take `{ deregister?: boolean }` to opt into full node teardown. -- Callers can declare who is behind a request with `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug` (or the matching `agent_relay_*` query params on a WebSocket upgrade). Server telemetry records them as `actor_user_id` / `actor_org_id` / `actor_org_slug` and keys events by the user instead of the workspace, so hosted usage can be reported per person and per organization. The SDK accepts `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`, and a supplied user id doubles as the distinct id. These are analytics dimensions only and never affect authorization. +- Callers can declare who is behind a request with `X-Agent-Relay-Machine-Id` / `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug` (or the matching `agent_relay_*` query params on a WebSocket upgrade). Server telemetry records them as `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug` and keys events by the user instead of the workspace, so hosted usage can be reported per machine, per person, and per organization — including how many machines share a workspace and whether they are signed into one account or several. The SDK accepts `agentRelayMachineId` / `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`; a supplied user id doubles as the distinct id, and the machine id is always sent alongside it rather than instead of it. These are analytics dimensions only and never affect authorization. ### Changed diff --git a/packages/engine/src/lib/__tests__/origin.test.ts b/packages/engine/src/lib/__tests__/origin.test.ts index 3aa3f960..7a1c1fb8 100644 --- a/packages/engine/src/lib/__tests__/origin.test.ts +++ b/packages/engine/src/lib/__tests__/origin.test.ts @@ -178,6 +178,7 @@ describe("extractActorIdentity", () => { extractActorIdentity( identityReq({ query: { + agent_relay_machine_id: "abc123def4567890", agent_relay_user_id: "usr_abc123", agent_relay_org_id: "org_xyz789", agent_relay_org_slug: "agentworkforce", @@ -185,6 +186,7 @@ describe("extractActorIdentity", () => { }), ), ).toEqual({ + actor_machine_id: "abc123def4567890", actor_user_id: "usr_abc123", actor_org_id: "org_xyz789", actor_org_slug: "agentworkforce", diff --git a/packages/engine/src/lib/origin.ts b/packages/engine/src/lib/origin.ts index 4f452670..04c72251 100644 --- a/packages/engine/src/lib/origin.ts +++ b/packages/engine/src/lib/origin.ts @@ -17,17 +17,19 @@ export const AGENT_RELAY_DISTINCT_ID_HEADER = "X-Agent-Relay-Distinct-Id"; export const AGENT_RELAY_DISTINCT_ID_QUERY = "agent_relay_distinct_id"; /** - * Who — as a person and an organization — is behind this request. + * Who — as a machine, a person, and an organization — is behind this request. * * A relaycast workspace is an API-key row; it has no user table, so the gateway * cannot derive a human identity on its own. Callers that *do* know one (the * Agent Relay CLI and broker, after `agent-relay cloud login`) forward it here - * so server-side product events can be grouped by user and org instead of only - * by workspace. + * so server-side product events can be grouped by machine, user, and org + * instead of only by workspace. * - * All three are optional and untrusted: they are analytics dimensions only and + * All four are optional and untrusted: they are analytics dimensions only and * must never gate authorization. */ +export const AGENT_RELAY_MACHINE_ID_HEADER = "X-Agent-Relay-Machine-Id"; +export const AGENT_RELAY_MACHINE_ID_QUERY = "agent_relay_machine_id"; export const AGENT_RELAY_USER_ID_HEADER = "X-Agent-Relay-User-Id"; export const AGENT_RELAY_USER_ID_QUERY = "agent_relay_user_id"; export const AGENT_RELAY_ORG_ID_HEADER = "X-Agent-Relay-Org-Id"; @@ -116,6 +118,16 @@ function readIdentityValue( } export interface ActorIdentity { + /** + * Hashed machine id of the host that made the request. + * + * Reported alongside the user id rather than instead of it, which is what + * makes the cross-tabs possible: how many machines share one workspace + * (`actor_machine_id` per `workspace_id`), and whether those machines are + * signed into one account or several (`actor_user_id` per + * `actor_machine_id`). + */ + actor_machine_id?: string; /** Agent Relay Cloud user id of the operator behind the request. */ actor_user_id?: string; /** Agent Relay Cloud organization id, used for PostHog group analytics. */ @@ -130,6 +142,11 @@ export interface ActorIdentity { * telemetry properties without emitting empty values. */ export function extractActorIdentity(request: Request): ActorIdentity { + const machineId = readIdentityValue( + request, + AGENT_RELAY_MACHINE_ID_HEADER, + AGENT_RELAY_MACHINE_ID_QUERY, + ); const userId = readIdentityValue( request, AGENT_RELAY_USER_ID_HEADER, @@ -148,6 +165,7 @@ export function extractActorIdentity(request: Request): ActorIdentity { ); return { + ...(machineId ? { actor_machine_id: machineId } : {}), ...(userId ? { actor_user_id: userId } : {}), ...(orgId ? { actor_org_id: orgId } : {}), ...(orgSlug ? { actor_org_slug: orgSlug } : {}), diff --git a/packages/sdk-typescript/src/__tests__/identity.test.ts b/packages/sdk-typescript/src/__tests__/identity.test.ts index 80a226bc..0100f548 100644 --- a/packages/sdk-typescript/src/__tests__/identity.test.ts +++ b/packages/sdk-typescript/src/__tests__/identity.test.ts @@ -57,6 +57,44 @@ describe('resolveAgentRelayIdentity', () => { }); }); +describe('machine identity', () => { + it('is reported alongside the user id, not replaced by it', () => { + expect( + resolveAgentRelayIdentity({ + agentRelayUserId: 'usr_abc123', + agentRelayMachineId: 'abc123def4567890', + }), + ).toEqual({ + // The user is the person key; the machine stays its own dimension. + distinctId: 'usr_abc123', + machineId: 'abc123def4567890', + userId: 'usr_abc123', + }); + }); + + it('falls back to the machine id as the distinct id when anonymous', () => { + expect(resolveAgentRelayIdentity({ agentRelayMachineId: 'abc123def4567890' })).toEqual({ + distinctId: 'abc123def4567890', + machineId: 'abc123def4567890', + }); + }); + + it('sends the machine id as its own header and query param', () => { + const identity = resolveAgentRelayIdentity({ + agentRelayUserId: 'usr_abc123', + agentRelayMachineId: 'abc123def4567890', + }); + + expect(agentRelayIdentityHeaders(identity)['X-Agent-Relay-Machine-Id']).toBe( + 'abc123def4567890', + ); + + const url = new URL('wss://cast.agentrelay.com/v1/ws'); + applyAgentRelayIdentityQuery(url, identity); + expect(url.searchParams.get('agent_relay_machine_id')).toBe('abc123def4567890'); + }); +}); + describe('agentRelayIdentityHeaders', () => { it('emits only the fields that are set', () => { expect(agentRelayIdentityHeaders({ userId: 'usr_1' })).toEqual({ diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 190746f7..3181724e 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -37,6 +37,11 @@ export interface ClientOptions { * `agentRelayDistinctId` is unset so both sides report one PostHog person. */ agentRelayUserId?: string; + /** + * Optional hashed machine id, sent as `X-Agent-Relay-Machine-Id` alongside the + * distinct id so machine-level cross-tabs survive after login. + */ + agentRelayMachineId?: string; /** Optional Agent Relay Cloud organization id, for group analytics. */ agentRelayOrgId?: string; /** Optional organization slug, for readable analytics breakdowns. */ @@ -205,6 +210,11 @@ export class HttpClient { return this._identity.userId; } + /** Sanitized hashed machine id, or `undefined` when none was supplied. */ + get agentRelayMachineId(): string | undefined { + return this._identity.machineId; + } + /** Sanitized Agent Relay Cloud organization id, or `undefined`. */ get agentRelayOrgId(): string | undefined { return this._identity.orgId; @@ -227,6 +237,7 @@ export class HttpClient { version: this._originVersion, ...(this._originActor ? { originActor: this._originActor } : {}), ...(this._identity.distinctId ? { agentRelayDistinctId: this._identity.distinctId } : {}), + ...(this._identity.machineId ? { agentRelayMachineId: this._identity.machineId } : {}), ...(this._identity.userId ? { agentRelayUserId: this._identity.userId } : {}), ...(this._identity.orgId ? { agentRelayOrgId: this._identity.orgId } : {}), ...(this._identity.orgSlug ? { agentRelayOrgSlug: this._identity.orgSlug } : {}), diff --git a/packages/sdk-typescript/src/origin.ts b/packages/sdk-typescript/src/origin.ts index 46d0b156..c4ed884c 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -23,6 +23,13 @@ export interface InternalOrigin { * rather than only workspaces. */ agentRelayUserId?: string; + /** + * Optional hashed machine id of the host process. Sent alongside the distinct + * id, never instead of it: after login the distinct id is the user id, so this + * is what keeps machine-level cross-tabs (machines per workspace, accounts per + * machine) answerable server-side. + */ + agentRelayMachineId?: string; /** Optional Agent Relay Cloud organization id, for group analytics. */ agentRelayOrgId?: string; /** Optional organization slug, for readable analytics breakdowns. */ @@ -42,6 +49,8 @@ export const SDK_ORIGIN: InternalOrigin = Object.freeze({ export const ORIGIN_ACTOR_HEADER = 'X-Relaycast-Origin-Actor'; export const AGENT_RELAY_DISTINCT_ID_HEADER = 'X-Agent-Relay-Distinct-Id'; export const AGENT_RELAY_DISTINCT_ID_QUERY = 'agent_relay_distinct_id'; +export const AGENT_RELAY_MACHINE_ID_HEADER = 'X-Agent-Relay-Machine-Id'; +export const AGENT_RELAY_MACHINE_ID_QUERY = 'agent_relay_machine_id'; export const AGENT_RELAY_USER_ID_HEADER = 'X-Agent-Relay-User-Id'; export const AGENT_RELAY_USER_ID_QUERY = 'agent_relay_user_id'; export const AGENT_RELAY_ORG_ID_HEADER = 'X-Agent-Relay-Org-Id'; @@ -87,6 +96,7 @@ export function sanitizeAgentRelayDistinctId(raw: string | undefined): string | /** Identity ids share the distinct-id contract: same charset, same length cap. */ export const sanitizeAgentRelayUserId = sanitizeAgentRelayDistinctId; +export const sanitizeAgentRelayMachineId = sanitizeAgentRelayDistinctId; export const sanitizeAgentRelayOrgId = sanitizeAgentRelayDistinctId; export function sanitizeAgentRelayOrgSlug(raw: string | undefined): string | undefined { @@ -105,6 +115,7 @@ export function sanitizeAgentRelayOrgSlug(raw: string | undefined): string | und */ export interface AgentRelayIdentity { distinctId?: string; + machineId?: string; userId?: string; orgId?: string; orgSlug?: string; @@ -122,10 +133,13 @@ export function resolveAgentRelayIdentity( }; const userId = sanitizeAgentRelayUserId(pick('agentRelayUserId')); - const distinctId = sanitizeAgentRelayDistinctId(pick('agentRelayDistinctId')) ?? userId; + const machineId = sanitizeAgentRelayMachineId(pick('agentRelayMachineId')); + const distinctId = + sanitizeAgentRelayDistinctId(pick('agentRelayDistinctId')) ?? userId ?? machineId; return { ...(distinctId ? { distinctId } : {}), + ...(machineId ? { machineId } : {}), ...(userId ? { userId } : {}), ...(sanitizeAgentRelayOrgId(pick('agentRelayOrgId')) ? { orgId: sanitizeAgentRelayOrgId(pick('agentRelayOrgId')) } @@ -142,6 +156,7 @@ export function agentRelayIdentityHeaders( ): Record { return { ...(identity.distinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: identity.distinctId } : {}), + ...(identity.machineId ? { [AGENT_RELAY_MACHINE_ID_HEADER]: identity.machineId } : {}), ...(identity.userId ? { [AGENT_RELAY_USER_ID_HEADER]: identity.userId } : {}), ...(identity.orgId ? { [AGENT_RELAY_ORG_ID_HEADER]: identity.orgId } : {}), ...(identity.orgSlug ? { [AGENT_RELAY_ORG_SLUG_HEADER]: identity.orgSlug } : {}), @@ -159,6 +174,9 @@ export function applyAgentRelayIdentityQuery( if (identity.distinctId) { url.searchParams.set(AGENT_RELAY_DISTINCT_ID_QUERY, identity.distinctId); } + if (identity.machineId) { + url.searchParams.set(AGENT_RELAY_MACHINE_ID_QUERY, identity.machineId); + } if (identity.userId) { url.searchParams.set(AGENT_RELAY_USER_ID_QUERY, identity.userId); } diff --git a/packages/sdk-typescript/src/relay.ts b/packages/sdk-typescript/src/relay.ts index 8ad79593..e4fcd6bb 100644 --- a/packages/sdk-typescript/src/relay.ts +++ b/packages/sdk-typescript/src/relay.ts @@ -161,6 +161,8 @@ export interface RelayCastOptions { * `agentRelayDistinctId` is unset. */ agentRelayUserId?: string; + /** Optional hashed machine id, sent as `X-Agent-Relay-Machine-Id`. */ + agentRelayMachineId?: string; /** Optional Agent Relay Cloud organization id, for group analytics. */ agentRelayOrgId?: string; /** Optional organization slug, for readable analytics breakdowns. */ @@ -170,6 +172,7 @@ export interface RelayCastOptions { /** Identity fields accepted by the unauthenticated workspace bootstrap calls. */ export interface WorkspaceIdentityOptions { agentRelayDistinctId?: string; + agentRelayMachineId?: string; agentRelayUserId?: string; agentRelayOrgId?: string; agentRelayOrgSlug?: string; @@ -245,6 +248,9 @@ export class RelayCast { ...(this.client.agentRelayDistinctId ? { agentRelayDistinctId: this.client.agentRelayDistinctId } : {}), + ...(this.client.agentRelayMachineId + ? { agentRelayMachineId: this.client.agentRelayMachineId } + : {}), ...(this.client.agentRelayUserId ? { agentRelayUserId: this.client.agentRelayUserId } : {}), diff --git a/packages/sdk-typescript/src/ws.ts b/packages/sdk-typescript/src/ws.ts index 84c3fdf3..e39d0561 100644 --- a/packages/sdk-typescript/src/ws.ts +++ b/packages/sdk-typescript/src/ws.ts @@ -51,6 +51,8 @@ export interface WsClientOptions { * `agentRelayDistinctId` is unset. */ agentRelayUserId?: string; + /** Optional hashed machine id, forwarded as `agent_relay_machine_id`. */ + agentRelayMachineId?: string; /** Optional Agent Relay Cloud organization id, for group analytics. */ agentRelayOrgId?: string; /** Optional organization slug, for readable analytics breakdowns. */ From b9ede2ed3508da8e3068a2cbb8d1d1783686e038 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:20:23 +0000 Subject: [PATCH 3/4] fix(sdk,docs): forward identity to agent sockets; correct changelog and API docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three review findings on #300. All were valid. **Agent sockets lost the new dimensions (P2).** `AgentClient.connect()` copied only `agentRelayDistinctId` from its inherited `HttpClient`, so a `/v1/node/ws` session reported `ws_session_started` as unauthenticated and without user/machine/org — even though the same client's HTTP requests carried them. Same class of bug as the `RelayCast` one this PR already fixed; I'd missed the sibling. Rather than adding the four fields to a third hand-copy, the root cause was the duplication itself: three places (`withApiKey`, `RelayCast`, `AgentClient`) each spread origin fields by hand, so any new dimension had to be remembered in all three. Added `HttpClient.internalOrigin` as the single source and pointed all three at it — a new dimension now reaches every socket at once. The regression test asserts the actual agent socket URL, and I verified it goes red against the old hand-copy before going green on the fix. **Changelog claimed a shipped release (P1).** My rebase resolution put these entries inside the dated `[6.2.0]` section — main's #299 had restructured the file underneath me — falsely telling users the published package supports these options, and leaving the release tooling nothing to carry forward. Moved to `[Unreleased]`, raised Patch → Minor per the monotonic rule in AGENTS.md, and added the package-level API detail to the engine and TypeScript SDK changelogs that AGENTS.md requires. **Undocumented API inputs (P1).** New accepted headers and WS query params were in neither `README.md` nor `openapi.yaml`, so non-SDK callers had no documented contract. Both now carry the full table, the charset/length rules, and why the query-param forms exist. Both docs were also stale independently: each described a `harness` option and an `X-Relaycast-Harness` header that the engine no longer reads anywhere (it was superseded by `X-Relaycast-Origin-Actor`). Corrected to the actual contract while adding the identity fields. Validation: turbo lint+build 17/17, engine 518 passed, SDK 414 passed, openapi.yaml re-parsed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UEGpCHvkLVy7AhPgjrCbcG --- CHANGELOG.md | 10 ++- README.md | 46 ++++++++++-- openapi.yaml | 28 ++++++- packages/engine/CHANGELOG.md | 6 +- packages/sdk-typescript/CHANGELOG.md | 9 ++- .../src/__tests__/identity.test.ts | 75 +++++++++++++++++++ packages/sdk-typescript/src/agent.ts | 7 +- packages/sdk-typescript/src/client.ts | 26 ++++--- packages/sdk-typescript/src/origin.ts | 20 +++++ packages/sdk-typescript/src/relay.ts | 21 +----- 10 files changed, 199 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d4a031..3e36be2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,17 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages without a separate changelog are covered by the cross-package notes below. -## [Unreleased - Patch] +## [Unreleased - Minor] + +### Added + +- Callers can declare who is behind a request with `X-Agent-Relay-Machine-Id` / `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug` (or the matching `agent_relay_*` query params on a WebSocket upgrade). Server telemetry records them as `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug` and keys events by the user instead of the workspace, so hosted usage can be reported per machine, per person, and per organization — including how many machines share a workspace and whether they are signed into one account or several. The SDK accepts `agentRelayMachineId` / `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`; a supplied user id doubles as the distinct id, and the machine id is always sent alongside it rather than instead of it. These are analytics dimensions only and never affect authorization. ### Fixed - Corrected the canonical `deliver` wire fixture in `@relaycast/types` to the `{type, data}` payload the engine actually emits, so SDK authors are not coding against a stale flat shape. +- SDK telemetry identity now reaches the WebSocket connection, not just HTTP requests — `ws_session_started` events were anonymous even when the caller supplied `agentRelayDistinctId`. +- SDK identity now also reaches agent (`/v1/node/ws`) sockets, not only the workspace observer socket, so an agent session's `ws_session_started` carries the same actor dimensions its HTTP requests do. ## [6.2.0] - 2026-07-17 @@ -29,7 +35,6 @@ Packages without a separate changelog are covered by the cross-package notes bel - Durable `agent.exited` event when a node-hosted agent leaves (deregister, missing from an inventory sync, or release), carrying `agent_id`, `agent_name`, `node_id`, the spawn `invocation_id`, and a `reason`; the spawn's caller is notified directly. - Durable `node.status.online` / `node.status.offline` events on node liveness transitions (offline carries a `reason` like `liveness_timeout`). Wildcard webhook subscriptions (`events: ["*"]`) receive all three new events automatically. - `POST /v1/agents/disconnect` accepts an optional `deregister` flag; SDK `disconnect()` and `presence.markOffline()` take `{ deregister?: boolean }` to opt into full node teardown. -- Callers can declare who is behind a request with `X-Agent-Relay-Machine-Id` / `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug` (or the matching `agent_relay_*` query params on a WebSocket upgrade). Server telemetry records them as `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug` and keys events by the user instead of the workspace, so hosted usage can be reported per machine, per person, and per organization — including how many machines share a workspace and whether they are signed into one account or several. The SDK accepts `agentRelayMachineId` / `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`; a supplied user id doubles as the distinct id, and the machine id is always sent alongside it rather than instead of it. These are analytics dimensions only and never affect authorization. ### Changed @@ -37,7 +42,6 @@ Packages without a separate changelog are covered by the cross-package notes bel ### Fixed -- SDK telemetry identity now reaches the WebSocket connection, not just HTTP requests — `ws_session_started` events were anonymous even when the caller supplied `agentRelayDistinctId`. - Node enrollment (`POST /v1/nodes`) now keys on `node_id` when supplied: re-enrolling rotates (and can rename) the same node in place, and a name held by a different node is rejected with `node_name_conflict` (409) instead of silently rewriting the other node. - Stopped a same-connection node broker `node.register` re-register from silently gating deliveries: already-announced agents keep their delivery readiness, and readiness-gated skips stamp the delivery row with observable retry metadata instead of failing silently. - Recovered WebSocket node messages whose single live dispatch was lost or failed: instead of letting rows sit queued until the mailbox TTL dead-letters them, the periodic delivery sweep now redrives queued ws-node rows (not just `http_push`), replaying each agent's backlog in ascending order so a later message never outruns an earlier one. diff --git a/README.md b/README.md index 36fde072..c1167c26 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,47 @@ API errors use `{ ok: false, error: { code, message } }`. Invalid or expired age ## Telemetry Attribution -SDK and wrapper clients may set a `harness` option, such as `codex` or -`claude-code/2.3 (model=opus-4.8)`, to attribute traffic in server telemetry. -The TypeScript SDK sends this as `X-Relaycast-Harness` for HTTP requests and as -the `harness` query parameter for WebSocket connections. Invalid values are -omitted. +Clients may declare who is driving a request so server-side product telemetry +can attribute it. Everything here is optional and analytics-only — it never +affects authentication, authorization, or routing. + +```ts +const relay = new RelayCast({ + apiKey: process.env.RELAY_API_KEY!, + originActor: 'agent-relay-cli/agent/claude-code', + agentRelayUserId: 'usr_abc123', // signed-in user, if your product has one + agentRelayMachineId: 'a1b2c3d4e5f6', // anonymous hashed machine id + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'acme', +}); +``` + +| Option | HTTP header | WS query parameter | +| --- | --- | --- | +| `originActor` | `X-Relaycast-Origin-Actor` | `origin_actor` | +| `agentRelayDistinctId` | `X-Agent-Relay-Distinct-Id` | `agent_relay_distinct_id` | +| `agentRelayMachineId` | `X-Agent-Relay-Machine-Id` | `agent_relay_machine_id` | +| `agentRelayUserId` | `X-Agent-Relay-User-Id` | `agent_relay_user_id` | +| `agentRelayOrgId` | `X-Agent-Relay-Org-Id` | `agent_relay_org_id` | +| `agentRelayOrgSlug` | `X-Agent-Relay-Org-Slug` | `agent_relay_org_slug` | + +`originActor` is a UA-style path, `{app}/{type}[/{name}]` — for example +`agent-relay-cli/agent/claude-code` or `pear/user/send-message-box`. (It +replaced the older `harness` option and its `X-Relaycast-Harness` header.) + +Relaycast has no user table of its own — a workspace is an API-key row — so +these identity fields are the only way hosted usage can be reported per person +or per organization rather than only per workspace. `agentRelayUserId` doubles +as the analytics person key when `agentRelayDistinctId` is unset, so a host that +knows the user only has to set one field. `agentRelayMachineId` is sent +*alongside* the person key rather than instead of it, which is what makes +"how many machines share this workspace" and "are they one account or several" +answerable. + +The query-parameter forms exist because browsers cannot set custom headers on a +WebSocket upgrade; the SDK applies them automatically to both the workspace +observer socket and agent sockets. Invalid values are dropped rather than +truncated. ## Core Concepts diff --git a/openapi.yaml b/openapi.yaml index 740ebed6..ac673a7e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11,9 +11,31 @@ info: - Success: `{ ok: true, data: ... }` - Error: `{ ok: false, error: { code, message } }` - Clients may include `X-Relaycast-Harness` on HTTP requests to attribute - traffic in server telemetry. WebSocket clients that cannot send custom - headers may use the `harness` query parameter instead. + Telemetry attribution (optional, all requests): + + Clients may declare who is driving a request so server-side product + telemetry can attribute it. These are analytics dimensions only — they + never affect authentication, authorization, or routing, and are safe to + omit entirely. + + | Header | Query parameter | Meaning | + | --- | --- | --- | + | `X-Relaycast-Origin-Actor` | `origin_actor` | UA-style path identifying the caller, `{app}/{type}[/{name}]` (e.g. `agent-relay-cli/agent/claude-code`). Supersedes the former `X-Relaycast-Harness`. | + | `X-Agent-Relay-Distinct-Id` | `agent_relay_distinct_id` | Analytics person key for the caller. | + | `X-Agent-Relay-Machine-Id` | `agent_relay_machine_id` | Anonymous hashed id of the host machine. Sent alongside the distinct id, not instead of it. | + | `X-Agent-Relay-User-Id` | `agent_relay_user_id` | Signed-in user id from the calling product's own account system. Used as the person key when present. | + | `X-Agent-Relay-Org-Id` | `agent_relay_org_id` | Organization id, for per-customer rollups. | + | `X-Agent-Relay-Org-Slug` | `agent_relay_org_slug` | Human-readable organization slug. | + + Relaycast has no user table of its own — a workspace is an API-key row — so + these are the only way hosted usage can be reported per person or per + organization rather than only per workspace. + + The query-parameter forms exist for WebSocket upgrades (`/v1/ws`, + `/v1/node/ws`), where browsers cannot set custom headers; the header wins + when both are present. Values must match `[A-Za-z0-9._:-]+` (org slug up to + 120 characters, the rest up to 128). Malformed values are ignored rather + than truncated. version: 1.0.0 contact: name: Relaycast diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 7c9661c8..72288d44 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added +- `extractActorIdentity(request)` reads caller-declared identity from `X-Agent-Relay-Machine-Id` / `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug`, falling back to the `agent_relay_machine_id` / `agent_relay_user_id` / `agent_relay_org_id` / `agent_relay_org_slug` query params for WebSocket upgrades (browsers cannot set custom headers on a handshake). Returns only the fields that were present and well-formed; malformed values are dropped rather than truncated. +- `emitServerEvent` folds those into every server event as `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug`, plus `is_authenticated`, and resolves the distinct id as `actor_user_id ?? client_distinct_id ?? workspace_id`. Alongside the existing `workspace_id` this makes machines-per-workspace, accounts-per-machine, and machines-per-account answerable. These are analytics dimensions only and never affect authorization. ## [6.2.0] - 2026-07-17 diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index abb033cf..cafc056f 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -7,7 +7,14 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added +- `RelayCastOptions`, `ClientOptions`, `WsClientOptions`, and the workspace bootstrap options accept `agentRelayMachineId`, `agentRelayUserId`, `agentRelayOrgId`, and `agentRelayOrgSlug`. They are sent as `X-Agent-Relay-Machine-Id` / `-User-Id` / `-Org-Id` / `-Org-Slug` on HTTP requests and as the matching `agent_relay_*` query params on WebSocket upgrades. A supplied `agentRelayUserId` doubles as the distinct id when `agentRelayDistinctId` is unset, so a host that knows the user only sets one field; the machine id is always sent alongside the distinct id, never instead of it. +- `HttpClient.internalOrigin` exposes the client's full origin (client/version, origin actor, and every identity dimension) as the single source both WebSocket clients build from, so a newly added dimension reaches every socket at once. + +### Fixed +- Identity now reaches WebSocket connections, not just HTTP requests. `RelayCast` never forwarded `agentRelayDistinctId` to its observer socket, and `AgentClient` forwarded only the distinct id to its `/v1/node/ws` socket — so agent sessions reported `ws_session_started` as unauthenticated and without actor dimensions even when the corresponding HTTP requests carried them. ## [6.2.0] - 2026-07-17 diff --git a/packages/sdk-typescript/src/__tests__/identity.test.ts b/packages/sdk-typescript/src/__tests__/identity.test.ts index 0100f548..11cb64b7 100644 --- a/packages/sdk-typescript/src/__tests__/identity.test.ts +++ b/packages/sdk-typescript/src/__tests__/identity.test.ts @@ -174,6 +174,81 @@ describe('RelayCast WebSocket identity', () => { vi.unstubAllGlobals(); }); + it('forwards identity onto an agent socket too, not just the observer socket', async () => { + const { RelayCast } = await import('../relay.js'); + + // The agent socket fetches a direct-node token before opening. + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response( + JSON.stringify({ + ok: true, + data: { token: 'nt_live_1', node_id: 'nd_1', node_name: 'sdk-direct' }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ); + + const relay = new RelayCast({ + apiKey: 'rk_live_1', + baseUrl: 'http://localhost:8080', + agentRelayUserId: 'usr_abc123', + agentRelayMachineId: 'abc123def4567890', + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'agentworkforce', + }); + + const agent = relay.as('at_live_worker'); + agent.connect(); + + // Opening is async behind the token fetch; wait for the socket to appear. + for (let i = 0; i < 50 && MockWebSocket.instances.length < 2; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const agentSocket = MockWebSocket.instances.find((ws) => ws.url.includes('/v1/node/ws')); + expect(agentSocket, 'agent node socket was never opened').toBeDefined(); + + const url = new URL(agentSocket!.url); + expect(url.searchParams.get('agent_relay_user_id')).toBe('usr_abc123'); + expect(url.searchParams.get('agent_relay_machine_id')).toBe('abc123def4567890'); + expect(url.searchParams.get('agent_relay_org_id')).toBe('org_xyz789'); + expect(url.searchParams.get('agent_relay_org_slug')).toBe('agentworkforce'); + expect(url.searchParams.get('agent_relay_distinct_id')).toBe('usr_abc123'); + + agent.disconnect(); + relay.disconnect(); + }); + + it('exposes one origin carrying every dimension, so new fields reach all sockets', async () => { + const { HttpClient } = await import('../client.js'); + + const client = new HttpClient({ + apiKey: 'rk_live_1', + originActor: 'agent-relay-cli/cli', + agentRelayUserId: 'usr_abc123', + agentRelayMachineId: 'abc123def4567890', + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'agentworkforce', + }); + + // Both WebSocket clients (RelayCast's observer socket and AgentClient's + // node socket) build their internal origin from exactly this, so a + // dimension present here cannot be missing from one socket and not the + // other — the drift that left agent sockets unauthenticated. + expect(client.internalOrigin).toMatchObject({ + client: '@relaycast/sdk', + originActor: 'agent-relay-cli/cli', + agentRelayDistinctId: 'usr_abc123', + agentRelayMachineId: 'abc123def4567890', + agentRelayUserId: 'usr_abc123', + agentRelayOrgId: 'org_xyz789', + agentRelayOrgSlug: 'agentworkforce', + }); + }); + it('forwards identity onto the socket, not just HTTP requests', async () => { const { RelayCast } = await import('../relay.js'); diff --git a/packages/sdk-typescript/src/agent.ts b/packages/sdk-typescript/src/agent.ts index 65c1534a..c694511d 100644 --- a/packages/sdk-typescript/src/agent.ts +++ b/packages/sdk-typescript/src/agent.ts @@ -263,12 +263,7 @@ export class AgentClient { nodeRegistration: () => this.directNodeRegistration(), autoAckDeliveries: true, }, - { - client: this.client.originClient, - version: this.client.originVersion, - ...(this.client.originActor ? { originActor: this.client.originActor } : {}), - ...(this.client.agentRelayDistinctId ? { agentRelayDistinctId: this.client.agentRelayDistinctId } : {}), - }, + this.client.internalOrigin, )); this.ws.on('open', () => { void this.presence.markOnline().catch(() => {}); diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 3181724e..b41e7f97 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -5,6 +5,7 @@ import { ORIGIN_ACTOR_HEADER, SDK_ORIGIN, agentRelayIdentityHeaders, + agentRelayIdentityOrigin, resolveAgentRelayIdentity, sanitizeOriginActor, type AgentRelayIdentity, @@ -229,19 +230,24 @@ export class HttpClient { return this._retryPolicy; } + /** + * This client's full origin — client/version, origin actor, and every + * identity dimension — for handing to a derived HTTP or WebSocket client. + * Single source so a newly added dimension reaches every socket at once. + */ + get internalOrigin(): InternalOrigin { + return { + client: this._originClient, + version: this._originVersion, + ...(this._originActor ? { originActor: this._originActor } : {}), + ...agentRelayIdentityOrigin(this._identity), + }; + } + withApiKey(apiKey: string): HttpClient { return new HttpClient(withInternalOrigin( { apiKey, baseUrl: this._baseUrl, retryPolicy: this._retryPolicy }, - { - client: this._originClient, - version: this._originVersion, - ...(this._originActor ? { originActor: this._originActor } : {}), - ...(this._identity.distinctId ? { agentRelayDistinctId: this._identity.distinctId } : {}), - ...(this._identity.machineId ? { agentRelayMachineId: this._identity.machineId } : {}), - ...(this._identity.userId ? { agentRelayUserId: this._identity.userId } : {}), - ...(this._identity.orgId ? { agentRelayOrgId: this._identity.orgId } : {}), - ...(this._identity.orgSlug ? { agentRelayOrgSlug: this._identity.orgSlug } : {}), - }, + this.internalOrigin, )); } diff --git a/packages/sdk-typescript/src/origin.ts b/packages/sdk-typescript/src/origin.ts index c4ed884c..7ab83f5f 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -150,6 +150,26 @@ export function resolveAgentRelayIdentity( }; } +/** + * Project a resolved identity back onto the {@link InternalOrigin} fields. + * + * Every place that hands identity to another client (`withApiKey`, and the + * WebSocket clients built by `RelayCast` and `AgentClient`) goes through this + * rather than spreading the fields by hand — that duplication is how the agent + * socket silently missed the user/machine/org dimensions when they were added. + */ +export function agentRelayIdentityOrigin( + identity: AgentRelayIdentity +): Partial { + return { + ...(identity.distinctId ? { agentRelayDistinctId: identity.distinctId } : {}), + ...(identity.machineId ? { agentRelayMachineId: identity.machineId } : {}), + ...(identity.userId ? { agentRelayUserId: identity.userId } : {}), + ...(identity.orgId ? { agentRelayOrgId: identity.orgId } : {}), + ...(identity.orgSlug ? { agentRelayOrgSlug: identity.orgSlug } : {}), + }; +} + /** Identity headers for an HTTP request. Omits anything unset. */ export function agentRelayIdentityHeaders( identity: AgentRelayIdentity diff --git a/packages/sdk-typescript/src/relay.ts b/packages/sdk-typescript/src/relay.ts index e4fcd6bb..7e04d433 100644 --- a/packages/sdk-typescript/src/relay.ts +++ b/packages/sdk-typescript/src/relay.ts @@ -239,26 +239,7 @@ export class RelayCast { token: this.client.apiKey, baseUrl: this.client.baseUrl, }, - { - client: this.client.originClient, - version: this.client.originVersion, - ...(this.client.originActor ? { originActor: this.client.originActor } : {}), - // Identity was only reaching HTTP requests, leaving every - // `ws_session_started` event anonymous even for a signed-in caller. - ...(this.client.agentRelayDistinctId - ? { agentRelayDistinctId: this.client.agentRelayDistinctId } - : {}), - ...(this.client.agentRelayMachineId - ? { agentRelayMachineId: this.client.agentRelayMachineId } - : {}), - ...(this.client.agentRelayUserId - ? { agentRelayUserId: this.client.agentRelayUserId } - : {}), - ...(this.client.agentRelayOrgId ? { agentRelayOrgId: this.client.agentRelayOrgId } : {}), - ...(this.client.agentRelayOrgSlug - ? { agentRelayOrgSlug: this.client.agentRelayOrgSlug } - : {}), - }, + this.client.internalOrigin, )); } From d62518d3f78c91758ea303721537b0212976255b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 15:13:10 +0000 Subject: [PATCH 4/4] fix(engine,sdk): drop oversized identity values; validate each source in turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Oversized actor dimensions are now dropped, not truncated** (engine). A truncated user or org id is a *different* id — it can collide with a real one and attribute usage to the wrong person or company, so no attribution beats wrong attribution. Only the four new actor dimensions changed; `agent_relay_distinct_id` keeps its shipped cap-at-128 behaviour (covered by an existing test on main), so `readIdentityValue` now takes an explicit `onOversize` rather than hiding the difference. Every SDK caps well below these limits, so only a malformed caller is affected. **Identity source selection validates before it picks** (SDK). `pick` returned the first non-empty candidate and sanitized afterwards, so a malformed higher-priority value shadowed a valid lower-priority one and dropped the dimension entirely — a wrapping host with a bad internal value silently lost identity the caller had supplied correctly. Sanitizing inside the loop fixes that and, as a side effect, computes each org value once instead of twice. Both regressions are covered by tests verified red against the previous code. Docs and changelogs updated to match: the length rule and its rationale in `README.md` and `openapi.yaml`, and the entries condensed to the impact-first form AGENTS.md asks for (dropping `HttpClient.internalOrigin`, which is internal plumbing rather than user-facing API). Not adopted, with reasons: - Zod schemas for the length checks and the SDK sanitizers. `origin.ts` has no Zod dependency today and these run per request and per client construction; expressing "trim → charset → bound" as a schema is longer and slower for identical behaviour. `sanitizeAgentRelayDistinctId` is also a published export whose truncate-at-128 semantics must be preserved exactly, which `.max()` inverts. Validation: turbo lint+build 17/17, engine 520 passed, SDK 416 passed, openapi.yaml re-parsed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UEGpCHvkLVy7AhPgjrCbcG --- CHANGELOG.md | 5 +-- README.md | 8 +++- openapi.yaml | 11 +++-- packages/engine/CHANGELOG.md | 4 +- .../engine/src/lib/__tests__/origin.test.ts | 42 ++++++++++++++++--- packages/engine/src/lib/origin.ts | 27 +++++++++--- packages/sdk-typescript/CHANGELOG.md | 6 +-- .../src/__tests__/identity.test.ts | 21 ++++++++++ packages/sdk-typescript/src/origin.ts | 33 ++++++++++----- 9 files changed, 121 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e36be2d..15770224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,13 +20,12 @@ Packages without a separate changelog are covered by the cross-package notes bel ### Added -- Callers can declare who is behind a request with `X-Agent-Relay-Machine-Id` / `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug` (or the matching `agent_relay_*` query params on a WebSocket upgrade). Server telemetry records them as `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug` and keys events by the user instead of the workspace, so hosted usage can be reported per machine, per person, and per organization — including how many machines share a workspace and whether they are signed into one account or several. The SDK accepts `agentRelayMachineId` / `agentRelayUserId` / `agentRelayOrgId` / `agentRelayOrgSlug`; a supplied user id doubles as the distinct id, and the machine id is always sent alongside it rather than instead of it. These are analytics dimensions only and never affect authorization. +- Callers can attribute requests to a machine, user, and organization via `X-Agent-Relay-Machine-Id` / `-User-Id` / `-Org-Id` / `-Org-Slug` headers (or `agent_relay_*` query params on WebSocket upgrades), so hosted usage is reported per person and per customer rather than only per workspace. Analytics only — never affects authorization. See the README's Telemetry Attribution section. ### Fixed - Corrected the canonical `deliver` wire fixture in `@relaycast/types` to the `{type, data}` payload the engine actually emits, so SDK authors are not coding against a stale flat shape. -- SDK telemetry identity now reaches the WebSocket connection, not just HTTP requests — `ws_session_started` events were anonymous even when the caller supplied `agentRelayDistinctId`. -- SDK identity now also reaches agent (`/v1/node/ws`) sockets, not only the workspace observer socket, so an agent session's `ws_session_started` carries the same actor dimensions its HTTP requests do. +- SDK telemetry identity now reaches WebSocket connections, both the workspace observer stream and agent sockets. `ws_session_started` was previously anonymous for identified callers. ## [6.2.0] - 2026-07-17 diff --git a/README.md b/README.md index c1167c26..f2ccae1f 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,12 @@ answerable. The query-parameter forms exist because browsers cannot set custom headers on a WebSocket upgrade; the SDK applies them automatically to both the workspace -observer socket and agent sockets. Invalid values are dropped rather than -truncated. +observer socket and agent sockets. + +Values must match `[A-Za-z0-9._:-]+` and stay within 120 characters for the org +slug, 128 for the rest. Anything else is dropped — identity values are never +truncated to fit, since a shortened id would be a different id and could +attribute usage to the wrong person or organization. ## Core Concepts diff --git a/openapi.yaml b/openapi.yaml index ac673a7e..c0d6c86d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -33,9 +33,14 @@ info: The query-parameter forms exist for WebSocket upgrades (`/v1/ws`, `/v1/node/ws`), where browsers cannot set custom headers; the header wins - when both are present. Values must match `[A-Za-z0-9._:-]+` (org slug up to - 120 characters, the rest up to 128). Malformed values are ignored rather - than truncated. + when both are present. + + Values must match `[A-Za-z0-9._:-]+` and stay within 120 characters for the + org slug, 128 for the rest. A value that fails either rule is ignored, and + only that dimension is dropped — the request itself is unaffected. Identity + values are never truncated to fit: a shortened id would be a different id + and could attribute usage to the wrong person or organization. + (`X-Agent-Relay-Distinct-Id` predates this rule and still truncates at 128.) version: 1.0.0 contact: name: Relaycast diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 72288d44..72b23d8c 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -10,8 +10,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased - Minor] ### Added -- `extractActorIdentity(request)` reads caller-declared identity from `X-Agent-Relay-Machine-Id` / `X-Agent-Relay-User-Id` / `X-Agent-Relay-Org-Id` / `X-Agent-Relay-Org-Slug`, falling back to the `agent_relay_machine_id` / `agent_relay_user_id` / `agent_relay_org_id` / `agent_relay_org_slug` query params for WebSocket upgrades (browsers cannot set custom headers on a handshake). Returns only the fields that were present and well-formed; malformed values are dropped rather than truncated. -- `emitServerEvent` folds those into every server event as `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug`, plus `is_authenticated`, and resolves the distinct id as `actor_user_id ?? client_distinct_id ?? workspace_id`. Alongside the existing `workspace_id` this makes machines-per-workspace, accounts-per-machine, and machines-per-account answerable. These are analytics dimensions only and never affect authorization. +- `extractActorIdentity(request)` reads caller-declared identity from the `X-Agent-Relay-Machine-Id` / `-User-Id` / `-Org-Id` / `-Org-Slug` headers, falling back to the matching `agent_relay_*` query params for WebSocket upgrades. Malformed or oversized values are dropped rather than truncated. +- Server events carry `actor_machine_id` / `actor_user_id` / `actor_org_id` / `actor_org_slug` and `is_authenticated`, and key on the caller's user id when present (`actor_user_id ?? client_distinct_id ?? workspace_id`). Analytics dimensions only; they never affect authorization. ## [6.2.0] - 2026-07-17 diff --git a/packages/engine/src/lib/__tests__/origin.test.ts b/packages/engine/src/lib/__tests__/origin.test.ts index 7a1c1fb8..972de4ee 100644 --- a/packages/engine/src/lib/__tests__/origin.test.ts +++ b/packages/engine/src/lib/__tests__/origin.test.ts @@ -222,19 +222,49 @@ describe("extractActorIdentity", () => { ).toEqual({ actor_org_slug: "fine-slug" }); }); - it("caps ids at the wire contract length", () => { + // A truncated id is a *different* id — it can collide with a real one and + // attribute usage to the wrong person or company. Dropping it means the event + // is keyed by workspace, which is merely less specific rather than wrong. + it("drops an oversized id instead of truncating it into a different id", () => { expect( extractActorIdentity( - identityReq({ headers: { "X-Agent-Relay-User-Id": "u".repeat(200) } }), + identityReq({ headers: { "X-Agent-Relay-User-Id": "u".repeat(129) } }), ).actor_user_id, - ).toHaveLength(128); + ).toBeUndefined(); }); - it("caps the org slug at 120 characters", () => { + it("drops an oversized org slug", () => { expect( extractActorIdentity( - identityReq({ headers: { "X-Agent-Relay-Org-Slug": "s".repeat(200) } }), + identityReq({ headers: { "X-Agent-Relay-Org-Slug": "s".repeat(121) } }), ).actor_org_slug, - ).toHaveLength(120); + ).toBeUndefined(); + }); + + it("keeps values exactly at the limit", () => { + const identity = extractActorIdentity( + identityReq({ + headers: { + "X-Agent-Relay-User-Id": "u".repeat(128), + "X-Agent-Relay-Org-Slug": "s".repeat(120), + }, + }), + ); + + expect(identity.actor_user_id).toHaveLength(128); + expect(identity.actor_org_slug).toHaveLength(120); + }); + + it("drops only the oversized dimension, keeping its siblings", () => { + expect( + extractActorIdentity( + identityReq({ + headers: { + "X-Agent-Relay-User-Id": "u".repeat(200), + "X-Agent-Relay-Org-Id": "org_xyz789", + }, + }), + ), + ).toEqual({ actor_org_id: "org_xyz789" }); }); }); diff --git a/packages/engine/src/lib/origin.ts b/packages/engine/src/lib/origin.ts index 04c72251..ea6287a5 100644 --- a/packages/engine/src/lib/origin.ts +++ b/packages/engine/src/lib/origin.ts @@ -87,6 +87,7 @@ export function extractAgentRelayDistinctId( request, AGENT_RELAY_DISTINCT_ID_HEADER, AGENT_RELAY_DISTINCT_ID_QUERY, + { maxLength: 128, onOversize: "truncate" }, ); } @@ -95,15 +96,23 @@ export function extractAgentRelayDistinctId( * WebSocket upgrades from browsers can't set custom headers, so the SDK forwards * these on the query string (mirrors how `origin_actor` works). * - * Rejects anything outside the distinct-id charset rather than truncating, which - * is what keeps a malformed upstream value from smuggling a header injection or - * a misleading id into analytics. + * Anything outside the distinct-id charset is dropped, which keeps a malformed + * upstream value from smuggling a header injection or a misleading id into + * analytics. + * + * `onOversize` differs by dimension on purpose: + * - `reject` for the actor dimensions. A truncated user or org id is a + * *different* id that can collide with a real one and attribute usage to + * the wrong person or company, so no attribution beats wrong attribution. + * Every SDK caps well below these limits, so only a malformed caller hits it. + * - `truncate` for `agent_relay_distinct_id`, whose cap-at-128 behaviour is + * already shipped and covered by a test; changing it is out of scope here. */ function readIdentityValue( request: Request, header: string, query: string, - maxLength = 128, + { maxLength, onOversize }: { maxLength: number; onOversize: "reject" | "truncate" }, ): string | undefined { const raw = request.headers.get(header) ?? @@ -113,8 +122,11 @@ function readIdentityValue( const trimmed = raw.trim(); if (!trimmed) return undefined; if (!AGENT_RELAY_DISTINCT_ID_ALLOWED.test(trimmed)) return undefined; + if (trimmed.length > maxLength) { + return onOversize === "reject" ? undefined : trimmed.slice(0, maxLength); + } - return trimmed.slice(0, maxLength); + return trimmed; } export interface ActorIdentity { @@ -146,22 +158,25 @@ export function extractActorIdentity(request: Request): ActorIdentity { request, AGENT_RELAY_MACHINE_ID_HEADER, AGENT_RELAY_MACHINE_ID_QUERY, + { maxLength: 128, onOversize: "reject" }, ); const userId = readIdentityValue( request, AGENT_RELAY_USER_ID_HEADER, AGENT_RELAY_USER_ID_QUERY, + { maxLength: 128, onOversize: "reject" }, ); const orgId = readIdentityValue( request, AGENT_RELAY_ORG_ID_HEADER, AGENT_RELAY_ORG_ID_QUERY, + { maxLength: 128, onOversize: "reject" }, ); const orgSlug = readIdentityValue( request, AGENT_RELAY_ORG_SLUG_HEADER, AGENT_RELAY_ORG_SLUG_QUERY, - 120, + { maxLength: 120, onOversize: "reject" }, ); return { diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index cafc056f..1070af4f 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -10,11 +10,11 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased - Minor] ### Added -- `RelayCastOptions`, `ClientOptions`, `WsClientOptions`, and the workspace bootstrap options accept `agentRelayMachineId`, `agentRelayUserId`, `agentRelayOrgId`, and `agentRelayOrgSlug`. They are sent as `X-Agent-Relay-Machine-Id` / `-User-Id` / `-Org-Id` / `-Org-Slug` on HTTP requests and as the matching `agent_relay_*` query params on WebSocket upgrades. A supplied `agentRelayUserId` doubles as the distinct id when `agentRelayDistinctId` is unset, so a host that knows the user only sets one field; the machine id is always sent alongside the distinct id, never instead of it. -- `HttpClient.internalOrigin` exposes the client's full origin (client/version, origin actor, and every identity dimension) as the single source both WebSocket clients build from, so a newly added dimension reaches every socket at once. +- Client and workspace-bootstrap options accept `agentRelayMachineId`, `agentRelayUserId`, `agentRelayOrgId`, and `agentRelayOrgSlug`, sent as `X-Agent-Relay-*` headers on HTTP and as `agent_relay_*` query params on WebSocket upgrades. `agentRelayUserId` doubles as the distinct id when `agentRelayDistinctId` is unset; the machine id is always sent alongside the distinct id, never instead of it. ### Fixed -- Identity now reaches WebSocket connections, not just HTTP requests. `RelayCast` never forwarded `agentRelayDistinctId` to its observer socket, and `AgentClient` forwarded only the distinct id to its `/v1/node/ws` socket — so agent sessions reported `ws_session_started` as unauthenticated and without actor dimensions even when the corresponding HTTP requests carried them. +- Identity now reaches WebSocket connections, not just HTTP requests: `RelayCast` never forwarded it to its observer socket, and `AgentClient` forwarded only the distinct id to its `/v1/node/ws` socket, so agent sessions reported `ws_session_started` as unauthenticated. +- A malformed higher-priority identity value no longer shadows a valid lower-priority one; each source is validated before it wins. ## [6.2.0] - 2026-07-17 diff --git a/packages/sdk-typescript/src/__tests__/identity.test.ts b/packages/sdk-typescript/src/__tests__/identity.test.ts index 11cb64b7..76ca420a 100644 --- a/packages/sdk-typescript/src/__tests__/identity.test.ts +++ b/packages/sdk-typescript/src/__tests__/identity.test.ts @@ -38,6 +38,27 @@ describe('resolveAgentRelayIdentity', () => { ).toMatchObject({ distinctId: 'abc123def4567890', userId: 'usr_abc123' }); }); + it('falls through to a valid lower-priority source when the first is malformed', () => { + // A wrapping host's internal origin normally wins, but a malformed value + // there must not shadow a valid one the caller supplied — that silently + // dropped identity the caller had provided correctly. + expect( + resolveAgentRelayIdentity( + { agentRelayUserId: 'usr\r\nX-Inject: bad', agentRelayOrgId: 'org/slash' }, + { agentRelayUserId: 'usr_valid', agentRelayOrgId: 'org_valid' }, + ), + ).toMatchObject({ userId: 'usr_valid', orgId: 'org_valid' }); + }); + + it('still prefers a valid higher-priority source', () => { + expect( + resolveAgentRelayIdentity( + { agentRelayUserId: 'usr_internal' }, + { agentRelayUserId: 'usr_public' }, + ).userId, + ).toBe('usr_internal'); + }); + it('drops malformed values instead of forwarding them', () => { expect( resolveAgentRelayIdentity({ diff --git a/packages/sdk-typescript/src/origin.ts b/packages/sdk-typescript/src/origin.ts index 7ab83f5f..0c85e765 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -124,29 +124,40 @@ export interface AgentRelayIdentity { export function resolveAgentRelayIdentity( ...sources: Array | undefined> ): AgentRelayIdentity { - const pick = (key: keyof InternalOrigin): string | undefined => { + /** + * First candidate that survives sanitization wins. + * + * Sanitizing inside the loop rather than after it matters: a malformed + * higher-priority value would otherwise shadow a valid lower-priority one and + * drop the dimension entirely, so a wrapping host with a bad internal value + * silently lost identity the caller had supplied correctly. + */ + const pick = ( + key: keyof InternalOrigin, + sanitize: (raw: string | undefined) => string | undefined + ): string | undefined => { for (const source of sources) { const value = source?.[key]; - if (typeof value === 'string' && value.trim()) return value; + if (typeof value !== 'string') continue; + const sanitized = sanitize(value); + if (sanitized) return sanitized; } return undefined; }; - const userId = sanitizeAgentRelayUserId(pick('agentRelayUserId')); - const machineId = sanitizeAgentRelayMachineId(pick('agentRelayMachineId')); + const userId = pick('agentRelayUserId', sanitizeAgentRelayUserId); + const machineId = pick('agentRelayMachineId', sanitizeAgentRelayMachineId); + const orgId = pick('agentRelayOrgId', sanitizeAgentRelayOrgId); + const orgSlug = pick('agentRelayOrgSlug', sanitizeAgentRelayOrgSlug); const distinctId = - sanitizeAgentRelayDistinctId(pick('agentRelayDistinctId')) ?? userId ?? machineId; + pick('agentRelayDistinctId', sanitizeAgentRelayDistinctId) ?? userId ?? machineId; return { ...(distinctId ? { distinctId } : {}), ...(machineId ? { machineId } : {}), ...(userId ? { userId } : {}), - ...(sanitizeAgentRelayOrgId(pick('agentRelayOrgId')) - ? { orgId: sanitizeAgentRelayOrgId(pick('agentRelayOrgId')) } - : {}), - ...(sanitizeAgentRelayOrgSlug(pick('agentRelayOrgSlug')) - ? { orgSlug: sanitizeAgentRelayOrgSlug(pick('agentRelayOrgSlug')) } - : {}), + ...(orgId ? { orgId } : {}), + ...(orgSlug ? { orgSlug } : {}), }; }