@vincentt-xr/cli 0.22.0 → 0.24.0

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.
@@ -10,6 +10,8 @@ import { EventStore } from "./store.js";
10
10
  import { TesterRegistry, isTesterId } from "./testers.js";
11
11
  import { AnnotationStore } from "./annotations.js";
12
12
  import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
13
+ import { isConsolePreflight, preflightHeaders, responseHeaders, } from "./consoleOrigin.js";
14
+ import { boundedSpec } from "./specBounds.js";
13
15
  import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "../tunnel/index.js";
14
16
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
15
17
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
@@ -56,6 +58,7 @@ export function startRelay(opts = {}) {
56
58
  host,
57
59
  port: boundPort(),
58
60
  allowedOrigins,
61
+ consoleOrigin: opts.consoleOrigin,
59
62
  token: opts.token,
60
63
  store,
61
64
  annotations,
@@ -208,9 +211,22 @@ function handleHttp(req, res, ctx) {
208
211
  res.writeHead(403).end();
209
212
  return;
210
213
  }
214
+ const annotationRoute = p === `${ctx.wsPath}/annotation`;
215
+ // The console origin is matched HERE, inside the annotation route, and never
216
+ // joins the origin list: that list also gates the WebSocket, and a console
217
+ // page reaching the diagnostics socket is the hole this placement closes.
218
+ // Exact string equality, so `null`, a duplicated header, a trailing slash and
219
+ // a case variant all miss.
220
+ const fromConsole = annotationRoute &&
221
+ ctx.consoleOrigin !== undefined &&
222
+ req.headers.origin === ctx.consoleOrigin;
223
+ if (annotationRoute && req.method === "OPTIONS") {
224
+ handlePreflight(req, res, ctx, fromConsole);
225
+ return;
226
+ }
211
227
  // The local-only routes have no browser caller at all, so any Origin is foreign.
212
228
  const origins = localOnly ? [] : ctx.allowedOrigins;
213
- if (!isAllowedOrigin(req.headers.origin, origins)) {
229
+ if (!fromConsole && !isAllowedOrigin(req.headers.origin, origins)) {
214
230
  ctx.log("refused a request from a foreign Origin");
215
231
  res.writeHead(403).end();
216
232
  return;
@@ -221,6 +237,26 @@ function handleHttp(req, res, ctx) {
221
237
  return;
222
238
  }
223
239
  }
240
+ // On EVERY status from here, so the console can read a refusal as well as a
241
+ // 200 and never retries an annotation the relay already stored.
242
+ if (fromConsole) {
243
+ for (const [k, v] of Object.entries(responseHeaders(ctx.consoleOrigin))) {
244
+ res.setHeader(k, v);
245
+ }
246
+ }
247
+ if (annotationRoute && req.method === "GET") {
248
+ // The console's probe: presence only, and it writes nothing.
249
+ if (!fromConsole) {
250
+ res.writeHead(405).end();
251
+ return;
252
+ }
253
+ res.writeHead(ctx.annotations ? 204 : 503).end();
254
+ return;
255
+ }
256
+ if (annotationRoute && req.method !== "POST") {
257
+ res.writeHead(405).end();
258
+ return;
259
+ }
224
260
  if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
225
261
  res.writeHead(415).end();
226
262
  return;
@@ -229,7 +265,9 @@ function handleHttp(req, res, ctx) {
229
265
  handleQuery(req, res, url, ctx);
230
266
  return;
231
267
  }
232
- if (p === `${ctx.wsPath}/annotation`) {
268
+ if (annotationRoute) {
269
+ if (fromConsole)
270
+ ctx.log("annotation from the console origin");
233
271
  handleAnnotationPost(req, res, ctx);
234
272
  return;
235
273
  }
@@ -239,6 +277,27 @@ function handleHttp(req, res, ctx) {
239
277
  }
240
278
  res.writeHead(404).end();
241
279
  }
280
+ /**
281
+ * The console's CORS preflight on the annotation route. Answered only for the
282
+ * exact console origin asking for POST with at most `content-type`; anything
283
+ * else is 403 with no CORS header at all. Never `*`, never credentials, never a
284
+ * private-network grant.
285
+ */
286
+ function handlePreflight(req, res, ctx, fromConsole) {
287
+ if (!fromConsole) {
288
+ ctx.log("refused a preflight from a foreign Origin");
289
+ res.writeHead(403).end();
290
+ return;
291
+ }
292
+ const ok = isConsolePreflight(req.headers["access-control-request-method"], req.headers["access-control-request-headers"]);
293
+ if (!ok) {
294
+ ctx.log("refused a console preflight for a method or header it does not allow");
295
+ res.writeHead(403).end();
296
+ return;
297
+ }
298
+ ctx.log("answered a console preflight");
299
+ res.writeHead(204, preflightHeaders(ctx.consoleOrigin)).end();
300
+ }
242
301
  // The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
243
302
  // session token from the 0600 relay marker.
244
303
  function handleQuery(req, res, url, ctx) {
@@ -279,6 +338,16 @@ function handleAnnotationPost(req, res, ctx) {
279
338
  res.writeHead(400).end("annotation requires message + screenshot");
280
339
  return;
281
340
  }
341
+ // Refused, never clamped: the agent acts on these values, and a stored
342
+ // coordinate must be exactly the one that was sent.
343
+ // What is stored is the spec REBUILT from the checked fields, never the
344
+ // body's own object: an unknown key would otherwise ride to the agent.
345
+ const bounded = boundedSpec(input);
346
+ if (!bounded.ok) {
347
+ ctx.log("refused marks out of bounds");
348
+ res.writeHead(400).end("annotation marks out of bounds");
349
+ return;
350
+ }
282
351
  // The annotation's session key is RESOLVED, never assigned — `resolve`,
283
352
  // not `attribute`. This route is unauthenticated and reachable by anyone
284
353
  // holding the capability URL, so a door that minted here would let 64 bare
@@ -288,7 +357,12 @@ function handleAnnotationPost(req, res, ctx) {
288
357
  // The key is still hostile input either way: the record reaches an agent's
289
358
  // context on the consumer's stdout, so the raw string must never survive
290
359
  // onto it.
291
- const annotation = await ctx.annotations.add({ ...input, sessionId: ctx.testers.resolve(input.sessionId) }, { now: Date.now(), id: ctx.makeId() });
360
+ const annotation = await ctx.annotations.add({
361
+ message: input.message,
362
+ screenshot: input.screenshot,
363
+ spec: bounded.spec,
364
+ sessionId: ctx.testers.resolve(input.sessionId),
365
+ }, { now: Date.now(), id: ctx.makeId() });
292
366
  // The live stream is told an annotation ARRIVED, with the resolved tester
293
367
  // and nothing else. It is a fact, not a directive: the consumer is the
294
368
  // long-poll, and a terminal line naming what to run would be the platform
@@ -299,7 +373,11 @@ function handleAnnotationPost(req, res, ctx) {
299
373
  .end(JSON.stringify({ id: annotation.id, seq: annotation.seq }));
300
374
  })
301
375
  .catch((err) => {
302
- ctx.log(`annotation error: ${err}`);
376
+ // A fixed line, never the error's text: a JSON.parse message quotes the
377
+ // body, which is hostile input, and this log reaches the terminal.
378
+ ctx.log(err instanceof SyntaxError
379
+ ? "annotation error: the body is not JSON"
380
+ : "annotation error: not stored (too large, or the write failed)");
303
381
  if (!res.headersSent)
304
382
  res.writeHead(400).end();
305
383
  });
@@ -397,9 +475,14 @@ async function readQuery(req, url) {
397
475
  q.presence = true;
398
476
  return normalizeTester(q);
399
477
  }
400
- function readBody(req, maxBytes = Infinity) {
478
+ /**
479
+ * The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
480
+ * its own turns a multi-byte character split across two chunks into U+FFFD.
481
+ * The size limit is in bytes.
482
+ */
483
+ export function readBody(req, maxBytes = Infinity) {
401
484
  return new Promise((resolve, reject) => {
402
- let data = "";
485
+ const chunks = [];
403
486
  let bytes = 0;
404
487
  req.on("data", (c) => {
405
488
  bytes += c.length;
@@ -408,9 +491,9 @@ function readBody(req, maxBytes = Infinity) {
408
491
  req.destroy();
409
492
  return;
410
493
  }
411
- data += c;
494
+ chunks.push(c);
412
495
  });
413
- req.on("end", () => resolve(data));
496
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
414
497
  req.on("error", reject);
415
498
  });
416
499
  }
@@ -0,0 +1,39 @@
1
+ import type { AnnotationInput, AnnotationSpec } from "@vincentt-xr/harness/events";
2
+ export declare const COORD_MIN = -1;
3
+ export declare const COORD_MAX = 2;
4
+ export declare const MAX_SHAPES = 50;
5
+ export declare const MAX_STROKES = 50;
6
+ export declare const MAX_PINS = 50;
7
+ export declare const MAX_LABELS = 50;
8
+ export declare const MAX_PATH_POINTS = 500;
9
+ export declare const MAX_NOTE_CHARS = 2000;
10
+ export declare const MAX_MESSAGE_CHARS = 110000;
11
+ /** The mark colours every sender draws with: the keys of the harness's MARK_COLORS. */
12
+ export declare const MARK_COLOR_NAMES: ReadonlySet<string>;
13
+ /**
14
+ * The width and height a PNG data URL's IHDR declares, or undefined when it is
15
+ * not a PNG data URL. Reads only the first 24 decoded bytes; never decodes the
16
+ * image.
17
+ */
18
+ export declare function pngSize(dataUrl: string): {
19
+ width: number;
20
+ height: number;
21
+ } | undefined;
22
+ /** Refused, or in bounds with the `spec` to store (undefined when none was sent). */
23
+ export type BoundedSpec = {
24
+ ok: false;
25
+ } | {
26
+ ok: true;
27
+ spec?: AnnotationSpec;
28
+ };
29
+ /**
30
+ * Check an annotation's message and marks, and rebuild `spec` from the fields
31
+ * that passed. Assumes the caller already checked `message` and `screenshot` are
32
+ * strings.
33
+ *
34
+ * `spec` absent, `{}` and `{shapes: []}` are all in bounds. A field the relay
35
+ * does not know is not an error; it is simply not stored.
36
+ */
37
+ export declare function boundedSpec(input: AnnotationInput): BoundedSpec;
38
+ /** Whether an annotation's message and marks are within bounds. */
39
+ export declare function isWithinBounds(input: AnnotationInput): boolean;
@@ -0,0 +1,157 @@
1
+ // The annotation's marks are hostile input the agent then acts on, from any
2
+ // sender: the phone, the console, or anyone holding the preview address. The
3
+ // relay refuses an annotation outside these bounds rather than clamping it, and
4
+ // stores a `spec` REBUILT from the fields it checked: an unknown key is dropped,
5
+ // so what reaches the agent is exactly what was validated, value for value.
6
+ //
7
+ // Coordinates are picture-relative: [0,1] spans the picture and a value outside
8
+ // it is a point past the picture's edge. No sender's drawing margin reaches past
9
+ // [-1, 2].
10
+ export const COORD_MIN = -1;
11
+ export const COORD_MAX = 2;
12
+ export const MAX_SHAPES = 50;
13
+ export const MAX_STROKES = 50;
14
+ export const MAX_PINS = 50;
15
+ export const MAX_LABELS = 50;
16
+ export const MAX_PATH_POINTS = 500;
17
+ export const MAX_NOTE_CHARS = 2_000;
18
+ export const MAX_MESSAGE_CHARS = 110_000;
19
+ /** The mark colours every sender draws with: the keys of the harness's MARK_COLORS. */
20
+ export const MARK_COLOR_NAMES = new Set(["red", "yellow", "blue"]);
21
+ /**
22
+ * A legacy stroke's colour, which an app could set itself: a mark colour name, a
23
+ * hex colour, or one short lowercase word. Never free text.
24
+ */
25
+ const STROKE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|[a-z]{1,16})$/;
26
+ const SHAPE_TYPES = new Set(["rect", "circle", "arrow", "pen"]);
27
+ const PNG_PREFIX = "data:image/png;base64,";
28
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
29
+ /** Thrown inside the rebuild; never escapes `boundedSpec`. */
30
+ class Refused extends Error {
31
+ }
32
+ const refuse = () => {
33
+ throw new Refused();
34
+ };
35
+ const isRec = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
36
+ const coord = (v) => typeof v === "number" && Number.isFinite(v) && v >= COORD_MIN && v <= COORD_MAX
37
+ ? v
38
+ : refuse();
39
+ const size = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : refuse();
40
+ const point = (v) => isRec(v) ? { x: coord(v.x), y: coord(v.y) } : refuse();
41
+ const text = (v) => typeof v === "string" && v.length <= MAX_NOTE_CHARS ? v : refuse();
42
+ const list = (v, max) => Array.isArray(v) && v.length <= max ? v : refuse();
43
+ const path = (v) => list(v, MAX_PATH_POINTS).map(point);
44
+ function shape(v) {
45
+ if (!isRec(v))
46
+ return refuse();
47
+ if (typeof v.type !== "string" || !SHAPE_TYPES.has(v.type))
48
+ return refuse();
49
+ const n = typeof v.n === "number" && Number.isInteger(v.n) && v.n >= 1 && v.n <= MAX_SHAPES
50
+ ? v.n
51
+ : refuse();
52
+ const color = typeof v.color === "string" && MARK_COLOR_NAMES.has(v.color) ? v.color : refuse();
53
+ const base = { n, color, ...(v.note === undefined ? {} : { note: text(v.note) }) };
54
+ if (v.type === "arrow")
55
+ return { ...base, type: "arrow", from: point(v.from), to: point(v.to) };
56
+ if (v.type === "pen")
57
+ return { ...base, type: "pen", points: path(v.points) };
58
+ const [x, y, w, h] = [coord(v.x), coord(v.y), size(v.w), size(v.h)];
59
+ // The far corner is a coordinate too. Its sum carries float error, so it alone
60
+ // gets a hair of tolerance; every coordinate a sender wrote is checked exactly.
61
+ if (x + w > COORD_MAX + 1e-9 || y + h > COORD_MAX + 1e-9)
62
+ return refuse();
63
+ return { ...base, type: v.type, x, y, w, h };
64
+ }
65
+ function stroke(v) {
66
+ if (!isRec(v))
67
+ return refuse();
68
+ const out = { points: path(v.points) };
69
+ if (v.color !== undefined) {
70
+ if (typeof v.color !== "string" || !STROKE_COLOR_RE.test(v.color))
71
+ return refuse();
72
+ out.color = v.color;
73
+ }
74
+ return out;
75
+ }
76
+ function pin(v) {
77
+ if (!isRec(v))
78
+ return refuse();
79
+ const out = { x: coord(v.x), y: coord(v.y) };
80
+ if (v.label !== undefined)
81
+ out.label = text(v.label);
82
+ return out;
83
+ }
84
+ /**
85
+ * The width and height a PNG data URL's IHDR declares, or undefined when it is
86
+ * not a PNG data URL. Reads only the first 24 decoded bytes; never decodes the
87
+ * image.
88
+ */
89
+ export function pngSize(dataUrl) {
90
+ if (!dataUrl.startsWith(PNG_PREFIX))
91
+ return undefined;
92
+ // 32 base64 characters decode to exactly the 24 header bytes.
93
+ const head = Buffer.from(dataUrl.slice(PNG_PREFIX.length, PNG_PREFIX.length + 32), "base64");
94
+ if (head.length < 24)
95
+ return undefined;
96
+ if (!PNG_SIGNATURE.every((b, i) => head[i] === b))
97
+ return undefined;
98
+ if (head.toString("latin1", 12, 16) !== "IHDR")
99
+ return undefined;
100
+ return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
101
+ }
102
+ function frameInImage(v, screenshot) {
103
+ if (!isRec(v))
104
+ return refuse();
105
+ const { x, y, w, h } = v;
106
+ if (![x, y, w, h].every((n) => typeof n === "number" && Number.isInteger(n)))
107
+ return refuse();
108
+ const [fx, fy, fw, fh] = [x, y, w, h];
109
+ if (fx < 0 || fy < 0 || fw < 1 || fh < 1)
110
+ return refuse();
111
+ const image = pngSize(screenshot) ?? refuse();
112
+ if (fx + fw > image.width || fy + fh > image.height)
113
+ return refuse();
114
+ return { x: fx, y: fy, w: fw, h: fh };
115
+ }
116
+ /**
117
+ * Check an annotation's message and marks, and rebuild `spec` from the fields
118
+ * that passed. Assumes the caller already checked `message` and `screenshot` are
119
+ * strings.
120
+ *
121
+ * `spec` absent, `{}` and `{shapes: []}` are all in bounds. A field the relay
122
+ * does not know is not an error; it is simply not stored.
123
+ */
124
+ export function boundedSpec(input) {
125
+ if (input.message.length > MAX_MESSAGE_CHARS)
126
+ return { ok: false };
127
+ const raw = input.spec;
128
+ if (raw === undefined)
129
+ return { ok: true };
130
+ try {
131
+ if (!isRec(raw))
132
+ return refuse();
133
+ const spec = {};
134
+ if (raw.shapes !== undefined)
135
+ spec.shapes = list(raw.shapes, MAX_SHAPES).map(shape);
136
+ if (raw.strokes !== undefined)
137
+ spec.strokes = list(raw.strokes, MAX_STROKES).map(stroke);
138
+ if (raw.pins !== undefined)
139
+ spec.pins = list(raw.pins, MAX_PINS).map(pin);
140
+ if (raw.labels !== undefined)
141
+ spec.labels = list(raw.labels, MAX_LABELS).map(text);
142
+ if (raw.note !== undefined)
143
+ spec.note = text(raw.note);
144
+ if (raw.frameInImage !== undefined)
145
+ spec.frameInImage = frameInImage(raw.frameInImage, input.screenshot);
146
+ return { ok: true, spec };
147
+ }
148
+ catch (err) {
149
+ if (err instanceof Refused)
150
+ return { ok: false };
151
+ throw err;
152
+ }
153
+ }
154
+ /** Whether an annotation's message and marks are within bounds. */
155
+ export function isWithinBounds(input) {
156
+ return boundedSpec(input).ok;
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "qrcode": "^1.5.4",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.23.8",
31
- "@vincentt-xr/harness": "3.0.0"
31
+ "@vincentt-xr/harness": "3.2.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/qrcode": "^1.5.5",