@2kw/ai 7.3.0-dev.1 → 7.3.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/agent-config/schema.d.ts +45 -1
  2. package/dist/agent-config/schema.js +26 -2
  3. package/dist/agent-config/template.js +3 -0
  4. package/dist/agent-config/validate.js +13 -3
  5. package/dist/bridge/hub.d.ts +11 -0
  6. package/dist/bridge/hub.js +44 -0
  7. package/dist/bridge/inventory.d.ts +27 -0
  8. package/dist/bridge/inventory.js +41 -0
  9. package/dist/bridge/pairing.d.ts +41 -0
  10. package/dist/bridge/pairing.js +158 -0
  11. package/dist/bridge/paths.d.ts +7 -0
  12. package/dist/bridge/paths.js +16 -0
  13. package/dist/bridge/run.d.ts +12 -0
  14. package/dist/bridge/run.js +134 -0
  15. package/dist/bridge/shell.d.ts +17 -0
  16. package/dist/bridge/shell.js +48 -0
  17. package/dist/bridge/store.d.ts +33 -0
  18. package/dist/bridge/store.js +110 -0
  19. package/dist/bridge/toml.d.ts +16 -0
  20. package/dist/bridge/toml.js +39 -0
  21. package/dist/commands/bridge.d.ts +7 -0
  22. package/dist/commands/bridge.js +228 -0
  23. package/dist/commands/extractions.js +24 -5
  24. package/dist/index.js +2 -0
  25. package/dist/lib/agent-run.js +1 -1
  26. package/dist/relay-protocol/b64.d.ts +13 -0
  27. package/dist/relay-protocol/b64.js +34 -0
  28. package/dist/relay-protocol/canonical-json.d.ts +22 -0
  29. package/dist/relay-protocol/canonical-json.js +101 -0
  30. package/dist/relay-protocol/frames.d.ts +75 -0
  31. package/dist/relay-protocol/frames.js +224 -0
  32. package/dist/relay-protocol/headers.d.ts +18 -0
  33. package/dist/relay-protocol/headers.js +88 -0
  34. package/dist/relay-protocol/jws.d.ts +28 -0
  35. package/dist/relay-protocol/jws.js +113 -0
  36. package/dist/relay-protocol/keys.d.ts +26 -0
  37. package/dist/relay-protocol/keys.js +80 -0
  38. package/dist/relay-protocol/pairing.d.ts +26 -0
  39. package/dist/relay-protocol/pairing.js +49 -0
  40. package/dist/relay-protocol/wire.d.ts +101 -0
  41. package/dist/relay-protocol/wire.js +63 -0
  42. package/dist/relay-protocol/wordlist.d.ts +30 -0
  43. package/dist/relay-protocol/wordlist.js +289 -0
  44. package/package.json +4 -1
@@ -131,13 +131,32 @@ export function makeExtractionsCommand() {
131
131
  });
132
132
  cmd
133
133
  .command("rerun")
134
- .description("Re-run an extraction")
135
- .argument("<id>", "Extraction ID to rerun")
136
- .action(async (id, _opts, command) => {
134
+ .description("Re-run one extraction, or queue a batch (several IDs, or a filter) of up to 100")
135
+ .argument("[ids...]", "Extraction IDs to rerun")
136
+ .option("--status <status>", "Rerun extractions with this status (e.g. FAILED)")
137
+ .option("--schema-version <id>", "Rerun extractions of this schema version")
138
+ .option("-s, --search <term>", "Rerun extractions whose model contains this text")
139
+ .action(async (ids, opts, command) => {
137
140
  await runAction(command, async () => {
138
141
  const client = getClient(command);
139
- const { data } = await withSpinner("Re-running extraction...", () => client.POST("/v1/extractions/{id}/rerun", {
140
- params: { path: { id } },
142
+ const filtered = Boolean(opts.status || opts.schemaVersion || opts.search);
143
+ if (ids.length === 1 && !filtered) {
144
+ const { data } = await withSpinner("Re-running extraction...", () => client.POST("/v1/extractions/{id}/rerun", {
145
+ params: { path: { id: ids[0] } },
146
+ }));
147
+ formatDetail(data, command);
148
+ return;
149
+ }
150
+ if (ids.length === 0 && !filtered) {
151
+ throw new Error("Pass an extraction ID, several IDs, or a filter (--status, --schema-version, --search).");
152
+ }
153
+ if (ids.length > 0 && filtered) {
154
+ throw new Error("Pass extraction IDs or a filter, not both.");
155
+ }
156
+ const { data } = await withSpinner("Queueing re-runs...", () => client.POST("/v1/extractions/rerun", {
157
+ body: filtered
158
+ ? { status: opts.status, schemaVersionId: opts.schemaVersion, search: opts.search }
159
+ : { ids },
141
160
  }));
142
161
  formatDetail(data, command);
143
162
  });
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { makeFilesCommand } from "./commands/files.js";
34
34
  import { makeInstallationsCommand } from "./commands/installations.js";
35
35
  import { makeConnectorsCommand } from "./commands/connectors.js";
36
36
  import { makeRelaysCommand } from "./commands/relays.js";
37
+ import { makeBridgeCommand } from "./commands/bridge.js";
37
38
  import { checkForUpdates } from "./lib/update-notifier.js";
38
39
  const updater = checkForUpdates(pkg.version);
39
40
  const program = new Command();
@@ -75,6 +76,7 @@ program.addCommand(makeFilesCommand());
75
76
  program.addCommand(makeInstallationsCommand());
76
77
  program.addCommand(makeConnectorsCommand());
77
78
  program.addCommand(makeRelaysCommand());
79
+ program.addCommand(makeBridgeCommand());
78
80
  program.addCommand(makeDocsCommand());
79
81
  program.parseAsync().then(() => updater.notify());
80
82
  //# sourceMappingURL=index.js.map
@@ -174,7 +174,7 @@ function connectorApproval(i) {
174
174
  /**
175
175
  * Output item types the envelope decodes, or that never hold a run: text, reasoning and finished
176
176
  * connector calls. A pause on anything else is named.
177
- * `backbone:tool_image` is an image a finished `ViewImage` call attached (#1244): it never holds a run.
177
+ * `backbone:tool_image` is an image a finished `ViewImage` call or a connector call attached (#1244, #844): it never holds a run.
178
178
  */
179
179
  const DECODED_ITEM_TYPES = new Set([
180
180
  "message",
@@ -0,0 +1,13 @@
1
+ /** base64url without padding (RFC 4648 §5), contract C1. */
2
+ export declare function b64url(bytes: Uint8Array): string;
3
+ /**
4
+ * Decodes the canonical unpadded base64url of contract C1 and nothing else. Node's decoder is
5
+ * lenient: it drops a dangling character that carries no whole byte ("A" gives no bytes) and
6
+ * ignores non-canonical trailing bits ("-_8", "-_9" and "-__" all give 0xfb 0xff). Re-encoding
7
+ * the result is what rejects those, so one byte string has exactly one accepted spelling.
8
+ */
9
+ export declare function fromB64url(value: string): Buffer;
10
+ export declare function sha256(data: Uint8Array | string): Buffer;
11
+ export declare function sha256b64url(data: Uint8Array | string): string;
12
+ export declare function randomHex(bytes: number): string;
13
+ //# sourceMappingURL=b64.d.ts.map
@@ -0,0 +1,34 @@
1
+ // GENERATED COPY of relay/src/protocol/b64.ts (#1377). Do not edit here: edit the relay's file,
2
+ // then run `npm run sync:relay-protocol` in cli/. `npm run check:relay-protocol` fails on any drift.
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ const BASE64URL = /^[A-Za-z0-9_-]*$/;
5
+ /** base64url without padding (RFC 4648 §5), contract C1. */
6
+ export function b64url(bytes) {
7
+ return Buffer.from(bytes).toString("base64url");
8
+ }
9
+ /**
10
+ * Decodes the canonical unpadded base64url of contract C1 and nothing else. Node's decoder is
11
+ * lenient: it drops a dangling character that carries no whole byte ("A" gives no bytes) and
12
+ * ignores non-canonical trailing bits ("-_8", "-_9" and "-__" all give 0xfb 0xff). Re-encoding
13
+ * the result is what rejects those, so one byte string has exactly one accepted spelling.
14
+ */
15
+ export function fromB64url(value) {
16
+ if (!BASE64URL.test(value)) {
17
+ throw new Error("invalid base64url");
18
+ }
19
+ const decoded = Buffer.from(value, "base64url");
20
+ if (b64url(decoded) !== value) {
21
+ throw new Error("invalid base64url");
22
+ }
23
+ return decoded;
24
+ }
25
+ export function sha256(data) {
26
+ return createHash("sha256").update(data).digest();
27
+ }
28
+ export function sha256b64url(data) {
29
+ return b64url(sha256(data));
30
+ }
31
+ export function randomHex(bytes) {
32
+ return randomBytes(bytes).toString("hex");
33
+ }
34
+ //# sourceMappingURL=b64.js.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Contract C10: keys sorted by code unit, arrays in order, no whitespace.
3
+ *
4
+ * The Java twin is CanonicalJson.java, and the two must agree byte for byte — `configHash` is
5
+ * computed on both sides, so a value the two platforms serialize differently would change the
6
+ * hash silently and every request assertion would then fail with no clue why. Where the
7
+ * platforms differ by default, the vectors in test/vectors/inventory.json pin this side's
8
+ * behaviour and test/vectors/README.md restates it: non-ASCII characters stay raw (never
9
+ * `\uXXXX`), control characters escape to lower-case `\u00xx` with the short forms for
10
+ * \b \f \n \r \t, `/` and DEL stay unescaped, and key order is UTF-16 code unit order
11
+ * (JavaScript `<`, Java `String.compareTo`), not UTF-8 byte order.
12
+ *
13
+ * Ruling C10-strict: everything outside that agreement is REFUSED rather than serialized.
14
+ * Fail closed — a `CanonicalJsonError` at the call site is a bug report; a hash that differs
15
+ * by platform is a support case nobody can diagnose.
16
+ */
17
+ export declare class CanonicalJsonError extends Error {
18
+ readonly code = "unsupported_value";
19
+ constructor(message: string);
20
+ }
21
+ export declare function canonicalJson(value: unknown): string;
22
+ //# sourceMappingURL=canonical-json.d.ts.map
@@ -0,0 +1,101 @@
1
+ // GENERATED COPY of relay/src/protocol/canonical-json.ts (#1377). Do not edit here: edit the relay's file,
2
+ // then run `npm run sync:relay-protocol` in cli/. `npm run check:relay-protocol` fails on any drift.
3
+ /**
4
+ * Contract C10: keys sorted by code unit, arrays in order, no whitespace.
5
+ *
6
+ * The Java twin is CanonicalJson.java, and the two must agree byte for byte — `configHash` is
7
+ * computed on both sides, so a value the two platforms serialize differently would change the
8
+ * hash silently and every request assertion would then fail with no clue why. Where the
9
+ * platforms differ by default, the vectors in test/vectors/inventory.json pin this side's
10
+ * behaviour and test/vectors/README.md restates it: non-ASCII characters stay raw (never
11
+ * `\uXXXX`), control characters escape to lower-case `\u00xx` with the short forms for
12
+ * \b \f \n \r \t, `/` and DEL stay unescaped, and key order is UTF-16 code unit order
13
+ * (JavaScript `<`, Java `String.compareTo`), not UTF-8 byte order.
14
+ *
15
+ * Ruling C10-strict: everything outside that agreement is REFUSED rather than serialized.
16
+ * Fail closed — a `CanonicalJsonError` at the call site is a bug report; a hash that differs
17
+ * by platform is a support case nobody can diagnose.
18
+ */
19
+ export class CanonicalJsonError extends Error {
20
+ code = "unsupported_value";
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "CanonicalJsonError";
24
+ }
25
+ }
26
+ /**
27
+ * A lone surrogate — a UTF-16 code unit with no partner. It is legal in a JavaScript string
28
+ * and reachable from legal JSON text (`"\ud800"`), but it has no UTF-8 encoding: JavaScript
29
+ * escapes it back, Java writes a replacement character or raises, and the two digests part
30
+ * company. Written as an explicit scan rather than a lookbehind regex so the Java twin can
31
+ * restate it line for line.
32
+ */
33
+ function hasLoneSurrogate(text) {
34
+ for (let i = 0; i < text.length; i += 1) {
35
+ const unit = text.charCodeAt(i);
36
+ if (unit >= 0xd800 && unit <= 0xdbff) {
37
+ const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
38
+ if (next < 0xdc00 || next > 0xdfff) {
39
+ return true;
40
+ }
41
+ i += 1;
42
+ }
43
+ else if (unit >= 0xdc00 && unit <= 0xdfff) {
44
+ return true;
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+ /** Only a plain object carries its data in its own enumerable properties; a Date or a Map does not. */
50
+ function isPlainObject(value) {
51
+ const proto = Object.getPrototypeOf(value);
52
+ return proto === Object.prototype || proto === null;
53
+ }
54
+ function refuse(what) {
55
+ throw new CanonicalJsonError(`canonical JSON does not support ${what}`);
56
+ }
57
+ function encodeString(value, what) {
58
+ if (hasLoneSurrogate(value)) {
59
+ refuse(`a lone surrogate in ${what}`);
60
+ }
61
+ return JSON.stringify(value);
62
+ }
63
+ export function canonicalJson(value) {
64
+ if (Array.isArray(value)) {
65
+ // An array element has no "drop it" option the way a property does: dropping one would
66
+ // change the array's length, so an unrepresentable element is refused.
67
+ return `[${value.map((element) => canonicalJson(element)).join(",")}]`;
68
+ }
69
+ switch (typeof value) {
70
+ case "string":
71
+ return encodeString(value, "a string");
72
+ case "boolean":
73
+ return value ? "true" : "false";
74
+ case "number":
75
+ if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
76
+ // JavaScript prints 1.0 as "1" and 1e21 as "1e+21"; Java's Double.toString gives
77
+ // "1.0" and "1.0E21". Only safe integers spell the same on both sides.
78
+ refuse(`the number ${String(value)}; only safe integers are portable`);
79
+ }
80
+ return String(value);
81
+ case "object": {
82
+ if (value === null) {
83
+ return "null";
84
+ }
85
+ if (!isPlainObject(value)) {
86
+ refuse(`${value.constructor?.name ?? "a non-plain object"}; use a plain object or an array`);
87
+ }
88
+ const entries = Object.entries(value)
89
+ // The brief drops an undefined property: an absent key and an undefined one mean the
90
+ // same thing in JSON, and dropping it changes nothing either side can observe.
91
+ .filter(([, v]) => v !== undefined)
92
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
93
+ return `{${entries.map(([k, v]) => `${encodeString(k, "a key")}:${canonicalJson(v)}`).join(",")}}`;
94
+ }
95
+ case "undefined":
96
+ return refuse("undefined outside an object property");
97
+ default:
98
+ return refuse(`a ${typeof value} value`);
99
+ }
100
+ }
101
+ //# sourceMappingURL=canonical-json.js.map
@@ -0,0 +1,75 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import type { HeaderPair } from "./wire.js";
3
+ export type FrameType = "accepted" | "head" | "data" | "end" | "error";
4
+ export type UnsignedFrame = {
5
+ seq: number;
6
+ type: "accepted";
7
+ } | {
8
+ seq: number;
9
+ type: "head";
10
+ status: number;
11
+ headers: HeaderPair[];
12
+ } | {
13
+ seq: number;
14
+ type: "data";
15
+ bytes: string;
16
+ } | {
17
+ seq: number;
18
+ type: "end";
19
+ } | {
20
+ seq: number;
21
+ type: "error";
22
+ code: string;
23
+ message: string;
24
+ };
25
+ export type Frame = UnsignedFrame & {
26
+ sig: string;
27
+ };
28
+ type Without<T, K extends keyof never> = T extends unknown ? Omit<T, K> : never;
29
+ export type FrameFields = Without<UnsignedFrame, "seq">;
30
+ export declare class FrameError extends Error {
31
+ readonly code = "response_invalid";
32
+ constructor(message: string);
33
+ }
34
+ /** UTF-16 code units, so `String.length` says the same thing on both sides of the wire. */
35
+ export declare const MAX_FRAME_MESSAGE_LENGTH = 1024;
36
+ export declare function isErrorFrameCode(code: string): boolean;
37
+ export declare function isErrorFrameMessage(message: string): boolean;
38
+ /**
39
+ * Makes a message built from untrusted text signable: control characters become spaces and the
40
+ * result is truncated. Every caller that interpolates something the hub or an upstream chose —
41
+ * a `srv` claim, a method, an upstream error string — runs it through this, because
42
+ * `FrameSigner.next` refuses the frame otherwise and the relay would answer nothing at all.
43
+ */
44
+ export declare function sanitizeFrameMessage(message: string): string;
45
+ /** Contract C7. */
46
+ export declare function fieldsDigest(frame: UnsignedFrame): string;
47
+ export declare function frameSigningInput(requestId: string, frame: UnsignedFrame, prev: string): string;
48
+ export declare class FrameSigner {
49
+ private readonly key;
50
+ private readonly requestId;
51
+ private prev;
52
+ private seq;
53
+ constructor(key: KeyObject, requestId: string, jti: string);
54
+ get nextSeq(): number;
55
+ /**
56
+ * A `head`'s headers are signed as given: pass `filterHeaders(pairs, isReturnedResponseHeader)`
57
+ * output. An `error`'s code and message must already satisfy the injectivity rule — build the
58
+ * message with `sanitizeFrameMessage` when any part of it came from outside the relay — because
59
+ * the relay must never put a signature on a frame a hub could restate.
60
+ */
61
+ next(fields: FrameFields): Frame;
62
+ }
63
+ export declare function isFrameShape(raw: unknown): raw is Frame;
64
+ export declare class FrameChainVerifier {
65
+ private readonly publicKey;
66
+ private readonly requestId;
67
+ private expectedSeq;
68
+ private prev;
69
+ private last;
70
+ private done;
71
+ constructor(publicKey: KeyObject, requestId: string, jti: string);
72
+ verify(raw: unknown): Frame;
73
+ }
74
+ export {};
75
+ //# sourceMappingURL=frames.d.ts.map
@@ -0,0 +1,224 @@
1
+ import { b64url, fromB64url, sha256b64url } from "./b64.js";
2
+ import { HeaderError, canonicalHeaders, filterHeaders, isReturnedResponseHeader } from "./headers.js";
3
+ import { signBytes, verifyBytes } from "./keys.js";
4
+ export class FrameError extends Error {
5
+ code = "response_invalid";
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "FrameError";
9
+ }
10
+ }
11
+ /**
12
+ * An `error` frame's digest is `code + "\n" + message`, so without a rule on the two fields the
13
+ * pair is not injective: `("upstream_incomplete", "a\nb")` and `("upstream_incomplete\na", "b")`
14
+ * hash the same and share one signature, which lets the untrusted hub (spec D14) restate either
15
+ * as the other. This is the rule C6 already applies to header values, in the one place C7 left
16
+ * open. The code is additionally constrained to a wire-code spelling because it ends up as a
17
+ * metric label and as a `McpRelayErrorCode` on the Java side; the message is bounded so a frame
18
+ * cannot carry a payload in a field nothing reads as one. `fieldsDigest` is unchanged: the rule
19
+ * lives in the shape gate, on the signer's side and the verifier's alike.
20
+ */
21
+ const ERROR_CODE_SHAPE = /^[a-z][a-z0-9_]{0,63}$/;
22
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
23
+ /** UTF-16 code units, so `String.length` says the same thing on both sides of the wire. */
24
+ export const MAX_FRAME_MESSAGE_LENGTH = 1024;
25
+ export function isErrorFrameCode(code) {
26
+ return ERROR_CODE_SHAPE.test(code);
27
+ }
28
+ export function isErrorFrameMessage(message) {
29
+ return message.length <= MAX_FRAME_MESSAGE_LENGTH && !CONTROL_CHARACTERS.test(message);
30
+ }
31
+ /**
32
+ * Makes a message built from untrusted text signable: control characters become spaces and the
33
+ * result is truncated. Every caller that interpolates something the hub or an upstream chose —
34
+ * a `srv` claim, a method, an upstream error string — runs it through this, because
35
+ * `FrameSigner.next` refuses the frame otherwise and the relay would answer nothing at all.
36
+ */
37
+ export function sanitizeFrameMessage(message) {
38
+ return message.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, MAX_FRAME_MESSAGE_LENGTH);
39
+ }
40
+ /** Contract C7. */
41
+ export function fieldsDigest(frame) {
42
+ switch (frame.type) {
43
+ case "accepted":
44
+ case "end":
45
+ return sha256b64url(Buffer.alloc(0));
46
+ case "head":
47
+ return sha256b64url(`${frame.status}\n${canonicalHeaders(frame.headers, isReturnedResponseHeader)}`);
48
+ case "data":
49
+ return sha256b64url(fromB64url(frame.bytes));
50
+ case "error":
51
+ return sha256b64url(`${frame.code}\n${frame.message}`);
52
+ }
53
+ }
54
+ export function frameSigningInput(requestId, frame, prev) {
55
+ return `mcprelay-frame-v1\n${requestId}\n${frame.seq}\n${frame.type}\n${fieldsDigest(frame)}\n${prev}`;
56
+ }
57
+ export class FrameSigner {
58
+ key;
59
+ requestId;
60
+ prev;
61
+ seq = 0;
62
+ constructor(key, requestId, jti) {
63
+ this.key = key;
64
+ this.requestId = requestId;
65
+ this.prev = jti;
66
+ }
67
+ get nextSeq() {
68
+ return this.seq;
69
+ }
70
+ /**
71
+ * A `head`'s headers are signed as given: pass `filterHeaders(pairs, isReturnedResponseHeader)`
72
+ * output. An `error`'s code and message must already satisfy the injectivity rule — build the
73
+ * message with `sanitizeFrameMessage` when any part of it came from outside the relay — because
74
+ * the relay must never put a signature on a frame a hub could restate.
75
+ */
76
+ next(fields) {
77
+ const frame = { ...fields, seq: this.seq };
78
+ if (frame.type === "error") {
79
+ if (!isErrorFrameCode(frame.code)) {
80
+ throw new FrameError(`error frame code ${JSON.stringify(frame.code)} is not a wire code`);
81
+ }
82
+ if (!isErrorFrameMessage(frame.message)) {
83
+ throw new FrameError("error frame message carries a control character or exceeds 1024 characters");
84
+ }
85
+ }
86
+ const sig = b64url(signBytes(this.key, frameSigningInput(this.requestId, frame, this.prev)));
87
+ this.prev = sig;
88
+ this.seq += 1;
89
+ return { ...frame, sig };
90
+ }
91
+ }
92
+ function isRecord(value) {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+ function isHeaderPairs(value) {
96
+ return (Array.isArray(value) &&
97
+ value.every((pair) => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" && typeof pair[1] === "string"));
98
+ }
99
+ export function isFrameShape(raw) {
100
+ if (!isRecord(raw) || typeof raw.sig !== "string" || !Number.isInteger(raw.seq) || raw.seq < 0) {
101
+ return false;
102
+ }
103
+ switch (raw.type) {
104
+ case "accepted":
105
+ case "end":
106
+ return true;
107
+ case "head":
108
+ return Number.isInteger(raw.status) && isHeaderPairs(raw.headers);
109
+ case "data":
110
+ return typeof raw.bytes === "string";
111
+ case "error":
112
+ return (typeof raw.code === "string" &&
113
+ isErrorFrameCode(raw.code) &&
114
+ typeof raw.message === "string" &&
115
+ isErrorFrameMessage(raw.message));
116
+ default:
117
+ return false;
118
+ }
119
+ }
120
+ /**
121
+ * The signed fields of a frame and nothing else. `isFrameShape` tolerates extra properties — the
122
+ * hub is untrusted (spec D14) and may add any — and the digest covers only the enumerated ones,
123
+ * so a verified object must be rebuilt before it reaches a caller. Otherwise unsigned,
124
+ * attacker-chosen data rides inside an object the backend has just proved authentic. The header
125
+ * pairs are copied for the same reason: the arrays themselves are the hub's.
126
+ */
127
+ function signedFieldsOnly(frame) {
128
+ switch (frame.type) {
129
+ case "accepted":
130
+ return { seq: frame.seq, type: "accepted", sig: frame.sig };
131
+ case "end":
132
+ return { seq: frame.seq, type: "end", sig: frame.sig };
133
+ case "head":
134
+ return {
135
+ seq: frame.seq,
136
+ type: "head",
137
+ status: frame.status,
138
+ headers: frame.headers.map(([name, value]) => [name, value]),
139
+ sig: frame.sig,
140
+ };
141
+ case "data":
142
+ return { seq: frame.seq, type: "data", bytes: frame.bytes, sig: frame.sig };
143
+ case "error":
144
+ return { seq: frame.seq, type: "error", code: frame.code, message: frame.message, sig: frame.sig };
145
+ }
146
+ }
147
+ /**
148
+ * A `head` carries the canonical returned headers of contract C6 as sorted pairs. The digest
149
+ * covers that canonical form, so a frame whose pairs are spelled differently would let the hub
150
+ * restate a signed head; a HeaderError (duplicate name, control character in a value) says the
151
+ * same thing — these pairs are not the canonical form — and is therefore a rejection here, not
152
+ * an exception out of the verifier.
153
+ */
154
+ function isCanonicalHead(headers) {
155
+ try {
156
+ return JSON.stringify(filterHeaders(headers, isReturnedResponseHeader)) === JSON.stringify(headers);
157
+ }
158
+ catch (error) {
159
+ if (error instanceof HeaderError) {
160
+ return false;
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+ export class FrameChainVerifier {
166
+ publicKey;
167
+ requestId;
168
+ expectedSeq = 0;
169
+ prev;
170
+ last = null;
171
+ done = false;
172
+ constructor(publicKey, requestId, jti) {
173
+ this.publicKey = publicKey;
174
+ this.requestId = requestId;
175
+ this.prev = jti;
176
+ }
177
+ verify(raw) {
178
+ if (!isFrameShape(raw)) {
179
+ throw new FrameError("malformed frame");
180
+ }
181
+ if (raw.seq !== this.expectedSeq) {
182
+ throw new FrameError(`expected frame ${this.expectedSeq}, got ${raw.seq}`);
183
+ }
184
+ if (this.done) {
185
+ throw new FrameError("frame after a terminal frame");
186
+ }
187
+ const allowed = raw.seq === 0
188
+ ? raw.type === "accepted" || raw.type === "error"
189
+ : this.last === "accepted"
190
+ ? raw.type === "head" || raw.type === "error"
191
+ : raw.type === "data" || raw.type === "end" || raw.type === "error";
192
+ if (!allowed) {
193
+ throw new FrameError(`${raw.type} is not allowed at this point of the chain`);
194
+ }
195
+ if (raw.type === "head" && !isCanonicalHead(raw.headers)) {
196
+ throw new FrameError("head headers are not canonical");
197
+ }
198
+ let signature;
199
+ try {
200
+ signature = fromB64url(raw.sig);
201
+ }
202
+ catch {
203
+ throw new FrameError("malformed signature");
204
+ }
205
+ let signingInput;
206
+ try {
207
+ signingInput = frameSigningInput(this.requestId, raw, this.prev);
208
+ }
209
+ catch {
210
+ // A `data` payload that is not canonical base64url (contract C1) lands here: the hub is
211
+ // untrusted for integrity (spec D14), so its input is rejected, never thrown past us.
212
+ throw new FrameError("malformed frame fields");
213
+ }
214
+ if (!verifyBytes(this.publicKey, signingInput, signature)) {
215
+ throw new FrameError("frame signature does not verify");
216
+ }
217
+ this.prev = raw.sig;
218
+ this.expectedSeq += 1;
219
+ this.last = raw.type;
220
+ this.done = raw.type === "end" || raw.type === "error";
221
+ return signedFieldsOnly(raw);
222
+ }
223
+ }
224
+ //# sourceMappingURL=frames.js.map
@@ -0,0 +1,18 @@
1
+ import type { HeaderPair } from "./wire.js";
2
+ export type HeaderErrorCode = "duplicate_header" | "invalid_header";
3
+ export declare class HeaderError extends Error {
4
+ readonly code: HeaderErrorCode;
5
+ constructor(code: HeaderErrorCode, message: string);
6
+ }
7
+ export declare function isForwardedRequestHeader(name: string): boolean;
8
+ export declare function isReturnedResponseHeader(name: string): boolean;
9
+ /**
10
+ * Contract C6: lower-case, keep allowed, refuse control characters and non-token names,
11
+ * trim, refuse duplicates, sort by name. A header that is not on the allowlist is ignored
12
+ * without being validated — it never reaches the digest.
13
+ */
14
+ export declare function filterHeaders(pairs: Iterable<readonly [string, string]>, allowed: (name: string) => boolean): HeaderPair[];
15
+ export declare function canonicalHeaders(pairs: Iterable<readonly [string, string]>, allowed: (name: string) => boolean): string;
16
+ export declare function requestHeadersDigest(pairs: Iterable<readonly [string, string]>): string;
17
+ export declare function bodyDigest(body: Uint8Array): string;
18
+ //# sourceMappingURL=headers.d.ts.map
@@ -0,0 +1,88 @@
1
+ // GENERATED COPY of relay/src/protocol/headers.ts (#1377). Do not edit here: edit the relay's file,
2
+ // then run `npm run sync:relay-protocol` in cli/. `npm run check:relay-protocol` fails on any drift.
3
+ import { sha256b64url } from "./b64.js";
4
+ const FORWARDED_REQUEST_HEADERS = new Set([
5
+ "accept",
6
+ "authorization",
7
+ "content-type",
8
+ "idempotency-key",
9
+ "last-event-id",
10
+ "mcp-method",
11
+ "mcp-name",
12
+ "mcp-protocol-version",
13
+ "mcp-session-id",
14
+ "traceparent",
15
+ "tracestate",
16
+ ]);
17
+ const RETURNED_RESPONSE_HEADERS = new Set([
18
+ "cache-control",
19
+ "content-type",
20
+ "etag",
21
+ "location",
22
+ "mcp-protocol-version",
23
+ "mcp-session-id",
24
+ "retry-after",
25
+ "www-authenticate",
26
+ ]);
27
+ export class HeaderError extends Error {
28
+ code;
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.name = "HeaderError";
32
+ this.code = code;
33
+ }
34
+ }
35
+ export function isForwardedRequestHeader(name) {
36
+ return FORWARDED_REQUEST_HEADERS.has(name) || name.startsWith("mcp-param-");
37
+ }
38
+ export function isReturnedResponseHeader(name) {
39
+ return RETURNED_RESPONSE_HEADERS.has(name);
40
+ }
41
+ const SURROUNDING_WHITESPACE = /^[ \t]+|[ \t]+$/g;
42
+ /** RFC 7230 token, already lower-cased. Only the `mcp-param-` prefix can reach it unvetted. */
43
+ const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/;
44
+ /**
45
+ * Any code unit below 0x20 except TAB, plus DEL. The canonical form joins pairs with ":"
46
+ * and "\n", so a value carrying either separator would let two different header sets
47
+ * produce one digest — and the hub, untrusted for integrity (spec D14), is the party that
48
+ * would exploit that. Refusing the whole control range keeps the rule trivial to restate
49
+ * in Java.
50
+ */
51
+ const FORBIDDEN_VALUE_CHARS = /[\u0000-\u0008\u000a-\u001f\u007f]/;
52
+ /**
53
+ * Contract C6: lower-case, keep allowed, refuse control characters and non-token names,
54
+ * trim, refuse duplicates, sort by name. A header that is not on the allowlist is ignored
55
+ * without being validated — it never reaches the digest.
56
+ */
57
+ export function filterHeaders(pairs, allowed) {
58
+ const kept = new Map();
59
+ for (const [rawName, rawValue] of pairs) {
60
+ const name = rawName.toLowerCase();
61
+ if (!allowed(name)) {
62
+ continue;
63
+ }
64
+ if (!HEADER_NAME_TOKEN.test(name)) {
65
+ throw new HeaderError("invalid_header", `header name ${JSON.stringify(name)} is not a token`);
66
+ }
67
+ if (FORBIDDEN_VALUE_CHARS.test(rawValue)) {
68
+ throw new HeaderError("invalid_header", `header ${name} carries a control character`);
69
+ }
70
+ if (kept.has(name)) {
71
+ throw new HeaderError("duplicate_header", `header ${name} appears more than once`);
72
+ }
73
+ kept.set(name, rawValue.replace(SURROUNDING_WHITESPACE, ""));
74
+ }
75
+ return [...kept.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
76
+ }
77
+ export function canonicalHeaders(pairs, allowed) {
78
+ return filterHeaders(pairs, allowed)
79
+ .map(([name, value]) => `${name}:${value}\n`)
80
+ .join("");
81
+ }
82
+ export function requestHeadersDigest(pairs) {
83
+ return sha256b64url(canonicalHeaders(pairs, isForwardedRequestHeader));
84
+ }
85
+ export function bodyDigest(body) {
86
+ return sha256b64url(body);
87
+ }
88
+ //# sourceMappingURL=headers.js.map