Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/sdk-typescript/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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
- Removed snake_case input aliases from the SDK surface; camelCase is now the only supported input style.

Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-typescript/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
200 changes: 200 additions & 0 deletions packages/sdk-typescript/src/__tests__/harness-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* 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 `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();
Comment on lines +101 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make withApiKey harness test assert the intended behavior

This test is labeled as verifying harness preservation across withApiKey(), but it never calls withApiKey and instead constructs a fresh HttpClient without internal origin and asserts originHarness is undefined. That means regressions in the actual withApiKey harness propagation path would still pass this suite, leaving the new behavior effectively untested.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The preserves the harness across withApiKey() test does not actually assert withApiKey behavior, so harness-rotation regressions can slip through.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-typescript/src/__tests__/harness-origin.test.ts, line 113:

<comment>The `preserves the harness across withApiKey()` test does not actually assert `withApiKey` behavior, so harness-rotation regressions can slip through.</comment>

<file context>
@@ -0,0 +1,130 @@
+    // 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
+  });
</file context>
Fix with Cubic

void relay; // referenced for typing only
});
Comment on lines +101 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test doesn't verify what its title claims.

The test is titled "preserves the harness across withApiKey()" but doesn't actually call withApiKey(). It creates a relay with harness 'cursor', then creates a completely separate HttpClient instance without any origin and asserts it has no harness.

To properly test preservation across key rotation, the test should access the internal HTTP client from the relay, call withApiKey('new_key') on it, and verify that the returned client still has originHarness === 'cursor'.

The core functionality itself is correctly implemented (line 177 in client.ts preserves harness), but this test doesn't validate it.

🧪 Suggested fix
  it('preserves the harness across withApiKey()', async () => {
-    const { HttpClient } = await import('../client.js');
+    const { withInternalOrigin } = await import('../client.js');
+    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(
+    
+    const client = new HttpClient(withInternalOrigin(
      { 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
+    ));
+    
+    expect(client.originHarness).toBe('cursor');
+    
+    const rotatedClient = client.withApiKey('rk_live_rotated');
+    expect(rotatedClient.originHarness).toBe('cursor');
  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-typescript/src/__tests__/harness-origin.test.ts` around lines
101 - 115, The test currently never calls withApiKey(), so it doesn't verify
harness preservation; update the test to obtain the internal HttpClient from the
relay returned by createInternalRelayCast (use the relay's internal client
access path created by createInternalRelayCast / as() chain), call
withApiKey('new_key') on that internal client, and assert the returned client
instance still has originHarness === 'cursor' (use HttpClient,
createInternalRelayCast, withApiKey, and originHarness identifiers to locate and
update the code).

});

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');
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));
});
});
12 changes: 11 additions & 1 deletion packages/sdk-typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}

Expand All @@ -157,6 +159,10 @@ export class HttpClient {
return this._originVersion;
}

get originHarness(): string | undefined {
return this._originHarness;
}

get retryPolicy(): RetryPolicy {
return this._retryPolicy;
}
Expand All @@ -168,6 +174,7 @@ export class HttpClient {
surface: this._originSurface,
client: this._originClient,
version: this._originVersion,
harness: this._originHarness,
},
));
}
Expand All @@ -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 || {}),
};

Expand Down
28 changes: 28 additions & 0 deletions packages/sdk-typescript/src/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,38 @@ export interface InternalOrigin {
surface: string;
client: string;
version: string;
/**
* 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(...)`
* consumers don't need to populate it — the field is opt-in.
*/
harness?: string;
}

export const SDK_ORIGIN: InternalOrigin = Object.freeze({
surface: 'sdk',
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;
}
Comment on lines +34 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 sanitizeHarness uses ad-hoc regex validation instead of a zod schema

sanitizeHarness at packages/sdk-typescript/src/origin.ts:35-42 performs validation and normalization using manual checks (.trim(), .toLowerCase(), a regex test, .slice()). The AGENTS.md engineering rule states: "Prefer zod schemas for validation instead of ad-hoc manual checks." The codebase already uses zod extensively (e.g. apiEnvelopeSchema at packages/sdk-typescript/src/client.ts:109), and this validation could be expressed as a zod schema with .trim(), .toLowerCase(), .regex(), and .max() transforms.

Prompt for agents
The sanitizeHarness function in packages/sdk-typescript/src/origin.ts uses ad-hoc manual checks (trim, toLowerCase, regex, slice) which violates the AGENTS.md rule: "Prefer zod schemas for validation instead of ad-hoc manual checks." The file already imports nothing from zod, but the package has zod as a dependency.

Replace the manual validation with a zod schema. For example, define a harnessSchema using z.string().trim().toLowerCase() piped through z.string().regex(/^[a-z0-9-]+$/).max(40), then use safeParse in the sanitizeHarness function. If parsing fails, return undefined. This keeps the same runtime semantics (trim, lowercase, validate charset, cap at 40 chars, return undefined on failure) but uses the zod-idiomatic approach consistent with the rest of the codebase.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

10 changes: 9 additions & 1 deletion packages/sdk-typescript/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = WsClientEvent> = (event: T) => void;
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -105,6 +107,12 @@ 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 `harness` from query params; only forward when an
// upstream caller actually populated it.
if (this.originHarness) {
wsUrl.searchParams.set('harness', this.originHarness);
}

const ws = new WebSocket(wsUrl.toString());
this.ws = ws;
Expand Down
Loading