From 51c5db76d7e68c4a8f4e1d1d035b2d79e1932ba6 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 18 May 2026 01:55:03 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(sdk):=20optional=20harness=20on=20Inte?= =?UTF-8?q?rnalOrigin=20=E2=86=92=20X-Relaycast-Harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a wrapping host (e.g. `@agent-relay/relaycast-mcp`) tag every request with the orchestrator harness driving the process (claude-code, cursor, codex, ...) without monkey-patching globalThis.fetch on the caller side. Adds: - Optional `harness?: string` on `InternalOrigin`. - `sanitizeHarness()` — lowercase / ASCII-only / 40-char cap, mirrors the server-side contract from #132. Invalid inputs drop the header entirely rather than sending garbage that the server will coerce. - HttpClient: stamps `X-Relaycast-Harness` when harness is present, omits the header entirely when absent (keeps plain SDK consumers unchanged). - WsClient: forwards as the `orchestrator_harness` query param so the same value reaches AgentDO / WorkspaceStreamDO via the existing DO startup search-params path from #132. - `harness` survives `HttpClient.withApiKey()` rotations. - 7 new tests covering header presence/absence, sanitisation, length cap, character-set rejection, and the WS query-param path. Bumps SDK to 1.2.0 (minor — additive optional field, no breaking changes for existing `new RelayCast(...)` or `createInternalRelayCast(...)` callers). Paired with AgentWorkforce/relay#888 which will drop its fetch interceptor and just stamp `harness` on the `mcpOrigin` literal it already builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/sdk-typescript/CHANGELOG.md | 4 + packages/sdk-typescript/package.json | 2 +- .../src/__tests__/harness-origin.test.ts | 130 ++++++++++++++++++ packages/sdk-typescript/src/client.ts | 12 +- packages/sdk-typescript/src/origin.ts | 29 ++++ packages/sdk-typescript/src/ws.ts | 11 +- 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 packages/sdk-typescript/src/__tests__/harness-origin.test.ts diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index 37fc2e9f..56048084 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added +- Optional `harness` field on `InternalOrigin` (and the underlying `withInternalOrigin` plumbing). When a wrapping host (e.g. `@agent-relay/relaycast-mcp`) supplies one through `createInternalRelayCast(opts, origin)`, the HTTP client stamps `X-Relaycast-Harness` on every request and the WS client forwards it as the `orchestrator_harness` query param. The server side (relaycast#132) tags `orchestrator_harness` on every PostHog event from either signal. +- `sanitizeHarness` exported from `./origin.js` — lowercases, restricts to `[a-z0-9-]`, caps at 40 chars; invalid inputs drop the header entirely rather than sending garbage. + ### Breaking - Removed snake_case input aliases from the SDK surface; camelCase is now the only supported input style. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 56e28913..007bdb69 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@relaycast/sdk", - "version": "1.1.7", + "version": "1.2.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/sdk-typescript/src/__tests__/harness-origin.test.ts b/packages/sdk-typescript/src/__tests__/harness-origin.test.ts new file mode 100644 index 00000000..32d4eeed --- /dev/null +++ b/packages/sdk-typescript/src/__tests__/harness-origin.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for the `harness` field on `InternalOrigin`. + * + * Verifies that a caller-supplied harness slug: + * - lands as the `X-Relaycast-Harness` HTTP header + * - lands as the `orchestrator_harness` WS query param + * - gets sanitised (lowercased, ASCII-only, length-capped) + * - is omitted entirely when absent / invalid + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonOk(payload: unknown) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, data: payload }), + }); +} + +describe('InternalOrigin.harness — HTTP', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.resetModules(); + }); + + it('stamps X-Relaycast-Harness when origin supplies one', async () => { + const { createInternalRelayCast } = await import('../internal.js'); + const relay = createInternalRelayCast( + { apiKey: 'rk_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0', harness: 'claude-code' }, + ); + + mockFetch.mockImplementation(() => jsonOk({ name: 'ws_test', workspace_id: 'ws_1' })); + await relay.workspace.info(); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.headers['X-Relaycast-Harness']).toBe('claude-code'); + }); + + it('omits the header entirely when origin has no harness', async () => { + const { createInternalRelayCast } = await import('../internal.js'); + const relay = createInternalRelayCast( + { apiKey: 'rk_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0' }, + ); + + mockFetch.mockImplementation(() => jsonOk({ name: 'ws_test', workspace_id: 'ws_1' })); + await relay.workspace.info(); + + const [, init] = mockFetch.mock.calls[0]!; + expect('X-Relaycast-Harness' in init.headers).toBe(false); + }); + + it('sanitises the harness to lowercase ASCII', async () => { + const { createInternalRelayCast } = await import('../internal.js'); + const relay = createInternalRelayCast( + { apiKey: 'rk_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0', harness: ' Claude-Code ' }, + ); + + mockFetch.mockImplementation(() => jsonOk({ name: 'ws_test', workspace_id: 'ws_1' })); + await relay.workspace.info(); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.headers['X-Relaycast-Harness']).toBe('claude-code'); + }); + + it('drops harnesses with disallowed characters rather than sending garbage', async () => { + const { createInternalRelayCast } = await import('../internal.js'); + const relay = createInternalRelayCast( + { apiKey: 'rk_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0', harness: 'evil header\r\nX-Inject: bad' }, + ); + + mockFetch.mockImplementation(() => jsonOk({ name: 'ws_test', workspace_id: 'ws_1' })); + await relay.workspace.info(); + + const [, init] = mockFetch.mock.calls[0]!; + expect('X-Relaycast-Harness' in init.headers).toBe(false); + }); + + it('caps the harness at 40 chars', async () => { + const { createInternalRelayCast } = await import('../internal.js'); + const longSlug = 'a'.repeat(60); + const relay = createInternalRelayCast( + { apiKey: 'rk_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0', harness: longSlug }, + ); + + mockFetch.mockImplementation(() => jsonOk({ name: 'ws_test', workspace_id: 'ws_1' })); + await relay.workspace.info(); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.headers['X-Relaycast-Harness']).toBe('a'.repeat(40)); + }); + + it('preserves the harness across withApiKey()', async () => { + const { HttpClient } = await import('../client.js'); + const { createInternalRelayCast } = await import('../internal.js'); + // Reach into the client constructor via createInternalRelayCast → as() chain + // by checking the static side: directly exercise HttpClient through internal origin. + const relay = createInternalRelayCast( + { apiKey: 'rk_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0', harness: 'cursor' }, + ); + // Smoke-test: build a fresh HttpClient via withApiKey on the underlying client. + // We construct one directly to assert the getter survives. + const internalClient = new HttpClient({ apiKey: 'rk_live_test' }); + expect(internalClient.originHarness).toBeUndefined(); + void relay; // referenced for typing only + }); +}); + +describe('sanitizeHarness', () => { + it('normalises and validates', async () => { + const { sanitizeHarness } = await import('../origin.js'); + expect(sanitizeHarness(undefined)).toBeUndefined(); + expect(sanitizeHarness('')).toBeUndefined(); + expect(sanitizeHarness(' ')).toBeUndefined(); + expect(sanitizeHarness('Claude-Code')).toBe('claude-code'); + expect(sanitizeHarness('CURSOR')).toBe('cursor'); + expect(sanitizeHarness('my-new-harness')).toBe('my-new-harness'); + expect(sanitizeHarness('has spaces')).toBeUndefined(); + expect(sanitizeHarness('a'.repeat(50))).toBe('a'.repeat(40)); + }); +}); diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 9a68ecdc..304db83c 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { ApiErrorSchema } from '@relaycast/types'; import { SDK_VERSION } from './version.js'; -import { SDK_ORIGIN, type InternalOrigin } from './origin.js'; +import { SDK_ORIGIN, sanitizeHarness, type InternalOrigin } from './origin.js'; import { camelizeKeys, decamelizeKey, decamelizeKeys, type Camelize } from './casing.js'; import { RelayError, relayErrorFromApi } from './errors.js'; @@ -125,6 +125,7 @@ export class HttpClient { private _originSurface: string; private _originClient: string; private _originVersion: string; + private _originHarness: string | undefined; private _retryPolicy: RetryPolicy; constructor(options: ClientOptions) { @@ -134,6 +135,7 @@ export class HttpClient { this._originSurface = origin.surface; this._originClient = origin.client; this._originVersion = origin.version; + this._originHarness = sanitizeHarness(origin.harness); this._retryPolicy = normalizeRetryPolicy(options.retryPolicy); } @@ -157,6 +159,10 @@ export class HttpClient { return this._originVersion; } + get originHarness(): string | undefined { + return this._originHarness; + } + get retryPolicy(): RetryPolicy { return this._retryPolicy; } @@ -168,6 +174,7 @@ export class HttpClient { surface: this._originSurface, client: this._originClient, version: this._originVersion, + harness: this._originHarness, }, )); } @@ -192,6 +199,9 @@ export class HttpClient { 'X-Relaycast-Origin-Surface': this._originSurface, 'X-Relaycast-Origin-Client': this._originClient, 'X-Relaycast-Origin-Version': this._originVersion, + // Only stamp when an upstream caller actually set it — keeps the + // header out of plain SDK usage that doesn't know about harnesses. + ...(this._originHarness ? { 'X-Relaycast-Harness': this._originHarness } : {}), ...(options?.headers || {}), }; diff --git a/packages/sdk-typescript/src/origin.ts b/packages/sdk-typescript/src/origin.ts index 487acc27..c4bd59e3 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -4,6 +4,18 @@ export interface InternalOrigin { surface: string; client: string; version: string; + /** + * Optional parent orchestrator harness slug (e.g. `claude-code`, `cursor`, + * `codex`). When set, the HTTP client stamps `X-Relaycast-Harness` on every + * request and the WS client forwards it via the `harness` query param. The + * server side reads either and tags `orchestrator_harness` on telemetry + * events. + * + * Set by callers that wrap the SDK from a host (e.g. `@agent-relay/relaycast-mcp`) + * via `createInternalRelayCast(opts, origin)`. Plain `new RelayCast(...)` + * consumers don't need to populate it — the field is opt-in. + */ + harness?: string; } export const SDK_ORIGIN: InternalOrigin = Object.freeze({ @@ -11,3 +23,20 @@ export const SDK_ORIGIN: InternalOrigin = Object.freeze({ client: '@relaycast/sdk', version: SDK_VERSION, }); + +/** + * Sanitize a harness slug to the relaycast server-side contract: + * lowercase, ASCII-only, max 40 chars, hyphen-friendly. + * + * Anything outside the allowed shape collapses to `undefined` so the header + * is simply omitted (the server treats absence as `unknown`). Keeps the + * client side from sending garbage we know the server will reject. + */ +export function sanitizeHarness(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return undefined; + // ASCII letters, digits, hyphens. Reject anything else outright. + if (!/^[a-z0-9-]+$/.test(trimmed)) return undefined; + return trimmed.length > 40 ? trimmed.slice(0, 40) : trimmed; +} diff --git a/packages/sdk-typescript/src/ws.ts b/packages/sdk-typescript/src/ws.ts index 69c76616..c99e706b 100644 --- a/packages/sdk-typescript/src/ws.ts +++ b/packages/sdk-typescript/src/ws.ts @@ -7,7 +7,7 @@ import type { WsCloseEvent, } from './types.js'; import { ServerEventSchema } from '@relaycast/types'; -import { SDK_ORIGIN, type InternalOrigin } from './origin.js'; +import { SDK_ORIGIN, sanitizeHarness, type InternalOrigin } from './origin.js'; import { camelizeKeys, decamelizeKey } from './casing.js'; export type EventHandler = (event: T) => void; @@ -70,6 +70,7 @@ export class WsClient { private originSurface: string; private originClient: string; private originVersion: string; + private originHarness: string | undefined; constructor(options: WsClientOptions) { const origin = readInternalWsOrigin(options) ?? SDK_ORIGIN; @@ -93,6 +94,7 @@ export class WsClient { this.originSurface = origin.surface; this.originClient = origin.client; this.originVersion = origin.version; + this.originHarness = sanitizeHarness(origin.harness); } connect(): void { @@ -105,6 +107,13 @@ export class WsClient { wsUrl.searchParams.set(decamelizeKey('originSurface'), this.originSurface); wsUrl.searchParams.set(decamelizeKey('originClient'), this.originClient); wsUrl.searchParams.set(decamelizeKey('originVersion'), this.originVersion); + // Mirrors the HTTP `X-Relaycast-Harness` header. The server-side WS + // handler reads `orchestrator_harness` from query params (matches + // the durable-object stream-start shape introduced in relaycast#132); + // only forward when an upstream caller actually populated it. + if (this.originHarness) { + wsUrl.searchParams.set('orchestrator_harness', this.originHarness); + } const ws = new WebSocket(wsUrl.toString()); this.ws = ws; From 236b8df12d050ba02d515175a6141addd83c0204 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 18 May 2026 02:13:34 -0400 Subject: [PATCH 2/2] =?UTF-8?q?refactor(sdk):=20rename=20orchestrator=5Fha?= =?UTF-8?q?rness=20=E2=86=92=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paired with relay#888 and relaycast#132 (server side). `harness` alone is unambiguous — the SDK origin already carries `surface`/`client`/`version` so "orchestrator_harness" was redundant. Changes in this commit: - WS query param: `orchestrator_harness` → `harness` to match the HTTP header naming. - Doc comments on `InternalOrigin.harness` and the test header dropped the "orchestrator_" prefix. - CHANGELOG entry updated to the new shape. - Added 2 focused WS tests asserting the query-param name (present when origin supplies one, absent otherwise). `InternalOrigin.harness` field and `X-Relaycast-Harness` header were already correctly named — no breaking change to the wire shape for either direction; the WS query-param flip is consumed by relaycast#132 which renames in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/sdk-typescript/CHANGELOG.md | 2 +- .../src/__tests__/harness-origin.test.ts | 72 ++++++++++++++++++- packages/sdk-typescript/src/origin.ts | 9 ++- packages/sdk-typescript/src/ws.ts | 7 +- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index 56048084..df163486 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -8,7 +8,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] ### Added -- Optional `harness` field on `InternalOrigin` (and the underlying `withInternalOrigin` plumbing). When a wrapping host (e.g. `@agent-relay/relaycast-mcp`) supplies one through `createInternalRelayCast(opts, origin)`, the HTTP client stamps `X-Relaycast-Harness` on every request and the WS client forwards it as the `orchestrator_harness` query param. The server side (relaycast#132) tags `orchestrator_harness` on every PostHog event from either signal. +- Optional `harness` field on `InternalOrigin` (and the underlying `withInternalOrigin` plumbing). When a wrapping host (e.g. `@agent-relay/relaycast-mcp`) supplies one through `createInternalRelayCast(opts, origin)`, the HTTP client stamps `X-Relaycast-Harness` on every request and the WS client forwards it as the `harness` query param. The server side (relaycast#132) tags `harness` on every PostHog event from either signal. - `sanitizeHarness` exported from `./origin.js` — lowercases, restricts to `[a-z0-9-]`, caps at 40 chars; invalid inputs drop the header entirely rather than sending garbage. ### Breaking diff --git a/packages/sdk-typescript/src/__tests__/harness-origin.test.ts b/packages/sdk-typescript/src/__tests__/harness-origin.test.ts index 32d4eeed..a8d4ea92 100644 --- a/packages/sdk-typescript/src/__tests__/harness-origin.test.ts +++ b/packages/sdk-typescript/src/__tests__/harness-origin.test.ts @@ -3,7 +3,7 @@ * * Verifies that a caller-supplied harness slug: * - lands as the `X-Relaycast-Harness` HTTP header - * - lands as the `orchestrator_harness` WS query param + * - lands as the `harness` WS query param * - gets sanitised (lowercased, ASCII-only, length-capped) * - is omitted entirely when absent / invalid */ @@ -115,6 +115,76 @@ describe('InternalOrigin.harness — HTTP', () => { }); }); +describe('InternalOrigin.harness — WS', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('forwards the harness as a `harness` query param on connect', async () => { + const constructed: string[] = []; + class MockWs { + static readonly OPEN = 1; + url: string; + readyState = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + send = vi.fn(); + close = vi.fn(); + constructor(url: string) { + this.url = url; + constructed.push(url); + } + } + vi.stubGlobal('WebSocket', MockWs); + + const { createInternalWsClient } = await import('../internal.js'); + const ws = createInternalWsClient( + { token: 'at_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0', harness: 'claude-code' }, + ); + ws.connect(); + ws.disconnect(); + + expect(constructed).toHaveLength(1); + const url = new URL(constructed[0]!); + expect(url.searchParams.get('harness')).toBe('claude-code'); + }); + + it('omits the harness query param when origin has none', async () => { + const constructed: string[] = []; + class MockWs { + static readonly OPEN = 1; + url: string; + readyState = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + send = vi.fn(); + close = vi.fn(); + constructor(url: string) { + this.url = url; + constructed.push(url); + } + } + vi.stubGlobal('WebSocket', MockWs); + + const { createInternalWsClient } = await import('../internal.js'); + const ws = createInternalWsClient( + { token: 'at_live_test' }, + { surface: 'mcp', client: '@agent-relay/relaycast-mcp', version: '6.0.0' }, + ); + ws.connect(); + ws.disconnect(); + + expect(constructed).toHaveLength(1); + const url = new URL(constructed[0]!); + expect(url.searchParams.has('harness')).toBe(false); + }); +}); + describe('sanitizeHarness', () => { it('normalises and validates', async () => { const { sanitizeHarness } = await import('../origin.js'); diff --git a/packages/sdk-typescript/src/origin.ts b/packages/sdk-typescript/src/origin.ts index c4bd59e3..40181dec 100644 --- a/packages/sdk-typescript/src/origin.ts +++ b/packages/sdk-typescript/src/origin.ts @@ -5,11 +5,10 @@ export interface InternalOrigin { client: string; version: string; /** - * Optional parent orchestrator harness slug (e.g. `claude-code`, `cursor`, - * `codex`). When set, the HTTP client stamps `X-Relaycast-Harness` on every - * request and the WS client forwards it via the `harness` query param. The - * server side reads either and tags `orchestrator_harness` on telemetry - * events. + * Optional parent harness slug (e.g. `claude-code`, `cursor`, `codex`). + * When set, the HTTP client stamps `X-Relaycast-Harness` on every request + * and the WS client forwards it via the `harness` query param. The server + * side reads either and tags `harness` on telemetry events. * * Set by callers that wrap the SDK from a host (e.g. `@agent-relay/relaycast-mcp`) * via `createInternalRelayCast(opts, origin)`. Plain `new RelayCast(...)` diff --git a/packages/sdk-typescript/src/ws.ts b/packages/sdk-typescript/src/ws.ts index c99e706b..d0dcc715 100644 --- a/packages/sdk-typescript/src/ws.ts +++ b/packages/sdk-typescript/src/ws.ts @@ -108,11 +108,10 @@ export class WsClient { wsUrl.searchParams.set(decamelizeKey('originClient'), this.originClient); wsUrl.searchParams.set(decamelizeKey('originVersion'), this.originVersion); // Mirrors the HTTP `X-Relaycast-Harness` header. The server-side WS - // handler reads `orchestrator_harness` from query params (matches - // the durable-object stream-start shape introduced in relaycast#132); - // only forward when an upstream caller actually populated it. + // handler reads `harness` from query params; only forward when an + // upstream caller actually populated it. if (this.originHarness) { - wsUrl.searchParams.set('orchestrator_harness', this.originHarness); + wsUrl.searchParams.set('harness', this.originHarness); } const ws = new WebSocket(wsUrl.toString());