@vincentt-xr/cli 0.23.0 → 0.25.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.
package/MIGRATION.md CHANGED
@@ -3,16 +3,39 @@
3
3
  Per version, newest first. Read by the platform's own version surface and served to
4
4
  creators' agents. Accurate or absent, never speculative (B-F5-2).
5
5
 
6
+ ## 0.25.0
7
+
8
+ required: false
9
+ No action required. `vincentt preview` leads with the console page and no creator-facing screen prints a localhost address.
10
+ - The start block prints `→ <project> · <workspace>` before the preview is created, then `✓ Preview is live`, then `Open the console:` with the project's console page, then `On a phone:` with the phone link and QR. The `→ Serving http://localhost:<port>` line is gone.
11
+ - Running `vincentt preview` while one is running in the folder prints the same two blocks: the console page, then the phone link.
12
+ - The not-signed-in screen ends with `Sign in: vincentt login` and `Then start it again: vincentt preview`. It no longer names the dev command or a local address. The preview's upgrade refusal likewise ends on the upgrade and `vincentt preview`.
13
+ - When the dev server is down, the advisory reads `! Your dev server isn't responding on port <n>.`
14
+ - `vincentt create` and `vincentt init` next steps no longer list the dev server; `vincentt preview` starts it. A non-interactive `create` that scaffolded without an account ends with `Signing in is what gives it an address a phone can open.`
15
+ - The browser page at the end of `vincentt login` reads `You're Signed In` and `Go back to your AI assistant to continue.` The problem-report consent page reads `Problem Reports Are On` or `Problem Reports Are Off` with the same line. Neither mentions the terminal.
16
+
17
+ ## 0.24.0
18
+
19
+ required: false
20
+ No action required. `vincentt preview` accepts feedback from the console the CLI signs in through, and the relay now refuses marks outside fixed bounds.
21
+ - The relay admits one browser origin besides the preview's own: the origin of the console the CLI is configured with (https, or http on this machine's loopback; anything else admits none). It answers that console's preflight and probe only on the annotation route, with that exact origin, never `*` and never with credentials.
22
+ - The diagnostics socket, the diagnostics query and the feedback drain refuse the console origin with 403, like any other browser origin.
23
+ - An annotation from any sender is refused with 400 and stored nowhere when a coordinate is outside [-1, 2], it has more than 50 marks, a pen path has more than 500 points, a note is over 2,000 characters, the message is over 110,000 characters, or `frameInImage` does not fit inside the screenshot's PNG size.
24
+ - Also refused with 400: a mark whose colour is not red, yellow or blue, or whose number is not 1 to 50; more than 50 legacy strokes, pins or labels; a stroke over 500 points or with a colour that is not a short colour token; a pin label or label over 2,000 characters.
25
+ - The stored annotation's `spec` is rebuilt from the fields the relay checked, so a field it does not know is dropped and never reaches the agent.
26
+ - An annotation sent from the console carries no tester.
27
+ - When the configured console is not the production console (a flag, an environment variable or the machine config can change it), the preview start block prints `Feedback is accepted from the console at <origin>`.
28
+
6
29
  ## 0.23.0
7
30
 
8
31
  required: false
9
32
  No action required. New verb `vincentt editor`, which hands the project to the Vincentt Editor desktop app.
10
- - `vincentt editor` (macOS 14 or later, no account) finds `Vincentt Editor.app` in `/Applications`, then `~/Applications`, checks it is signed by Vincentt, hands it the project folder and exits 0 with `✓ Sent <name> to Vincentt Editor.` It does not wait for the app. The app is a separate download from https://vincentt.studio/editor; the CLI installs no editor binary and has no port option for it.
33
+ - `vincentt editor` (macOS 14 or later, no account) finds the Vincentt Editor app in the Mac's Applications folder, then in the user's own Applications folder, checks it is signed by Vincentt, hands it the project folder and exits 0 with `✓ Sent <name> to Vincentt Editor.` It does not wait for the app. The app is a separate download from https://vincentt.studio/editor; the CLI installs no editor binary and has no port option for it.
11
34
  - It exits 1, last line `Nothing was opened.`, when the app is not installed, the installed copy is not signed by Vincentt, the Mac is not on macOS 14 or later, or another copy of the app is running from a different folder.
12
35
  - `vincentt editor --json` prints one `vincentt.editor-open/1` object: `{"schema","handedOff":true,"project","app","appVersion"}`, or `handedOff: false` with a `reason` (`not_installed`, `bad_signature`, `unsupported_os`, `other_copy_running`, `open_failed`) and, where known, `app` and `detail`. The sentence goes to stderr.
13
36
  - `vincentt editor --selection [--json]` prints what is selected in the editor (`vincentt.editor-selection/1`), and `vincentt editor --status --json` the editor's state for the folder (`vincentt.editor-state/1`, including `app.lastOpen`). Both work on any OS and exit 1, with one JSON object still on stdout, when no editor is running for the folder.
14
37
  - In `--status --json`, `app` is always `{"version","lastOpen"}`; when no editor is running, `lastOpen` is reported only for a folder this machine handed over with `vincentt editor`, from a state file written after that hand-off, and is `null` otherwise.
15
- - The CLI keeps those hand-offs in `~/.vincentt/editor-handoffs.json`, as a hash of each folder's path and a time, never the path itself. A file written in a newer format than this CLI reads is refused with an upgrade line on stderr and nothing on stdout.
38
+ - The CLI keeps those hand-offs in a file in its own settings folder, as a hash of each folder's path and a time, never the path itself. A file written in a newer format than this CLI reads is refused with an upgrade line on stderr and nothing on stdout.
16
39
 
17
40
  ## 0.22.0
18
41
 
package/README.md CHANGED
@@ -108,9 +108,8 @@ later verbs run without prompting. `init` never reaches that gate at all.
108
108
 
109
109
  ## The loop, once a project exists
110
110
 
111
- - `pnpm dev` — local dev server at `http://localhost:5173`.
112
- - `npx vincentt preview` — build, serve, and tunnel the app so you get one `https`
113
- URL (and a desktop QR page) to open on a phone. AR needs a secure context for
111
+ - `npx vincentt preview` — start the dev server and tunnel the app, then hand over
112
+ the project's console page, which carries the phone link and its QR. AR needs a secure context for
114
113
  camera access, so this is how you test tracking and gestures on a device.
115
114
  - `npx vincentt logs | network | trace` — read what the phone reported to the
116
115
  preview relay. Debug on-device misbehavior instead of guessing.
package/dist/cli/index.js CHANGED
@@ -39,7 +39,7 @@ import { USAGE_TEXT } from "./usage-text.js";
39
39
  import { PreviewUi } from "../preview/command.js";
40
40
  import { httpRelayClient } from "../lib/diagnostics.js";
41
41
  import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
42
- import { attachedToRunning, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, previewUserLimitRefused, settingsBanner, startBlock, } from "../preview/copy.js";
42
+ import { attachedToRunning, bindingLine, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, previewUserLimitRefused, settingsBanner, startBlock, } from "../preview/copy.js";
43
43
  import { chooseDevPort } from "../preview/devPort.js";
44
44
  import { isPortListening } from "../preview/net.js";
45
45
  import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, validateField, } from "../lib/settings.js";
@@ -570,9 +570,6 @@ async function preview() {
570
570
  }
571
571
  const devCommand = settings.devCommand.value;
572
572
  const { port: devPort, fresh: devPortFresh } = await chooseDevPort(settings);
573
- // What the creator gets running the dev command by hand, without PORT.
574
- const handRunUrl = `http://localhost:${settings.devPort.value}`;
575
- const localUrl = `http://localhost:${devPort}`;
576
573
  for (const l of settingsBanner({
577
574
  devCommand,
578
575
  devCommandSource: sourceWords(settings.devCommand),
@@ -589,7 +586,7 @@ async function preview() {
589
586
  // The gate is LOCAL and NOTHING IS ATTEMPTED: no spinner, no partial mint, no
590
587
  // session left in `pending`. The command stops before it touches the network.
591
588
  if (!(await currentToken())) {
592
- for (const l of notSignedIn(devCommand, handRunUrl))
589
+ for (const l of notSignedIn())
593
590
  console.log(l);
594
591
  process.exitCode = EXIT.NOT_AUTHENTICATED;
595
592
  return;
@@ -600,7 +597,7 @@ async function preview() {
600
597
  const ui = new PreviewUi({
601
598
  print: (l) => console.log(l),
602
599
  now: () => Date.now(),
603
- targetUrl: localUrl,
600
+ devPort,
604
601
  color,
605
602
  filterTester,
606
603
  filterViewer,
@@ -629,8 +626,10 @@ async function preview() {
629
626
  // No filter means the caller wanted the preview, not a diagnostics stream:
630
627
  // hand them the link and exit rather than blocking in the follow loop.
631
628
  // Following a live preview is what `vincentt logs`/`network`/`trace` are.
632
- for (const l of attachedToRunning(existing.url, existing.expiresAt))
629
+ const consoleUrl = await projectConsoleUrl(cwd, existing.sessionId);
630
+ for (const l of attachedToRunning(existing.url, consoleUrl, existing.expiresAt)) {
633
631
  console.log(l);
632
+ }
634
633
  return;
635
634
  }
636
635
  else {
@@ -642,11 +641,7 @@ async function preview() {
642
641
  let endingRefusal;
643
642
  let resolveDone;
644
643
  const refusedAtStart = (facts) => {
645
- for (const l of upgradeRefusalLines(facts, {
646
- kind: "preview_start",
647
- devCommand,
648
- localUrl: handRunUrl,
649
- })) {
644
+ for (const l of upgradeRefusalLines(facts, { kind: "preview_start" })) {
650
645
  console.log(l);
651
646
  }
652
647
  process.exitCode = EXIT.UPGRADE_REQUIRED;
@@ -654,12 +649,21 @@ async function preview() {
654
649
  const done = new Promise((r) => {
655
650
  resolveDone = r;
656
651
  });
652
+ // The binding line prints BEFORE the mint: a wrong binding is cheap to catch
653
+ // only while no session exists. An unlinked folder prints none — previewStart
654
+ // refuses it, and a guessed name would be a line about the wrong project.
655
+ if (await locateProjectBinding(cwd)) {
656
+ for (const l of bindingLine(await projectDisplayName(cwd), await activeOrgName())) {
657
+ console.log(l);
658
+ }
659
+ }
657
660
  let running;
658
661
  try {
659
662
  const started = await previewStart(cwd, (m) => console.error(`[preview] ${m}`), {
660
663
  appPort: devPort,
661
664
  appPortFresh: devPortFresh,
662
665
  devCommand,
666
+ consoleUrl: (await resolveAuthUrls()).consoleUrl,
663
667
  onHiddenPath: (p) => ui.hiddenPath(p),
664
668
  appStdio: has("--verbose") ? "inherit" : "ignore",
665
669
  onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
@@ -722,11 +726,12 @@ async function preview() {
722
726
  ? await renderTerminalQr(running.url).catch(() => undefined)
723
727
  : undefined;
724
728
  for (const l of startBlock({
725
- projectName: await projectDisplayName(cwd),
726
- orgName: await activeOrgName(),
727
- targetUrl: `http://localhost:${running.appPort}`,
728
729
  url: running.url,
729
730
  consoleUrl: await projectConsoleUrl(cwd, running.sessionId),
731
+ feedbackOrigin: running.consoleOrigin !== undefined &&
732
+ running.consoleOrigin !== new URL(DEFAULT_CONSOLE_URL).origin
733
+ ? running.consoleOrigin
734
+ : undefined,
730
735
  qr,
731
736
  expiresInHours: 12,
732
737
  })) {
@@ -195,7 +195,7 @@ export async function runConsentInBrowser(opts) {
195
195
  value: result.decision,
196
196
  page: {
197
197
  status: 200,
198
- html: browserPage(granted, granted ? "Reports allowed" : "Not enabled", "Return to your terminal. You can close this tab."),
198
+ html: browserPage(granted, granted ? "Problem Reports Are On" : "Problem Reports Are Off", "Go back to your AI assistant to continue."),
199
199
  },
200
200
  };
201
201
  }
@@ -134,11 +134,13 @@ export async function initCommand(deps, opts = {}) {
134
134
  }
135
135
  }
136
136
  // The template ships a pnpm lockfile and a hoisted node-linker, so telling a
137
- // creator to `npm run dev` in it produces a second lockfile and an unproven
137
+ // creator to `npm install` in it produces a second lockfile and an unproven
138
138
  // node_modules layout. Name the manager the scaffold is actually set up for.
139
139
  const pm = await detectPackageManager(target).catch(() => "npm");
140
140
  deps.print("");
141
141
  deps.print(`✓ Created ${label}`);
142
+ // No dev-server step: `vincentt preview` starts it, and the product never
143
+ // points the creator at the local loop (B-F3-6).
142
144
  const steps = [];
143
145
  // Keyed off the RESOLVED dir, not the flag — a prompted name needs the cd too.
144
146
  if (dir !== ".")
@@ -146,16 +148,18 @@ export async function initCommand(deps, opts = {}) {
146
148
  if (!installed || opts.skipInstall) {
147
149
  steps.push([`${pm} install`, "Install dependencies"]);
148
150
  }
149
- steps.push([`${pm} run dev`, "Start the local dev server"]);
150
151
  if (!opts.from) {
151
- deps.print("");
152
- for (const line of alignedSteps(steps))
153
- deps.print(line);
152
+ if (steps.length > 0) {
153
+ deps.print("");
154
+ for (const line of alignedSteps(steps))
155
+ deps.print(line);
156
+ }
154
157
  deps.print("");
155
158
  deps.print(" No account needed. When you're ready to put it online:");
156
159
  for (const line of alignedSteps([
157
160
  ["vincentt login", "Connect this machine to Vincentt"],
158
161
  ["vincentt create", "Register the project and reserve its address"],
162
+ ["vincentt preview", "Try it on your phone"],
159
163
  ])) {
160
164
  deps.print(line);
161
165
  }
@@ -181,6 +185,7 @@ export async function initCommand(deps, opts = {}) {
181
185
  return code;
182
186
  deps.print("");
183
187
  deps.print(" Next:");
188
+ steps.push(["vincentt preview", "Try it on your phone"]);
184
189
  for (const line of alignedSteps(steps))
185
190
  deps.print(line);
186
191
  return 0;
@@ -86,10 +86,10 @@ export async function registerAfterScaffold(deps, opts = {}) {
86
86
  resume(deps.print, deps.dirLabel, resumeCommand, needsCd);
87
87
  if (scaffolded) {
88
88
  deps.print("");
89
- // B-F2-9(a): the open route must be named in something the creator's
90
- // agent reads. On a non-TTY this output IS that artifact — the TTY
91
- // branch's login prompt, which carries the same line, is unreachable here.
92
- deps.print(" Or don't: the local dev loop needs no account.");
89
+ // B-F3-6 (amended): the product points only at the platform, so this
90
+ // ending names what signing in buys, never the local dev loop. The open
91
+ // route B-F2-9(a) requires stays named by the template's verb list.
92
+ deps.print(" Signing in is what gives it an address a phone can open.");
93
93
  }
94
94
  deps.print("");
95
95
  deps.print(closing);
@@ -176,6 +176,7 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
176
176
  expiresAt: preview.expiresAt,
177
177
  pidStartTime: await readProcessStartTime(process.pid),
178
178
  token: preview.relayToken,
179
+ sessionId: preview.sessionId,
179
180
  });
180
181
  return { preview, started: true };
181
182
  }
@@ -203,16 +203,15 @@ export async function createCommand(deps, opts = {}) {
203
203
  if (code !== 0 && code !== EXIT.DELIVERED_NOT_REGISTERED)
204
204
  return code;
205
205
  if (scaffolded) {
206
- // This reader has never run the dev server, so the block that fits is the one
207
- // `init` already ships for exactly this reader — two aligned columns off the
208
- // DETECTED package manager, never a hardcoded one (the template ships a pnpm
209
- // lockfile and a hoisted node-linker).
206
+ // Two aligned columns off the DETECTED package manager, never a hardcoded one
207
+ // (the template ships a pnpm lockfile and a hoisted node-linker). No dev-server
208
+ // step: `vincentt preview` starts it, and the product never points the creator
209
+ // at the local loop (B-F3-6).
210
210
  const steps = [];
211
211
  if (opts.dir)
212
212
  steps.push([`cd ${opts.dir}`, ""]);
213
213
  if (!installed || opts.skipInstall)
214
214
  steps.push([`${pm} install`, "Install dependencies"]);
215
- steps.push([`${pm} run dev`, "Start the local dev server"]);
216
215
  steps.push(["vincentt preview", "Try it on your phone"]);
217
216
  deps.print("");
218
217
  for (const line of alignedSteps(steps))
@@ -30,6 +30,12 @@ export interface RelayInfo {
30
30
  * whose relay asks for none.
31
31
  */
32
32
  token?: string;
33
+ /**
34
+ * The running session's id, so a re-run can hand over the console page that
35
+ * shows THIS preview rather than the bare project page. Never printed on its
36
+ * own; absent from markers written by an older CLI.
37
+ */
38
+ sessionId?: string;
33
39
  }
34
40
  export declare function relayInfoPath(cwd: string): string;
35
41
  /**
@@ -166,7 +166,7 @@ export async function runPkceLogin(opts) {
166
166
  value: result.code,
167
167
  page: {
168
168
  status: 200,
169
- html: browserPage(true, "You're all set", "Return to your terminal. You can close this tab."),
169
+ html: browserPage(true, "You're Signed In", "Go back to your AI assistant to continue."),
170
170
  },
171
171
  };
172
172
  }
@@ -6,8 +6,8 @@ import { type UpgradeFacts } from "../shared/upgradeError.js";
6
6
  export interface PreviewUiDeps {
7
7
  print: (line: string) => void;
8
8
  now: () => number;
9
- /** The forward target, named identically in the start block and the 502. */
10
- targetUrl: string;
9
+ /** The forwarded dev port, named identically in the settings banner and the 502. */
10
+ devPort: number;
11
11
  color: boolean;
12
12
  /** Filter the stream to one tester (`--tester`) — one tab, across refreshes. */
13
13
  filterTester?: string;
@@ -238,7 +238,7 @@ export class PreviewUi {
238
238
  const settings = this.deps.devCommand !== undefined && this.deps.portSource !== undefined
239
239
  ? { devCommand: this.deps.devCommand, portSource: this.deps.portSource }
240
240
  : undefined;
241
- this.lines(copy.originDown(this.deps.targetUrl, settings).map((l) => ` ${l}`));
241
+ this.lines(copy.originDown(this.deps.devPort, settings).map((l) => ` ${l}`));
242
242
  }
243
243
  originRecovered() {
244
244
  this.originDown = false;
@@ -1,33 +1,41 @@
1
+ /**
2
+ * Flow L13 — the binding line, printed BEFORE the mint. The creator sees which
3
+ * project this folder is before anything is minted, the only moment a wrong
4
+ * binding is cheap to catch; the start block follows once the preview is live.
5
+ */
6
+ export declare function bindingLine(projectName: string, orgName: string): string[];
7
+ /** Flow L13 — the start block, after the binding line. */
1
8
  export interface StartBlockInput {
2
- projectName: string;
3
- orgName: string;
4
- /** The forward target, printed BEFORE the URL so the 502 advisory matches it. */
5
- targetUrl: string;
6
9
  url: string;
7
10
  /**
8
- * The project's console page, or undefined for a folder with no binding. An
9
- * unlinked folder gets NO console line rather than a guessed one — the same rule
10
- * `consoleVerbCommand` follows, and for the same reason: a link to the wrong
11
- * project is worse than no link.
11
+ * The project's console page. Always set on the start path, because a preview
12
+ * refuses an unlinked folder; kept optional so a missing binding prints NO
13
+ * console line rather than a guessed one — a link to the wrong project is worse
14
+ * than no link.
12
15
  */
13
16
  consoleUrl?: string;
17
+ /**
18
+ * The console origin the relay accepts feedback from, passed ONLY when it is
19
+ * not the production console. A flag or environment variable can point the
20
+ * CLI at another console, and the creator sees which one before any feedback
21
+ * arrives from it.
22
+ */
23
+ feedbackOrigin?: string;
14
24
  /** Pre-rendered QR rows, or undefined for non-TTY / piped / --no-qr. */
15
25
  qr?: string[];
16
26
  expiresInHours: number;
17
27
  }
18
28
  export declare function startBlock(i: StartBlockInput): string[];
19
29
  /**
20
- * Flow L14 — not signed in. The B2/B21 line, and the one screen that must not
21
- * feel like a wall.
30
+ * Flow L14 — not signed in.
22
31
  *
23
32
  * Line 1 is f1's exact string, so a creator meeting this gate in a second
24
- * command reads one product. The last paragraph is the most important copy in
25
- * f3's CLI: the overview commits that the local room stays a real room, and a
26
- * creator told only "sign in" has just been told the product does not work
27
- * without an account, which is false. It is offered as a CAPABILITY, not as
28
- * consolation — no "unfortunately", no "you can still".
33
+ * command reads one product. The second line is the reason, not a rule. No local
34
+ * alternative is offered (B-F3-6 as amended: the local loop keeps working, but
35
+ * no product surface points at it) — an agent reading this screen relays what
36
+ * it says, and the only thing to relay is: sign in.
29
37
  */
30
- export declare function notSignedIn(devCommand?: string, localUrl?: string): string[];
38
+ export declare function notSignedIn(): string[];
31
39
  /**
32
40
  * Flow P1 — the two settings lines, printed right after resolution and before
33
41
  * the sign-in check, the mint and the spawn. They are the ONLY disclosure of the
@@ -65,9 +73,10 @@ export declare function followingFooter(): string[];
65
73
  * room over an `npm run dev` they forgot.
66
74
  *
67
75
  * The middle line splits the wire in two, because the creator's first instinct
68
- * on a 502 is to suspect us.
76
+ * on a 502 is to suspect us. It names the PORT, matching the settings banner,
77
+ * never a loopback URL an agent could paste.
69
78
  */
70
- export declare function originDown(targetUrl: string, settings?: {
79
+ export declare function originDown(devPort: number, settings?: {
71
80
  devCommand: string;
72
81
  portSource: "flag" | "vincentt.json" | "default";
73
82
  }): string[];
@@ -190,10 +199,13 @@ export declare function supersededLoser(): string[];
190
199
  * each of those re-runs superseded the previous session: the link really did
191
200
  * keep changing, because the attach never printed one.
192
201
  *
193
- * The URL renders bare on its own line, matching the start block, so anything
194
- * scraping for a link finds it in the same shape either way.
202
+ * The two addresses render in the start block's order and shape (Flow L16a),
203
+ * console page first, so an agent that re-runs preview to recover a link finds
204
+ * the console page exactly where the start block put it. `consoleUrl` carries
205
+ * the running session when the marker holds its id, and is undefined only for an
206
+ * unlinked folder, which prints no console line rather than a guessed one.
195
207
  */
196
- export declare function attachedToRunning(url: string, expiresAt?: string): string[];
208
+ export declare function attachedToRunning(url: string, consoleUrl?: string, expiresAt?: string): string[];
197
209
  /**
198
210
  * c. Superseded — the terminal that WON. This sits ABOVE the `✓ Preview is live`
199
211
  * block so the consequence is read before the URL is copied.
@@ -16,53 +16,60 @@
16
16
  // session id, the edge hostname, or the workingTreeKey. The grant is the
17
17
  // sharpest — it transits an agent's tool-output transcript by default in this
18
18
  // product, so f1's "a token is never printed" extends to it verbatim.
19
- /** Flow L13 — the start block. */
20
19
  import { escapeControl, terminalSafe } from "../lib/terminalText.js";
21
20
  import { INSTALL_COMMAND } from "../shared/upgradeError.js";
22
- export function startBlock(i) {
23
- const out = [
21
+ /**
22
+ * Flow L13 — the binding line, printed BEFORE the mint. The creator sees which
23
+ * project this folder is before anything is minted, the only moment a wrong
24
+ * binding is cheap to catch; the start block follows once the preview is live.
25
+ */
26
+ export function bindingLine(projectName, orgName) {
27
+ return [
24
28
  "",
25
29
  // The org is dropped rather than rendered as a dangling separator when it
26
30
  // is not known. `Museum AR Tour · ` reads as a truncated line — the one
27
31
  // moment a wrong binding is cheap to catch is the one moment the line must
28
32
  // not look broken.
29
- i.orgName ? `→ ${i.projectName} · ${i.orgName}` : `→ ${i.projectName}`,
30
- `→ Serving ${i.targetUrl}`,
31
- "",
32
- "✓ Preview is live",
33
- "",
34
- // The URL is on its own line, indented, unwrapped and UNADORNED. No `URL:`
35
- // label, no backticks, no surrounding punctuation — it is the single
36
- // most-copied string in the product, and anything beside it ends up in
37
- // someone's paste.
38
- ` ${i.url}`,
39
- "",
33
+ orgName ? `→ ${projectName} · ${orgName}` : `→ ${projectName}`,
40
34
  ];
35
+ }
36
+ export function startBlock(i) {
37
+ // No loopback address: the settings banner already names the dev port, and a
38
+ // URL in this transcript is a URL an agent can paste. A loopback one reaches
39
+ // nobody but this machine (B-F3-6: the local loop works, no surface points at
40
+ // it).
41
+ const out = ["", "✓ Preview is live", ""];
42
+ // Each URL is on its own line, indented, unwrapped and UNADORNED. No `URL:`
43
+ // label, no backticks, no surrounding punctuation — anything beside it ends up
44
+ // in someone's paste.
45
+ //
46
+ // The console page comes FIRST: it carries the phone link, the QR, the live
47
+ // device view and Stop, so it is the one address an agent hands over, and the
48
+ // first one it reads.
49
+ if (i.consoleUrl) {
50
+ out.push(" Open the console:", "", ` ${i.consoleUrl}`, "");
51
+ }
52
+ out.push(" On a phone:", "", ` ${i.url}`, "");
41
53
  if (i.qr && i.qr.length > 0) {
42
54
  out.push(...i.qr.map((row) => ` ${row}`), "");
43
55
  }
44
56
  out.push(" Scan it, or open the link on any phone. No sign-in needed on the phone.", ` Expires in ${i.expiresInHours}h · Ctrl-C to stop`, "");
45
- // BELOW the phone URL and its instruction, never above. The phone address is the
46
- // line the creator is waiting on; a second URL printed first is a second thing to
47
- // read before the one that matters. Dropped entirely when the folder is unlinked.
48
- if (i.consoleUrl) {
49
- out.push(" In the console:", "", ` ${i.consoleUrl}`, "");
57
+ if (i.feedbackOrigin) {
58
+ out.push(` Feedback is accepted from the console at ${terminalSafe(i.feedbackOrigin)}`, "");
50
59
  }
51
60
  out.push(" Waiting for a device…", "");
52
61
  return out;
53
62
  }
54
63
  /**
55
- * Flow L14 — not signed in. The B2/B21 line, and the one screen that must not
56
- * feel like a wall.
64
+ * Flow L14 — not signed in.
57
65
  *
58
66
  * Line 1 is f1's exact string, so a creator meeting this gate in a second
59
- * command reads one product. The last paragraph is the most important copy in
60
- * f3's CLI: the overview commits that the local room stays a real room, and a
61
- * creator told only "sign in" has just been told the product does not work
62
- * without an account, which is false. It is offered as a CAPABILITY, not as
63
- * consolation — no "unfortunately", no "you can still".
67
+ * command reads one product. The second line is the reason, not a rule. No local
68
+ * alternative is offered (B-F3-6 as amended: the local loop keeps working, but
69
+ * no product surface points at it) — an agent reading this screen relays what
70
+ * it says, and the only thing to relay is: sign in.
64
71
  */
65
- export function notSignedIn(devCommand = "npm run dev", localUrl = "http://localhost:5173") {
72
+ export function notSignedIn() {
66
73
  return [
67
74
  "",
68
75
  "✗ You're not signed in.",
@@ -70,10 +77,7 @@ export function notSignedIn(devCommand = "npm run dev", localUrl = "http://local
70
77
  " A preview needs a hostname your phone can reach, and that needs an account.",
71
78
  "",
72
79
  " Sign in: vincentt login",
73
- "",
74
- ` Your local dev loop does not need one. Run \`${devCommand}\` and open`,
75
- ` ${localUrl} on this machine. The camera works there.`,
76
- " What signing in adds is the phone.",
80
+ " Then start it again: vincentt preview",
77
81
  "",
78
82
  ];
79
83
  }
@@ -153,12 +157,13 @@ export function followingFooter() {
153
157
  * room over an `npm run dev` they forgot.
154
158
  *
155
159
  * The middle line splits the wire in two, because the creator's first instinct
156
- * on a 502 is to suspect us.
160
+ * on a 502 is to suspect us. It names the PORT, matching the settings banner,
161
+ * never a loopback URL an agent could paste.
157
162
  */
158
- export function originDown(targetUrl, settings) {
163
+ export function originDown(devPort, settings) {
159
164
  if (settings === undefined) {
160
165
  return [
161
- `! Your dev server isn't responding on ${targetUrl}.`,
166
+ `! Your dev server isn't responding on port ${devPort}.`,
162
167
  "",
163
168
  " The preview is still live. The phone is reaching us; we can't reach your app.",
164
169
  " Start it and reload the phone: npm run dev",
@@ -176,7 +181,7 @@ export function originDown(targetUrl, settings) {
176
181
  ? " If it runs on another port, pass that port with --port."
177
182
  : " If it runs on another port, pass --port or add vincentt.json.";
178
183
  return [
179
- `! Your dev server isn't responding on ${targetUrl}${source}.`,
184
+ `! Your dev server isn't responding on port ${devPort}${source}.`,
180
185
  "",
181
186
  " The preview is still live. The phone is reaching us; we can't reach your app.",
182
187
  ` Start it and reload the phone: ${settings.devCommand}`,
@@ -402,11 +407,18 @@ export function supersededLoser() {
402
407
  * each of those re-runs superseded the previous session: the link really did
403
408
  * keep changing, because the attach never printed one.
404
409
  *
405
- * The URL renders bare on its own line, matching the start block, so anything
406
- * scraping for a link finds it in the same shape either way.
410
+ * The two addresses render in the start block's order and shape (Flow L16a),
411
+ * console page first, so an agent that re-runs preview to recover a link finds
412
+ * the console page exactly where the start block put it. `consoleUrl` carries
413
+ * the running session when the marker holds its id, and is undefined only for an
414
+ * unlinked folder, which prints no console line rather than a guessed one.
407
415
  */
408
- export function attachedToRunning(url, expiresAt) {
409
- const lines = ["", "→ A preview is already running for this folder.", "", url, ""];
416
+ export function attachedToRunning(url, consoleUrl, expiresAt) {
417
+ const lines = ["", "→ A preview is already running for this folder.", ""];
418
+ if (consoleUrl) {
419
+ lines.push(" Open the console:", "", ` ${consoleUrl}`, "");
420
+ }
421
+ lines.push(" On a phone:", "", ` ${url}`, "");
410
422
  // Stated only when known: a marker written by an older CLI has no expiry, and
411
423
  // inventing one would be the over-claim this copy exists to avoid.
412
424
  if (expiresAt !== undefined) {
@@ -101,6 +101,11 @@ export interface StartPreviewOptions {
101
101
  appReadyTimeoutMs?: number;
102
102
  /** Progress sink. Never write app stdout to an MCP server's stdout. */
103
103
  onLog?: (message: string) => void;
104
+ /**
105
+ * The console this CLI signs in through, from the creator's own configuration.
106
+ * Its origin, if valid, may send feedback to the relay's annotation route.
107
+ */
108
+ consoleUrl?: string;
104
109
  /**
105
110
  * A viewer label appearing on a stream. Fires per OPEN, so the roster's
106
111
  * per-label idempotence is what keeps one device from announcing itself once
@@ -171,6 +176,8 @@ export interface RunningPreview {
171
176
  appPort: number;
172
177
  /** Whether the app was responding when we returned. */
173
178
  appReady: boolean;
179
+ /** The console origin the relay admits on its annotation route, if any. */
180
+ consoleOrigin?: string;
174
181
  /** Tear down the tunnel, the app dev serve, the front proxy and the relay. */
175
182
  stop: () => Promise<void>;
176
183
  }
@@ -17,6 +17,7 @@ import { spawn, spawnSync } from "node:child_process";
17
17
  import { realpath } from "node:fs/promises";
18
18
  import { createServer } from "node:http";
19
19
  import { TunnelClient, resolveForwardTarget, } from "../tunnel/index.js";
20
+ import { consoleOriginOf } from "../relay/consoleOrigin.js";
20
21
  import { startRelay } from "../relay/server.js";
21
22
  import { mintRelayToken } from "../relay/guard.js";
22
23
  import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
@@ -168,10 +169,12 @@ export async function startPreview(opts) {
168
169
  }
169
170
  // Fixed here, from the server-computed URL, and never from a request.
170
171
  const previewOrigin = new URL(minted.url).origin;
172
+ const consoleOrigin = consoleOriginOf(opts.consoleUrl);
171
173
  relay = startRelay({
172
174
  port: relayPort,
173
175
  path: harnessPath,
174
176
  allowedOrigins: [previewOrigin],
177
+ consoleOrigin,
175
178
  token: relayToken,
176
179
  // Annotations land beside the binding, so `vincentt feedback` finds them
177
180
  // from anywhere in the tree rather than only from the folder that started
@@ -293,6 +296,7 @@ export async function startPreview(opts) {
293
296
  relayToken,
294
297
  appPort,
295
298
  appReady,
299
+ consoleOrigin,
296
300
  stop,
297
301
  };
298
302
  }
@@ -0,0 +1,12 @@
1
+ /** The console origin to admit for `consoleUrl`, or undefined to admit none. */
2
+ export declare function consoleOriginOf(consoleUrl: string | undefined): string | undefined;
3
+ /**
4
+ * Is this preflight one the relay answers? The Origin was already matched
5
+ * exactly. The method must be exactly POST, and the requested headers, parsed
6
+ * as a comma list, trimmed and lowercased, a subset of `content-type`.
7
+ */
8
+ export declare function isConsolePreflight(method: string | string[] | undefined, requestHeaders: string | string[] | undefined): boolean;
9
+ /** The CORS headers on a preflight the relay answers. Nothing else, ever. */
10
+ export declare function preflightHeaders(consoleOrigin: string): Record<string, string>;
11
+ /** The CORS headers on the probe and on every POST response to the console. */
12
+ export declare function responseHeaders(consoleOrigin: string): Record<string, string>;
@@ -0,0 +1,68 @@
1
+ // The one browser origin the relay admits besides the preview's own: the console
2
+ // this CLI signs in through, and only on the annotation route.
3
+ //
4
+ // It comes from the creator's own configuration (flag, env, machine config),
5
+ // never from a request and never from the folder's binding. A value that is not
6
+ // exactly an https origin, or an http origin on this machine's loopback, grants
7
+ // nothing, and the relay then behaves as one with no console origin at all.
8
+ import { isLoopbackOrigin } from "./guard.js";
9
+ /** The console origin to admit for `consoleUrl`, or undefined to admit none. */
10
+ export function consoleOriginOf(consoleUrl) {
11
+ if (consoleUrl === undefined)
12
+ return undefined;
13
+ let url;
14
+ try {
15
+ url = new URL(consoleUrl.trim());
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ // `origin` is the literal "null" for every scheme without a host, so this one
21
+ // check refuses `javascript:`, `data:`, `file:` and a blob URL alike.
22
+ const origin = url.origin;
23
+ if (origin === "null" || origin === "*")
24
+ return undefined;
25
+ if (url.username !== "" || url.password !== "")
26
+ return undefined;
27
+ if (url.protocol === "https:")
28
+ return origin;
29
+ if (url.protocol === "http:" && isLoopbackOrigin(origin))
30
+ return origin;
31
+ return undefined;
32
+ }
33
+ /**
34
+ * Is this preflight one the relay answers? The Origin was already matched
35
+ * exactly. The method must be exactly POST, and the requested headers, parsed
36
+ * as a comma list, trimmed and lowercased, a subset of `content-type`.
37
+ */
38
+ export function isConsolePreflight(method, requestHeaders) {
39
+ if (method !== "POST")
40
+ return false;
41
+ if (requestHeaders === undefined)
42
+ return true;
43
+ if (typeof requestHeaders !== "string")
44
+ return false;
45
+ if (requestHeaders.trim() === "")
46
+ return true;
47
+ return requestHeaders
48
+ .split(",")
49
+ .map((h) => h.trim().toLowerCase())
50
+ .every((h) => h === "content-type");
51
+ }
52
+ /** The CORS headers on a preflight the relay answers. Nothing else, ever. */
53
+ export function preflightHeaders(consoleOrigin) {
54
+ return {
55
+ "access-control-allow-origin": consoleOrigin,
56
+ "access-control-allow-methods": "POST",
57
+ "access-control-allow-headers": "content-type",
58
+ "access-control-max-age": "600",
59
+ vary: "Origin",
60
+ };
61
+ }
62
+ /** The CORS headers on the probe and on every POST response to the console. */
63
+ export function responseHeaders(consoleOrigin) {
64
+ return {
65
+ "access-control-allow-origin": consoleOrigin,
66
+ vary: "Origin",
67
+ };
68
+ }
@@ -16,8 +16,9 @@ export declare function isAllowedOrigin(raw: string | string[] | undefined, allo
16
16
  export declare function isLoopbackOrigin(raw: string): boolean;
17
17
  /**
18
18
  * A cross-site form or `no-cors` fetch can only send `text/plain`, form or
19
- * multipart bodies without a preflight. Requiring JSON forces a preflight the
20
- * relay never answers.
19
+ * multipart bodies without a preflight. Requiring JSON forces a preflight, and
20
+ * the relay answers one only for the configured console origin, only on the
21
+ * annotation route.
21
22
  */
22
23
  export declare function isJsonContentType(raw: string | undefined): boolean;
23
24
  export declare function hasRelayToken(raw: string | string[] | undefined, token: string): boolean;
@@ -1,8 +1,9 @@
1
1
  // The relay's request gate. Every caller the relay serves is on this machine:
2
2
  // the CLI (no Origin), the tunnel client and the front proxy (both rewrite Host
3
3
  // to the loopback target), and the preview page itself (its own minted origin).
4
- // A web page the creator happens to have open is none of those, and these checks
5
- // are what tell it apart.
4
+ // The one exception is the creator's console, admitted on the annotation route
5
+ // only (see consoleOrigin.ts). A web page the creator happens to have open is
6
+ // none of those, and these checks are what tell it apart.
6
7
  import { randomBytes, timingSafeEqual } from "node:crypto";
7
8
  /** The header carrying the per-session token on the local-only routes. */
8
9
  export const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
@@ -55,8 +56,9 @@ export function isLoopbackOrigin(raw) {
55
56
  }
56
57
  /**
57
58
  * A cross-site form or `no-cors` fetch can only send `text/plain`, form or
58
- * multipart bodies without a preflight. Requiring JSON forces a preflight the
59
- * relay never answers.
59
+ * multipart bodies without a preflight. Requiring JSON forces a preflight, and
60
+ * the relay answers one only for the configured console origin, only on the
61
+ * annotation route.
60
62
  */
61
63
  export function isJsonContentType(raw) {
62
64
  if (raw === undefined)
@@ -64,6 +64,15 @@ export interface RelayOptions {
64
64
  * loopback pass, which is what a standalone `vincentt relay` serves.
65
65
  */
66
66
  allowedOrigins?: readonly string[];
67
+ /**
68
+ * The console this CLI signs in through, already validated (`consoleOriginOf`).
69
+ * Admitted on the annotation route ONLY: its preflight and probe are answered
70
+ * there and its POST carries CORS headers. It is never added to
71
+ * `allowedOrigins`, which also gates the WebSocket, so the diagnostics socket,
72
+ * `/query` and `feedback/next` refuse it like any foreign origin. Omitted, the
73
+ * relay admits no console.
74
+ */
75
+ consoleOrigin?: string;
67
76
  /**
68
77
  * The per-session secret `/query` and `feedback/next` require in
69
78
  * `x-vincentt-relay-token`. The preview always sets it; without it those
@@ -136,3 +145,9 @@ export declare function isUnsafeRelayPath(rawUrl: string): boolean;
136
145
  * number before it reaches a timer.
137
146
  */
138
147
  export declare function feedbackTimeoutMs(raw: string | null): number;
148
+ /**
149
+ * The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
150
+ * its own turns a multi-byte character split across two chunks into U+FFFD.
151
+ * The size limit is in bytes.
152
+ */
153
+ export declare function readBody(req: IncomingMessage, maxBytes?: number): Promise<string>;
@@ -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,
@@ -144,7 +147,9 @@ export function startRelay(opts = {}) {
144
147
  });
145
148
  ws.on("close", () => log("client disconnected"));
146
149
  });
147
- http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
150
+ // Port only: this line reaches `vincentt preview`'s transcript, and a loopback
151
+ // address there is one an agent can paste and hand over.
152
+ http.listen(port, host, () => log(`relay listening on port ${port} (ws ${wsPath}, http /query)`));
148
153
  return {
149
154
  store,
150
155
  annotations,
@@ -208,9 +213,22 @@ function handleHttp(req, res, ctx) {
208
213
  res.writeHead(403).end();
209
214
  return;
210
215
  }
216
+ const annotationRoute = p === `${ctx.wsPath}/annotation`;
217
+ // The console origin is matched HERE, inside the annotation route, and never
218
+ // joins the origin list: that list also gates the WebSocket, and a console
219
+ // page reaching the diagnostics socket is the hole this placement closes.
220
+ // Exact string equality, so `null`, a duplicated header, a trailing slash and
221
+ // a case variant all miss.
222
+ const fromConsole = annotationRoute &&
223
+ ctx.consoleOrigin !== undefined &&
224
+ req.headers.origin === ctx.consoleOrigin;
225
+ if (annotationRoute && req.method === "OPTIONS") {
226
+ handlePreflight(req, res, ctx, fromConsole);
227
+ return;
228
+ }
211
229
  // The local-only routes have no browser caller at all, so any Origin is foreign.
212
230
  const origins = localOnly ? [] : ctx.allowedOrigins;
213
- if (!isAllowedOrigin(req.headers.origin, origins)) {
231
+ if (!fromConsole && !isAllowedOrigin(req.headers.origin, origins)) {
214
232
  ctx.log("refused a request from a foreign Origin");
215
233
  res.writeHead(403).end();
216
234
  return;
@@ -221,6 +239,26 @@ function handleHttp(req, res, ctx) {
221
239
  return;
222
240
  }
223
241
  }
242
+ // On EVERY status from here, so the console can read a refusal as well as a
243
+ // 200 and never retries an annotation the relay already stored.
244
+ if (fromConsole) {
245
+ for (const [k, v] of Object.entries(responseHeaders(ctx.consoleOrigin))) {
246
+ res.setHeader(k, v);
247
+ }
248
+ }
249
+ if (annotationRoute && req.method === "GET") {
250
+ // The console's probe: presence only, and it writes nothing.
251
+ if (!fromConsole) {
252
+ res.writeHead(405).end();
253
+ return;
254
+ }
255
+ res.writeHead(ctx.annotations ? 204 : 503).end();
256
+ return;
257
+ }
258
+ if (annotationRoute && req.method !== "POST") {
259
+ res.writeHead(405).end();
260
+ return;
261
+ }
224
262
  if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
225
263
  res.writeHead(415).end();
226
264
  return;
@@ -229,7 +267,9 @@ function handleHttp(req, res, ctx) {
229
267
  handleQuery(req, res, url, ctx);
230
268
  return;
231
269
  }
232
- if (p === `${ctx.wsPath}/annotation`) {
270
+ if (annotationRoute) {
271
+ if (fromConsole)
272
+ ctx.log("annotation from the console origin");
233
273
  handleAnnotationPost(req, res, ctx);
234
274
  return;
235
275
  }
@@ -239,6 +279,27 @@ function handleHttp(req, res, ctx) {
239
279
  }
240
280
  res.writeHead(404).end();
241
281
  }
282
+ /**
283
+ * The console's CORS preflight on the annotation route. Answered only for the
284
+ * exact console origin asking for POST with at most `content-type`; anything
285
+ * else is 403 with no CORS header at all. Never `*`, never credentials, never a
286
+ * private-network grant.
287
+ */
288
+ function handlePreflight(req, res, ctx, fromConsole) {
289
+ if (!fromConsole) {
290
+ ctx.log("refused a preflight from a foreign Origin");
291
+ res.writeHead(403).end();
292
+ return;
293
+ }
294
+ const ok = isConsolePreflight(req.headers["access-control-request-method"], req.headers["access-control-request-headers"]);
295
+ if (!ok) {
296
+ ctx.log("refused a console preflight for a method or header it does not allow");
297
+ res.writeHead(403).end();
298
+ return;
299
+ }
300
+ ctx.log("answered a console preflight");
301
+ res.writeHead(204, preflightHeaders(ctx.consoleOrigin)).end();
302
+ }
242
303
  // The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
243
304
  // session token from the 0600 relay marker.
244
305
  function handleQuery(req, res, url, ctx) {
@@ -279,6 +340,16 @@ function handleAnnotationPost(req, res, ctx) {
279
340
  res.writeHead(400).end("annotation requires message + screenshot");
280
341
  return;
281
342
  }
343
+ // Refused, never clamped: the agent acts on these values, and a stored
344
+ // coordinate must be exactly the one that was sent.
345
+ // What is stored is the spec REBUILT from the checked fields, never the
346
+ // body's own object: an unknown key would otherwise ride to the agent.
347
+ const bounded = boundedSpec(input);
348
+ if (!bounded.ok) {
349
+ ctx.log("refused marks out of bounds");
350
+ res.writeHead(400).end("annotation marks out of bounds");
351
+ return;
352
+ }
282
353
  // The annotation's session key is RESOLVED, never assigned — `resolve`,
283
354
  // not `attribute`. This route is unauthenticated and reachable by anyone
284
355
  // holding the capability URL, so a door that minted here would let 64 bare
@@ -288,7 +359,12 @@ function handleAnnotationPost(req, res, ctx) {
288
359
  // The key is still hostile input either way: the record reaches an agent's
289
360
  // context on the consumer's stdout, so the raw string must never survive
290
361
  // onto it.
291
- const annotation = await ctx.annotations.add({ ...input, sessionId: ctx.testers.resolve(input.sessionId) }, { now: Date.now(), id: ctx.makeId() });
362
+ const annotation = await ctx.annotations.add({
363
+ message: input.message,
364
+ screenshot: input.screenshot,
365
+ spec: bounded.spec,
366
+ sessionId: ctx.testers.resolve(input.sessionId),
367
+ }, { now: Date.now(), id: ctx.makeId() });
292
368
  // The live stream is told an annotation ARRIVED, with the resolved tester
293
369
  // and nothing else. It is a fact, not a directive: the consumer is the
294
370
  // long-poll, and a terminal line naming what to run would be the platform
@@ -299,7 +375,11 @@ function handleAnnotationPost(req, res, ctx) {
299
375
  .end(JSON.stringify({ id: annotation.id, seq: annotation.seq }));
300
376
  })
301
377
  .catch((err) => {
302
- ctx.log(`annotation error: ${err}`);
378
+ // A fixed line, never the error's text: a JSON.parse message quotes the
379
+ // body, which is hostile input, and this log reaches the terminal.
380
+ ctx.log(err instanceof SyntaxError
381
+ ? "annotation error: the body is not JSON"
382
+ : "annotation error: not stored (too large, or the write failed)");
303
383
  if (!res.headersSent)
304
384
  res.writeHead(400).end();
305
385
  });
@@ -397,9 +477,14 @@ async function readQuery(req, url) {
397
477
  q.presence = true;
398
478
  return normalizeTester(q);
399
479
  }
400
- function readBody(req, maxBytes = Infinity) {
480
+ /**
481
+ * The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
482
+ * its own turns a multi-byte character split across two chunks into U+FFFD.
483
+ * The size limit is in bytes.
484
+ */
485
+ export function readBody(req, maxBytes = Infinity) {
401
486
  return new Promise((resolve, reject) => {
402
- let data = "";
487
+ const chunks = [];
403
488
  let bytes = 0;
404
489
  req.on("data", (c) => {
405
490
  bytes += c.length;
@@ -408,9 +493,9 @@ function readBody(req, maxBytes = Infinity) {
408
493
  req.destroy();
409
494
  return;
410
495
  }
411
- data += c;
496
+ chunks.push(c);
412
497
  });
413
- req.on("end", () => resolve(data));
498
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
414
499
  req.on("error", reject);
415
500
  });
416
501
  }
@@ -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
+ }
@@ -50,8 +50,6 @@ export type RefusalArm = {
50
50
  stillServing?: number;
51
51
  } | {
52
52
  kind: "preview_start";
53
- devCommand: string;
54
- localUrl: string;
55
53
  };
56
54
  /** Flow L22: the version refusal on any verb, in the arm that knows where it landed. */
57
55
  export declare function upgradeRefusalLines(facts: UpgradeFacts, arm?: RefusalArm, own?: string): string[];
@@ -171,11 +171,10 @@ export function upgradeRefusalLines(facts, arm = { kind: "generic" }, own = clie
171
171
  "",
172
172
  remedy(variant),
173
173
  ];
174
+ // No local-loop paragraph on preview_start (B-F3-6 as amended): the remedy is
175
+ // the upgrade, and the screen ends on the command that leads back to a preview.
174
176
  if (variant !== "not_received")
175
177
  out.push(thenLine(arm));
176
- if (arm.kind === "preview_start") {
177
- out.push("", ` Your local dev loop does not need an upgrade. Run \`${arm.devCommand}\` and open`, ` ${arm.localUrl} on this machine. The camera works there.`);
178
- }
179
178
  out.push("");
180
179
  return out;
181
180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.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.1.0"
31
+ "@vincentt-xr/harness": "3.2.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/qrcode": "^1.5.5",