diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b55bfba..15770224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,16 @@ 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 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 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 36fde072..f2ccae1f 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,51 @@ 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. + +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 740ebed6..c0d6c86d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11,9 +11,36 @@ 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._:-]+` 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 7c9661c8..72b23d8c 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 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 19eeac7c..972de4ee 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,131 @@ 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_machine_id: "abc123def4567890", + agent_relay_user_id: "usr_abc123", + agent_relay_org_id: "org_xyz789", + agent_relay_org_slug: "agentworkforce", + }, + }), + ), + ).toEqual({ + actor_machine_id: "abc123def4567890", + 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" }); + }); + + // 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(129) } }), + ).actor_user_id, + ).toBeUndefined(); + }); + + it("drops an oversized org slug", () => { + expect( + extractActorIdentity( + identityReq({ headers: { "X-Agent-Relay-Org-Slug": "s".repeat(121) } }), + ).actor_org_slug, + ).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 94bd85cc..ea6287a5 100644 --- a/packages/engine/src/lib/origin.ts +++ b/packages/engine/src/lib/origin.ts @@ -16,6 +16,27 @@ 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 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 machine, user, and org + * instead of only by workspace. + * + * 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"; +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 +82,109 @@ 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, + { maxLength: 128, onOversize: "truncate" }, + ); +} + +/** + * 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). + * + * 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, onOversize }: { maxLength: number; onOversize: "reject" | "truncate" }, ): 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; + if (trimmed.length > maxLength) { + return onOversize === "reject" ? undefined : trimmed.slice(0, maxLength); + } - return trimmed.slice(0, 128); + return trimmed; +} + +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. */ + 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 machineId = readIdentityValue( + 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, + { maxLength: 120, onOversize: "reject" }, + ); + + return { + ...(machineId ? { actor_machine_id: machineId } : {}), + ...(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/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index abb033cf..1070af4f 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 +- 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 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 new file mode 100644 index 00000000..76ca420a --- /dev/null +++ b/packages/sdk-typescript/src/__tests__/identity.test.ts @@ -0,0 +1,293 @@ +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('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({ + 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('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({ + '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 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'); + + 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/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 01bbd5d0..b41e7f97 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -2,11 +2,13 @@ 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, + agentRelayIdentityOrigin, + resolveAgentRelayIdentity, sanitizeOriginActor, + type AgentRelayIdentity, type InternalOrigin, } from './origin.js'; import { camelizeKeys, decamelizeKey, decamelizeKeys, type Camelize } from './casing.js'; @@ -30,6 +32,21 @@ 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 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. */ + agentRelayOrgSlug?: string; } export interface RequestOptions { @@ -146,7 +163,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 +175,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,22 +203,51 @@ 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 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; + } + + /** Sanitized Agent Relay Cloud organization slug, or `undefined`. */ + get agentRelayOrgSlug(): string | undefined { + return this._identity.orgSlug; } get retryPolicy(): RetryPolicy { 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._agentRelayDistinctId ? { agentRelayDistinctId: this._agentRelayDistinctId } : {}), - }, + this.internalOrigin, )); } @@ -226,7 +271,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..0c85e765 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -16,6 +16,24 @@ 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 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. */ + agentRelayOrgSlug?: string; } export const SDK_ORIGIN: InternalOrigin = Object.freeze({ @@ -31,6 +49,14 @@ 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'; +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 +93,128 @@ 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 sanitizeAgentRelayMachineId = 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; + machineId?: string; + userId?: string; + orgId?: string; + orgSlug?: string; +} + +export function resolveAgentRelayIdentity( + ...sources: Array | undefined> +): AgentRelayIdentity { + /** + * 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') continue; + const sanitized = sanitize(value); + if (sanitized) return sanitized; + } + return undefined; + }; + + const userId = pick('agentRelayUserId', sanitizeAgentRelayUserId); + const machineId = pick('agentRelayMachineId', sanitizeAgentRelayMachineId); + const orgId = pick('agentRelayOrgId', sanitizeAgentRelayOrgId); + const orgSlug = pick('agentRelayOrgSlug', sanitizeAgentRelayOrgSlug); + const distinctId = + pick('agentRelayDistinctId', sanitizeAgentRelayDistinctId) ?? userId ?? machineId; + + return { + ...(distinctId ? { distinctId } : {}), + ...(machineId ? { machineId } : {}), + ...(userId ? { userId } : {}), + ...(orgId ? { orgId } : {}), + ...(orgSlug ? { orgSlug } : {}), + }; +} + +/** + * 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 +): 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 } : {}), + }; +} + +/** + * 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.machineId) { + url.searchParams.set(AGENT_RELAY_MACHINE_ID_QUERY, identity.machineId); + } + 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..7e04d433 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,36 @@ 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 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. */ + agentRelayOrgSlug?: string; +} + +/** Identity fields accepted by the unauthenticated workspace bootstrap calls. */ +export interface WorkspaceIdentityOptions { + agentRelayDistinctId?: string; + agentRelayMachineId?: string; + agentRelayUserId?: string; + agentRelayOrgId?: string; + agentRelayOrgSlug?: string; } -export interface WorkspaceBootstrapOptions { +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 { @@ -216,11 +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 } : {}), - }, + this.client.internalOrigin, )); } @@ -292,10 +311,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 +325,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 +364,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 +376,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..e39d0561 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,18 @@ 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 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. */ + agentRelayOrgSlug?: string; } /** @@ -108,7 +121,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 +155,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 +189,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;