Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ The `Connection.connect()` function can take the following additional options:
(optional). This parameter allows for better resource management and cost
control by automatically terminating idle sessions.

- `clientChain`: an inbound `X-Wherobots-Client` value to forward (optional).
The SDK always identifies itself to Wherobots with an advisory
`X-Wherobots-Client` attribution header. Set this only if your application is
itself acting on behalf of an upstream Wherobots client: the value you pass is
kept to the left of the SDK's own hop, so the original caller stays
identifiable. The header is used for analytics only and never affects
authentication, authorization, or quotas.

- `resultsFormat`: one of the `ResultsFormat` enum values;
Arrow encoding is the default and most efficient format for
receiving query results.
Expand Down
150 changes: 150 additions & 0 deletions src/clientHeader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { platform } from "@platform";
import {
CLIENT_TOKEN,
MAX_HEADER_BYTES,
buildHop,
clientHeaderValue,
resolvePlatform,
} from "./clientHeader";
import { PACKAGE_VERSION } from "./version";

const byteLength = (value: string) => new TextEncoder().encode(value).length;

describe("buildHop", () => {
it("renders the token, version and platform", () => {
expect(buildHop("0.11.1", "linux")).toBe(
"client=typescript-sdk;ver=0.11.1;plat=linux",
);
});

it("uses the package version and host platform by default", () => {
expect(buildHop()).toBe(
`client=${CLIENT_TOKEN};ver=${PACKAGE_VERSION};plat=${resolvePlatform()}`,
);
});

it("omits a parameter it cannot resolve rather than emitting a placeholder", () => {
// The `client=` token is the part attribution depends on; a missing
// version must not cost us the hop.
expect(buildHop("", "linux")).toBe("client=typescript-sdk;plat=linux");
expect(buildHop("0.11.1", "")).toBe("client=typescript-sdk;ver=0.11.1");
expect(buildHop("", "")).toBe("client=typescript-sdk");
});

it("takes its platform from the platform layer", () => {
// Vitest resolves `@platform` to the Node implementation, so this asserts
// the Node value; the browser build swaps in `"browser"` at bundle time,
// which bundle.smoke.test.ts pins by forbidding `process.platform` there.
expect(resolvePlatform()).toBe(platform.clientPlatform);
expect(buildHop()).toContain(`;plat=${platform.clientPlatform}`);
});

it("neutralizes grammar delimiters in a parameter", () => {
const hop = buildHop("1.0;cmd=evil,client=spoofed", "li;nux");

expect(hop).toBe(
"client=typescript-sdk;ver=1.0_cmd_evil_client_spoofed;plat=li_nux",
);
// Exactly one hop, with only the delimiters we rendered ourselves.
expect(hop).not.toContain(",");
expect(hop.match(/;/g)).toHaveLength(2);
});

it("bounds parameter length without exposing a trailing separator", () => {
// The 63-character cut lands right after the `,` that sanitizing turned
// into a `_`, so stripping separators before truncating is not enough --
// the truncation itself can create a new trailing one.
const hop = buildHop(`${"v".repeat(62)},rc1`, "linux");

expect(hop).toBe(`client=typescript-sdk;ver=${"v".repeat(62)};plat=linux`);
});
});

describe("clientHeaderValue", () => {
it("is a single hop when the SDK originates the request", () => {
const value = clientHeaderValue();

expect(value).not.toContain(",");
expect(value.startsWith(`client=${CLIENT_TOKEN};ver=`)).toBe(true);
});

it("appends its own hop to the right of an upstream chain", () => {
const value = clientHeaderValue("client=studio-frontend", "client=x");

// Leftmost hop stays the origin; ours is the rightmost, direct caller.
expect(value).toBe("client=studio-frontend, client=x");
});

it("normalizes upstream spacing and drops empty hops", () => {
const value = clientHeaderValue(
" client=cli;ver=1.2.0 ,, , client=mcp ",
"client=x",
);

expect(value).toBe("client=cli;ver=1.2.0, client=mcp, client=x");
});

it("preserves upstream hop parameters verbatim", () => {
// The chain records what upstream asserted; it is not ours to re-render.
const value = clientHeaderValue(
"client=mcp;ver=0.9;cmd=run_query",
"client=x",
);

expect(value).toBe("client=mcp;ver=0.9;cmd=run_query, client=x");
});

it("strips characters that would inject a header or corrupt the grammar", () => {
const value = clientHeaderValue(
'client=cli\r\nX-Evil: 1\ttab"quote"',
"client=x",
);

expect(value).not.toMatch(/[\r\n\t"]/);
expect(value.startsWith("client=cli_")).toBe(true);
});

it("drops the oldest upstream hops rather than blowing the size bound", () => {
// An oversized value is malformed to the server, which then attributes the
// whole request to `unknown` -- losing early provenance is the better half
// of that trade.
const oversized = Array.from(
{ length: 20 },
(_, i) => `client=${String(i).repeat(40)}`,
).join(", ");
expect(byteLength(oversized)).toBeGreaterThan(MAX_HEADER_BYTES);

const value = clientHeaderValue(oversized, "client=x");

expect(byteLength(value)).toBeLessThanOrEqual(MAX_HEADER_BYTES);
// Our own hop always survives, and it stays rightmost.
expect(value.endsWith("client=x")).toBe(true);
});

it("keeps our hop when even one upstream hop will not fit", () => {
const value = clientHeaderValue(`client=${"x".repeat(600)}`, "client=x");

expect(value).toBe("client=x");
});

it("sanitizes non-ASCII before measuring the bound", () => {
const value = clientHeaderValue(`client=${"é".repeat(600)}`, "client=x");

expect(byteLength(value)).toBeLessThanOrEqual(MAX_HEADER_BYTES);
expect(value).toBe("client=x");
});

it("is always sendable as a real header value", () => {
// `fetch` rejects an illegal header value outright, so a hostile chain
// must never be able to break session creation.
const value = clientHeaderValue(
"client=evil\r\nX-Injected: yes, client=日本語",
);

expect(() => new Headers({ "X-Wherobots-Client": value })).not.toThrow();
expect(
new Headers({ "X-Wherobots-Client": value }).get("X-Wherobots-Client"),
).toBe(value);
});
});
140 changes: 140 additions & 0 deletions src/clientHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Build the advisory `X-Wherobots-Client` request header.
//
// `X-Wherobots-Client` is the shared, cross-client attribution header. It
// carries an ordered, comma-separated chain of hops modelled on
// `X-Forwarded-For`: the *leftmost* hop is the origin client and every
// component that forwards the request *appends its own hop on the right*:
//
// client=studio-frontend, client=typescript-sdk;ver=0.11.1;plat=browser
//
// Each hop is `client=<token>` plus optional `;key=value` parameters. The
// convention defines `ver`, `plat` and `cmd`; this SDK emits `ver` and `plat` —
// there is no subcommand for it to name. Commas and semicolons are the
// delimiters, so they never appear inside a value, and neither do control
// characters, which an HTTP header field-value cannot carry at all.
//
// The header is *advisory only*: it is client-asserted, used for attribution
// and analytics, and must never influence authentication or authorization.
//
// See studio-backend `docs/client-attribution.md` — that document is the
// contract this module implements.

import { platform } from "@platform";
import { PACKAGE_VERSION } from "./version";

// Canonical name of the shared, cross-service client-chain header.
export const CLIENT_HEADER_NAME = "X-Wherobots-Client";

// This SDK's stable token in the shared client vocabulary. Renaming it splits
// its analytics history, so it never changes.
export const CLIENT_TOKEN = "typescript-sdk";

// The server treats a header value longer than this (in bytes) as malformed and
// records `unknown` for the whole chain, so we never emit more: oversized
// upstream chains are trimmed from the left instead. Every value is scrubbed to
// ASCII, so bytes and characters count the same here.
export const MAX_HEADER_BYTES = 512;

// Bounds a single parameter value (`ver`, `plat`). The server's 64-character
// limit applies to a hop's `client` token, not to its parameters, so an
// over-length value here costs nothing on its own; the bound exists so one
// pathological version string cannot eat the header budget and starve upstream
// hops. `CLIENT_TOKEN` is a short fixed literal and never passes through
// `sanitizeValue`.
const MAX_VALUE_CHARS = 63;

const HOP_SEPARATOR = ", ";
const REPLACEMENT = "_";

// Everything outside this ASCII allowlist collapses to `_`: the `,` and `;`
// grammar delimiters, every C0 control character and DEL (a CR or LF here is
// the classic header-injection primitive, and `fetch` rejects the request
// outright rather than sending it), and every non-ASCII character.
const UNSAFE_VALUE_CHARS = /[^A-Za-z0-9._+-]/g;

// An upstream chain carries its own `,` / `;` / `=` grammar, so those survive;
// everything else outside the allowlist does not.
const UNSAFE_CHAIN_CHARS = /[^A-Za-z0-9._+:/@=;, -]/g;

// Render a value safe to embed in a hop we build ourselves.
const sanitizeValue = (value: string): string =>
value
Comment thread
salty-hambot[bot] marked this conversation as resolved.
.trim()
.replace(UNSAFE_VALUE_CHARS, REPLACEMENT)
.slice(0, MAX_VALUE_CHARS)
// Trailing separators carry no information and read as noise. Stripped
// after truncation as well as before it: the cut can land immediately
// after a replaced character and expose a separator that was in the
// middle of the value a moment ago.
.replace(/^[_.-]+|[_.-]+$/g, "");

// The `plat` parameter for this hop. Node reports its OS (`darwin`, `linux`,
// `win32`) to match what the Python SDK and JDBC driver emit; the browser has
// no equivalent it can report honestly, and a UA-string parse would be a guess,
// so it reports the runtime instead. Resolved through the platform layer so
// that `process.platform` stays out of the browser bundle entirely.
export const resolvePlatform = (): string => platform.clientPlatform;

// Render this SDK's single hop. A parameter whose value is missing or empty is
// omitted rather than emitted as a placeholder, so an unresolvable version
// simply means the hop carries no `ver` — the `client=` token, which is the
// part attribution actually depends on, is always present.
export const buildHop = (
version: string = PACKAGE_VERSION,
platformName: string = resolvePlatform(),
): string => {
const segments = [`client=${CLIENT_TOKEN}`];
const sanitizedVersion = sanitizeValue(version);
if (sanitizedVersion) {
segments.push(`ver=${sanitizedVersion}`);
}
const sanitizedPlatform = sanitizeValue(platformName);
if (sanitizedPlatform) {
segments.push(`plat=${sanitizedPlatform}`);
}
return segments.join(";");
};

// Split an inbound chain into its individual hops. Hop *parameters* are
// preserved as-is — the chain is a record of what upstream asserted, not
// something to re-render — but the text is scrubbed, because an upstream chain
// is arbitrary caller-supplied input and this is the only sanitization it ever
// gets before landing in a request header.
const splitChain = (chain: string | undefined): string[] => {
if (!chain) {
return [];
}
return chain
.replace(UNSAFE_CHAIN_CHARS, REPLACEMENT)
.split(",")
.map((hop) => hop.replace(/\s+/g, " ").trim())
.filter((hop) => hop.length > 0);
};

const byteLength = (value: string): number =>
new TextEncoder().encode(value).length;

// Build the full header value for a request this SDK is sending.
//
// Any `upstreamChain` the caller supplies is preserved — scrubbed, but
// otherwise untouched — and this SDK's hop is appended on its right, so the
// origin stays leftmost. When the result would exceed `MAX_HEADER_BYTES` the
// oldest (leftmost) upstream hops are dropped until it fits: losing early
// provenance beats the server discarding the whole chain as malformed.
//
// Never returns an empty string. With no upstream chain it is this SDK's
// single hop.
export const clientHeaderValue = (
upstreamChain?: string,
hop: string = buildHop(),
): string => {
const hops = splitChain(upstreamChain);
while (hops.length > 0) {
const value = [...hops, hop].join(HOP_SEPARATOR);
if (byteLength(value) <= MAX_HEADER_BYTES) {
return value;
}
hops.shift();
}
return hop;
};
38 changes: 38 additions & 0 deletions src/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { expect, test, describe, vi, beforeEach } from "vitest";
import fetchMockBuilder, { FetchMock } from "vitest-fetch-mock";
import WebSocket from "ws";
import { Connection } from "./connection";
import { CLIENT_HEADER_NAME, buildHop } from "./clientHeader";
import { Runtime, SessionType } from "./constants";
import {
SESSION_LIFECYCLE_RESPONSES,
Expand Down Expand Up @@ -134,6 +135,43 @@ describe("Connection.connect, when passed connection options", () => {
expectCorrectApiKey();
});

test("identifies itself with a well-formed client-attribution hop", async () => {
simulateImmediatelyReadySession(fetchMock);
simulateImmediatelyOpenSocket(MockWebSocket);
const connection = createConnectionUnderTest();
vi.runAllTimersAsync();
await connection;

expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
[CLIENT_HEADER_NAME]: buildHop(),
}),
}),
);
});

test("keeps a caller-supplied chain to the left of its own hop", async () => {
simulateImmediatelyReadySession(fetchMock);
simulateImmediatelyOpenSocket(MockWebSocket);
const connection = Connection.connect(
{ apiKey: testApiKey, clientChain: "client=studio-frontend" },
testHarness,
);
vi.runAllTimersAsync();
await connection;

expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
[CLIENT_HEADER_NAME]: `client=studio-frontend, ${buildHop()}`,
}),
}),
);
});

test("rejects if API key is missing", async () => {
if (process.env["WHEROBOTS_API_KEY"]) {
throw new Error(
Expand Down
4 changes: 2 additions & 2 deletions src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { platform } from "@platform";
import logger, { sessionContextLogger } from "./logger";
import { getEnv } from "./platform/env";
import { OpenSocket, SocketApiSubset } from "./platform/types";
import { PACKAGE_NAME, PACKAGE_VERSION } from "./version";
import { CLIENT_HEADER_NAME, clientHeaderValue } from "./clientHeader";
import { DataCompression } from "./constants";
import {
CancelExecutionEvent,
Expand Down Expand Up @@ -134,7 +134,7 @@ export class Connection {
// Identifies the SDK on both platforms; a custom header is used because
// browsers drop a JS-set User-Agent. The richer User-Agent below is
// added only where the runtime allows it (Node).
"X-Wherobots-Client": `${PACKAGE_NAME}/${PACKAGE_VERSION}`,
[CLIENT_HEADER_NAME]: clientHeaderValue(this.options.clientChain),
};
Comment thread
ClayMav marked this conversation as resolved.
if (this.options.token) {
headers["Authorization"] = `Bearer ${this.options.token}`;
Expand Down
1 change: 1 addition & 0 deletions src/platform/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@
if (level === "debug" && !options.debug) return;
const prefix = `[${options.name}]`;
if (typeof msg === "string") {
console[level](prefix, msg, context);

Check warning on line 71 in src/platform/browser.ts

View workflow job for this annotation

GitHub Actions / checks

Function Call Object Injection Sink
} else {
console[level](prefix, { ...context, ...msg });

Check warning on line 73 in src/platform/browser.ts

View workflow job for this annotation

GitHub Actions / checks

Function Call Object Injection Sink
}
};
return {
Expand All @@ -89,6 +89,7 @@
// Browsers drop a JS-set User-Agent; the SDK identifies itself via the
// X-Wherobots-Client header instead (set by the connection on both platforms).
userAgent: () => undefined,
clientPlatform: "browser",
defaultCompression: DataCompression.GZIP,
createLogger: (options) => consoleLogger(options),
};
1 change: 1 addition & 0 deletions src/platform/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const platform: Platform = {
decompress,
userAgent: () =>
`${PACKAGE_NAME}/${PACKAGE_VERSION} os/${process.platform};${process.arch} node/${process.version}`,
clientPlatform: process.platform,
defaultCompression: DataCompression.BROTLI,
createLogger,
};
Loading
Loading