@vincentt-xr/cli 0.1.4 → 0.2.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.
Files changed (72) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +47 -20
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +3 -7
  61. package/dist/cli/deprecated/harness-mcp.d.ts +0 -2
  62. package/dist/cli/deprecated/harness-mcp.js +0 -7
  63. package/dist/cli/deprecated/harness-relay.d.ts +0 -2
  64. package/dist/cli/deprecated/harness-relay.js +0 -7
  65. package/dist/cli/deprecated/harness.d.ts +0 -2
  66. package/dist/cli/deprecated/harness.js +0 -7
  67. package/dist/login/login.d.ts +0 -34
  68. package/dist/login/login.js +0 -148
  69. package/dist/preview/cloudflared.d.ts +0 -13
  70. package/dist/preview/cloudflared.js +0 -46
  71. package/dist/preview/tunnel.d.ts +0 -14
  72. package/dist/preview/tunnel.js +0 -28
@@ -0,0 +1,113 @@
1
+ import type { UAClass } from "@vincentt-xr/harness/tunnel";
2
+ /** The roster block prints once, on the FOURTH TESTER's arrival, and never again. */
3
+ export declare const ROSTER_AT = 4;
4
+ /**
5
+ * The reconnect advisory's window (Flow L17). It prints only when a connection
6
+ * attaches within 120s of a disconnect WITH THE SAME UA CLASS. On a first
7
+ * connection, or at a demo where four fresh phones arrive, the creator never
8
+ * sees it.
9
+ */
10
+ export declare const RECONNECT_WINDOW_MS = 120000;
11
+ export interface Viewer {
12
+ readonly label: string;
13
+ readonly uaClass: UAClass;
14
+ readonly connectedAt: number;
15
+ /** The tester this connection resolved to, once the harness introduced itself. */
16
+ readonly tester?: string;
17
+ }
18
+ /** One printable line, or a block of them. The runner writes; this decides. */
19
+ export interface ViewerLines {
20
+ /** `tester-1 connected · iPhone · Safari` */
21
+ readonly connect?: string;
22
+ /**
23
+ * The `!` reconnect advisory, printed BELOW the connect line so the connect
24
+ * line stays scannable. Held rather than printed immediately — see
25
+ * `attribute`.
26
+ */
27
+ readonly advisory?: string[];
28
+ /** The one-time roster block, on the fourth tester. */
29
+ readonly roster?: string[];
30
+ }
31
+ export declare class ViewerRoster {
32
+ private readonly live;
33
+ /** Connections seen, ever. Printed once at the end; never a count of people. */
34
+ private total;
35
+ private rosterPrinted;
36
+ /** The most recent disconnect, for the L17 adjacency check. */
37
+ private lastDeparture;
38
+ /** Testers ever seen live, so a refresh is recognised as a return. */
39
+ private readonly testersSeen;
40
+ /**
41
+ * A held advisory per connection, keyed by label. The connection arrives before
42
+ * the harness mounts, so at connect time we do not yet know whether this is a
43
+ * known tester — and a hedge printed beside a definite `tester-1 refreshed`
44
+ * teaches the creator to distrust the definite line. So the advisory is
45
+ * DEFERRED here and dropped if attribution lands. A retracted line in a piped
46
+ * transcript is worse than a late one.
47
+ */
48
+ private readonly heldAdvisory;
49
+ /** Live CONNECTIONS. The console counts these; the CLI does not display them. */
50
+ get size(): number;
51
+ get totalConnections(): number;
52
+ /** Live TESTERS — distinct, attributed, currently connected. The display count. */
53
+ get testerCount(): number;
54
+ /** Live connections with no tester yet: the pre-mount window, or a crashed app. */
55
+ get unattributedCount(): number;
56
+ labels(): string[];
57
+ has(label: string): boolean;
58
+ get(label: string): Viewer | undefined;
59
+ /** Distinct live testers, in arrival order. */
60
+ liveTesters(): string[];
61
+ /**
62
+ * connect records a CONNECTION. It prints nothing about a tester yet, because
63
+ * the tester is not known until the app mounts — see `attribute`, which is
64
+ * where the line a creator reads comes from.
65
+ *
66
+ * Idempotent per label: the tunnel client fires onViewer on EVERY OPEN frame,
67
+ * not once per connection, so a viewer that requests forty assets would
68
+ * otherwise announce itself forty times.
69
+ */
70
+ connect(label: string, uaClass: UAClass, now: number): ViewerLines;
71
+ /**
72
+ * The harness introduced itself: this connection belongs to `tester`. This is
73
+ * where the creator's line comes from.
74
+ *
75
+ * Returns the connect line on a tester's FIRST connection, `refreshed` when a
76
+ * tester already seen comes back, and nothing for a second concurrent
77
+ * connection belonging to a tester already live — which is the ordinary case
78
+ * for one tab, and the reason one scan now reads as one thing.
79
+ */
80
+ attribute(label: string, tester: string): ViewerLines;
81
+ /**
82
+ * The advisory leads with the RULE and follows with the instance. Leading with
83
+ * a guess about this specific connection would teach the creator a hedge rather
84
+ * than a behavior, and one uncertain sentence on a screen where everything
85
+ * else is fact teaches them to distrust the labels generally — which is the
86
+ * opposite of what the labels are for.
87
+ *
88
+ * It names CONNECTIONS, not devices, and closes on the fact that testers
89
+ * survive a refresh — so the remedy for the uncertainty is on the same line as
90
+ * the uncertainty.
91
+ */
92
+ private reconnectAdvisory;
93
+ disconnect(label: string, now: number): ViewerLines;
94
+ /**
95
+ * The roster is a ONE-TIME SIGNPOST, not a live-updating footer. A redrawn
96
+ * status bar fights Ctrl-C, corrupts scrollback, breaks when the output is
97
+ * piped to a file — which is exactly what an agent does with it — and turns a
98
+ * plain stream into a TUI, which is a different product.
99
+ *
100
+ * It LEADS WITH A COUNT, and the count is exact: distinct attributed testers
101
+ * currently live. Never the number ever assigned, never a connection count.
102
+ * The old disclaimer ("this is not a device count") is gone — it existed only
103
+ * because the number above it was wrong.
104
+ */
105
+ rosterBlock(): string[];
106
+ /**
107
+ * The `--viewer` miss (Flow L16). An unknown label is a FACT, not an error,
108
+ * because labels churn — and the remedy is on the same line.
109
+ */
110
+ notFollowing(label: string): string;
111
+ /** The `--tester` miss. Same shape, different coordinate. */
112
+ notFollowingTester(tester: string): string;
113
+ }
@@ -0,0 +1,242 @@
1
+ // The roster: who is connected, what the connect/disconnect lines say, when the
2
+ // roster block prints, and when the reconnect advisory prints.
3
+ //
4
+ // TWO COORDINATES, and the difference is the whole point of this file:
5
+ //
6
+ // viewer-N one CONNECTION. Assigned by the edge at the first HTTP byte, so it
7
+ // exists immediately — and churns, because a browser opens several
8
+ // per tab under HTTP/1.1 and a reconnect never reuses a label.
9
+ // tester-N one BROWSER TAB, across its refreshes. Assigned by the relay when
10
+ // the app mounts and the harness introduces itself, so it arrives
11
+ // LATE — and is stable, which is what makes it the display unit.
12
+ //
13
+ // The creator reads testers. `--viewer` still filters connections for
14
+ // connection-level debugging. Neither is derived from the other.
15
+ //
16
+ // It is a pure state machine over (label, uaClass, tester, now), so every rule
17
+ // below is unit-testable without a terminal or a socket. The runner owns the
18
+ // printing.
19
+ import { uaLabel, uaShort } from "./stream.js";
20
+ /** The roster block prints once, on the FOURTH TESTER's arrival, and never again. */
21
+ export const ROSTER_AT = 4;
22
+ /**
23
+ * The reconnect advisory's window (Flow L17). It prints only when a connection
24
+ * attaches within 120s of a disconnect WITH THE SAME UA CLASS. On a first
25
+ * connection, or at a demo where four fresh phones arrive, the creator never
26
+ * sees it.
27
+ */
28
+ export const RECONNECT_WINDOW_MS = 120_000;
29
+ export class ViewerRoster {
30
+ live = new Map();
31
+ /** Connections seen, ever. Printed once at the end; never a count of people. */
32
+ total = 0;
33
+ rosterPrinted = false;
34
+ /** The most recent disconnect, for the L17 adjacency check. */
35
+ lastDeparture = null;
36
+ /** Testers ever seen live, so a refresh is recognised as a return. */
37
+ testersSeen = new Set();
38
+ /**
39
+ * A held advisory per connection, keyed by label. The connection arrives before
40
+ * the harness mounts, so at connect time we do not yet know whether this is a
41
+ * known tester — and a hedge printed beside a definite `tester-1 refreshed`
42
+ * teaches the creator to distrust the definite line. So the advisory is
43
+ * DEFERRED here and dropped if attribution lands. A retracted line in a piped
44
+ * transcript is worse than a late one.
45
+ */
46
+ heldAdvisory = new Map();
47
+ /** Live CONNECTIONS. The console counts these; the CLI does not display them. */
48
+ get size() {
49
+ return this.live.size;
50
+ }
51
+ get totalConnections() {
52
+ return this.total;
53
+ }
54
+ /** Live TESTERS — distinct, attributed, currently connected. The display count. */
55
+ get testerCount() {
56
+ return this.liveTesters().length;
57
+ }
58
+ /** Live connections with no tester yet: the pre-mount window, or a crashed app. */
59
+ get unattributedCount() {
60
+ let n = 0;
61
+ for (const v of this.live.values())
62
+ if (v.tester === undefined)
63
+ n++;
64
+ return n;
65
+ }
66
+ labels() {
67
+ return [...this.live.keys()];
68
+ }
69
+ has(label) {
70
+ return this.live.has(label);
71
+ }
72
+ get(label) {
73
+ return this.live.get(label);
74
+ }
75
+ /** Distinct live testers, in arrival order. */
76
+ liveTesters() {
77
+ const out = [];
78
+ for (const v of this.live.values()) {
79
+ if (v.tester !== undefined && !out.includes(v.tester))
80
+ out.push(v.tester);
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * connect records a CONNECTION. It prints nothing about a tester yet, because
86
+ * the tester is not known until the app mounts — see `attribute`, which is
87
+ * where the line a creator reads comes from.
88
+ *
89
+ * Idempotent per label: the tunnel client fires onViewer on EVERY OPEN frame,
90
+ * not once per connection, so a viewer that requests forty assets would
91
+ * otherwise announce itself forty times.
92
+ */
93
+ connect(label, uaClass, now) {
94
+ if (this.live.has(label))
95
+ return {};
96
+ this.live.set(label, { label, uaClass, connectedAt: now });
97
+ this.total++;
98
+ const advisory = this.reconnectAdvisory(label, uaClass, now);
99
+ if (advisory)
100
+ this.heldAdvisory.set(label, advisory);
101
+ // NOTHING PRINTS HERE. One tab opens two connections under HTTP/1.1, so a
102
+ // line per connection is what made one QR scan read as two records. The
103
+ // creator learns about this connection when it resolves to a tester, or —
104
+ // if it never does — through the un-attributed line below.
105
+ return {};
106
+ }
107
+ /**
108
+ * The harness introduced itself: this connection belongs to `tester`. This is
109
+ * where the creator's line comes from.
110
+ *
111
+ * Returns the connect line on a tester's FIRST connection, `refreshed` when a
112
+ * tester already seen comes back, and nothing for a second concurrent
113
+ * connection belonging to a tester already live — which is the ordinary case
114
+ * for one tab, and the reason one scan now reads as one thing.
115
+ */
116
+ attribute(label, tester) {
117
+ const v = this.live.get(label);
118
+ if (v === undefined || v.tester !== undefined)
119
+ return {};
120
+ // A tester already live on ANOTHER connection: this is the tab's second
121
+ // socket, not an arrival.
122
+ const alreadyLive = this.liveTesters().includes(tester);
123
+ this.live.set(label, { ...v, tester });
124
+ // The advisory is dropped: we have a definite answer now.
125
+ const held = this.heldAdvisory.get(label);
126
+ this.heldAdvisory.delete(label);
127
+ if (alreadyLive)
128
+ return {};
129
+ const returning = this.testersSeen.has(tester);
130
+ this.testersSeen.add(tester);
131
+ if (returning) {
132
+ // A refresh, or a socket that dropped and came back. Same tester, same
133
+ // line, whichever it was — the creator's question is "is this the same
134
+ // person", and the answer is yes in both cases.
135
+ return { connect: `${tester} refreshed` };
136
+ }
137
+ const descriptor = uaLabel(v.uaClass);
138
+ const out = {
139
+ connect: descriptor ? `${tester} connected · ${descriptor}` : `${tester} connected`,
140
+ };
141
+ // Only an un-attributed neighbour can still justify the hedge; a known
142
+ // tester's arrival is certain, so the held advisory dies with it.
143
+ if (held && this.unattributedCount > 0)
144
+ out.advisory = held;
145
+ if (!this.rosterPrinted && this.testerCount >= ROSTER_AT) {
146
+ this.rosterPrinted = true;
147
+ out.roster = this.rosterBlock();
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * The advisory leads with the RULE and follows with the instance. Leading with
153
+ * a guess about this specific connection would teach the creator a hedge rather
154
+ * than a behavior, and one uncertain sentence on a screen where everything
155
+ * else is fact teaches them to distrust the labels generally — which is the
156
+ * opposite of what the labels are for.
157
+ *
158
+ * It names CONNECTIONS, not devices, and closes on the fact that testers
159
+ * survive a refresh — so the remedy for the uncertainty is on the same line as
160
+ * the uncertainty.
161
+ */
162
+ reconnectAdvisory(label, uaClass, now) {
163
+ const gone = this.lastDeparture;
164
+ if (!gone)
165
+ return undefined;
166
+ if (gone.label === label)
167
+ return undefined;
168
+ if (gone.uaClass !== uaClass)
169
+ return undefined;
170
+ if (now - gone.at > RECONNECT_WINDOW_MS)
171
+ return undefined;
172
+ return [
173
+ `! A connection that reconnects gets a new label. If ${gone.label} came back,`,
174
+ ` it is ${label} now — labels last one connection. Testers survive a refresh.`,
175
+ ];
176
+ }
177
+ disconnect(label, now) {
178
+ const v = this.live.get(label);
179
+ if (!v)
180
+ return {};
181
+ this.live.delete(label);
182
+ this.heldAdvisory.delete(label);
183
+ this.lastDeparture = { uaClass: v.uaClass, label, at: now };
184
+ // A tester leaves when its LAST connection does. A tab's two sockets close
185
+ // moments apart, and announcing a departure on the first would print
186
+ // `disconnected` for someone still looking at the app.
187
+ if (v.tester === undefined)
188
+ return {};
189
+ if (this.liveTesters().includes(v.tester))
190
+ return {};
191
+ return { connect: `${v.tester} disconnected` };
192
+ }
193
+ /**
194
+ * The roster is a ONE-TIME SIGNPOST, not a live-updating footer. A redrawn
195
+ * status bar fights Ctrl-C, corrupts scrollback, breaks when the output is
196
+ * piped to a file — which is exactly what an agent does with it — and turns a
197
+ * plain stream into a TUI, which is a different product.
198
+ *
199
+ * It LEADS WITH A COUNT, and the count is exact: distinct attributed testers
200
+ * currently live. Never the number ever assigned, never a connection count.
201
+ * The old disclaimer ("this is not a device count") is gone — it existed only
202
+ * because the number above it was wrong.
203
+ */
204
+ rosterBlock() {
205
+ const rule = "─".repeat(65);
206
+ const byTester = new Map();
207
+ for (const v of this.live.values()) {
208
+ if (v.tester !== undefined && !byTester.has(v.tester)) {
209
+ byTester.set(v.tester, uaShort(v.uaClass));
210
+ }
211
+ }
212
+ const parts = [...byTester].map(([t, ua]) => `${t} ${ua}`.trim());
213
+ const n = byTester.size;
214
+ const lines = [rule, `${n} ${n === 1 ? "tester" : "testers"} · ${parts.join(" · ")}`];
215
+ // Without this, `4 testers` printed while five connections are live is the
216
+ // original defect in a new place — and the pre-mount gap makes that state
217
+ // routine, not rare.
218
+ const pending = this.unattributedCount;
219
+ if (pending > 0) {
220
+ lines.push(`+ ${pending} ${pending === 1 ? "connection" : "connections"} not yet attributed`);
221
+ }
222
+ lines.push(rule);
223
+ return lines;
224
+ }
225
+ /**
226
+ * The `--viewer` miss (Flow L16). An unknown label is a FACT, not an error,
227
+ * because labels churn — and the remedy is on the same line.
228
+ */
229
+ notFollowing(label) {
230
+ const connected = this.labels();
231
+ return connected.length === 0
232
+ ? `! No ${label} on this preview right now. Nothing is connected.`
233
+ : `! No ${label} on this preview right now. Connected: ${connected.join(", ")}.`;
234
+ }
235
+ /** The `--tester` miss. Same shape, different coordinate. */
236
+ notFollowingTester(tester) {
237
+ const live = this.liveTesters();
238
+ return live.length === 0
239
+ ? `! No ${tester} on this preview right now. No testers are connected.`
240
+ : `! No ${tester} on this preview right now. Connected: ${live.join(", ")}.`;
241
+ }
242
+ }
@@ -1,6 +1,8 @@
1
1
  import { EventStore } from "./store.js";
2
+ import { TesterRegistry } from "./testers.js";
2
3
  import { AnnotationStore } from "./annotations.js";
3
4
  import { type Dispatcher } from "./injectors.js";
5
+ import type { DiagEvent } from "@vincentt-xr/harness/events";
4
6
  export interface RelayOptions {
5
7
  port?: number;
6
8
  /** WS path the client connects to (default matches HarnessProvider). */
@@ -15,10 +17,40 @@ export interface RelayOptions {
15
17
  dispatcher?: Dispatcher;
16
18
  /** Injected for tests. */
17
19
  makeId?: () => string;
20
+ /**
21
+ * Live tap for the CLI's terminal stream. It fires per batch as events land,
22
+ * so the creator sees a line the moment the phone produces it rather than on
23
+ * a poll. The relay still buffers everything — this is an addition, not a
24
+ * replacement, and a missing handler changes nothing.
25
+ */
26
+ onEvents?: (events: DiagEvent[], tags: EventTags) => void;
27
+ /**
28
+ * Fires ONCE per refused session when the tester cap is reached, so the CLI
29
+ * can print the one warning line. Once per session rather than once per batch:
30
+ * the registry holds a tombstone precisely so a chatty refused client cannot
31
+ * repaint the warning on every flush.
32
+ */
33
+ onCapReached?: () => void;
34
+ /** Injected for tests, so cap behavior is drivable without 64 real clients. */
35
+ testerRegistry?: TesterRegistry;
36
+ }
37
+ /** Who produced a batch: the connection's viewer label and its resolved tester. */
38
+ export interface EventTags {
39
+ viewer?: string;
40
+ tester?: string;
18
41
  }
19
42
  export interface RelayHandle {
20
43
  store: EventStore;
21
44
  annotations?: AnnotationStore;
45
+ /** The run's tester registry — read-only to callers; the relay owns writes. */
46
+ testers: TesterRegistry;
22
47
  close: () => Promise<void>;
23
48
  }
24
49
  export declare function startRelay(opts?: RelayOptions): RelayHandle;
50
+ /**
51
+ * readViewerLabel accepts ONLY `^viewer-[0-9]{1,4}$` and returns undefined for
52
+ * everything else — an array-valued header (a duplicate), a forged class, an
53
+ * ANSI escape, a newline. There is no "sanitize and keep" branch: a label that
54
+ * does not match is not a label, and the events simply carry no viewer.
55
+ */
56
+ export declare function readViewerLabel(raw: string | string[] | undefined): string | undefined;
@@ -8,8 +8,10 @@ import { randomBytes } from "node:crypto";
8
8
  import path from "node:path";
9
9
  import { WebSocketServer } from "ws";
10
10
  import { EventStore } from "./store.js";
11
+ import { TesterRegistry, isTesterId } from "./testers.js";
11
12
  import { AnnotationStore, annotationsDir } from "./annotations.js";
12
13
  import { claudeCodeInjector, createDispatcher, pasteInjector, } from "./injectors.js";
14
+ import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
13
15
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
14
16
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
15
17
  /** Clamp the long-poll hold so a request can't park forever. */
@@ -18,6 +20,7 @@ export function startRelay(opts = {}) {
18
20
  const port = opts.port ?? 7331;
19
21
  const wsPath = opts.path ?? "/__harness";
20
22
  const store = new EventStore(opts.capacity);
23
+ const testers = opts.testerRegistry ?? new TesterRegistry();
21
24
  const log = opts.onLog ?? (() => undefined);
22
25
  const makeId = opts.makeId ?? (() => randomBytes(3).toString("hex"));
23
26
  // Reverse channel is enabled when we know which project to bind annotations to.
@@ -34,15 +37,44 @@ export function startRelay(opts = {}) {
34
37
  pasteInjector((p) => log(`annotation ready to paste:\n${p}`)),
35
38
  ], log);
36
39
  }
37
- const http = createServer((req, res) => handleHttp(req, res, { store, annotations, dispatcher, wsPath, makeId, log }));
40
+ const http = createServer((req, res) => handleHttp(req, res, {
41
+ store,
42
+ annotations,
43
+ dispatcher,
44
+ testers,
45
+ wsPath,
46
+ makeId,
47
+ log,
48
+ onCapReached: opts.onCapReached,
49
+ }));
38
50
  const wss = new WebSocketServer({ server: http, path: wsPath });
39
- wss.on("connection", (ws) => {
40
- log("client connected");
51
+ wss.on("connection", (ws, req) => {
52
+ // The label comes from the CONNECTION, stamped by the tunnel client on this
53
+ // machine after it re-validated the edge's value. It is re-validated ONCE
54
+ // MORE here — this is the last hop before a string reaches the terminal and
55
+ // MCP tool output, which for this product is adjacent to prompt injection,
56
+ // and a check that runs only upstream is a check a direct localhost client
57
+ // skips.
58
+ const viewer = readViewerLabel(req.headers[VIEWER_STAMP_HEADER]);
59
+ log(viewer ? `client connected · ${viewer}` : "client connected");
60
+ // The tester is resolved ONCE per connection, from the first batch's key.
61
+ // Re-resolving per batch would let one socket's events land under two ids if
62
+ // a client changed its key mid-stream.
63
+ let tester;
64
+ let resolved = false;
41
65
  ws.on("message", (data) => {
42
66
  try {
43
67
  const msg = JSON.parse(String(data));
44
68
  if (msg.type === "events" && Array.isArray(msg.events)) {
45
- store.ingest(msg.sessionId, msg.events);
69
+ if (!resolved) {
70
+ resolved = true;
71
+ const attribution = testers.attribute(msg.sessionId);
72
+ tester = attribution.tester;
73
+ if (attribution.warnCapReached)
74
+ opts.onCapReached?.();
75
+ }
76
+ store.ingest(msg.events, { viewer, tester });
77
+ opts.onEvents?.(msg.events, { viewer, tester });
46
78
  }
47
79
  }
48
80
  catch {
@@ -55,6 +87,7 @@ export function startRelay(opts = {}) {
55
87
  return {
56
88
  store,
57
89
  annotations,
90
+ testers,
58
91
  close: () => new Promise((resolve) => {
59
92
  annotations?.close();
60
93
  wss.close();
@@ -62,6 +95,17 @@ export function startRelay(opts = {}) {
62
95
  }),
63
96
  };
64
97
  }
98
+ /**
99
+ * readViewerLabel accepts ONLY `^viewer-[0-9]{1,4}$` and returns undefined for
100
+ * everything else — an array-valued header (a duplicate), a forged class, an
101
+ * ANSI escape, a newline. There is no "sanitize and keep" branch: a label that
102
+ * does not match is not a label, and the events simply carry no viewer.
103
+ */
104
+ export function readViewerLabel(raw) {
105
+ if (typeof raw !== "string")
106
+ return undefined;
107
+ return VIEWER_LABEL_RE.test(raw) ? raw : undefined;
108
+ }
65
109
  function handleHttp(req, res, ctx) {
66
110
  const url = new URL(req.url ?? "/", "http://localhost");
67
111
  const p = url.pathname;
@@ -105,10 +149,16 @@ function handleAnnotationPost(req, res, ctx) {
105
149
  res.writeHead(400).end("annotation requires message + screenshot");
106
150
  return;
107
151
  }
108
- const annotation = await ctx.annotations.add(input, {
109
- now: Date.now(),
110
- id: ctx.makeId(),
111
- });
152
+ // The annotation's session key is RESOLVED, never assigned — `resolve`,
153
+ // not `attribute`. This route is unauthenticated and reachable by anyone
154
+ // holding the capability URL, so a door that minted here would let 64 bare
155
+ // POSTs exhaust the registry before the creator's phone connected, and the
156
+ // terminal only prints attributed events.
157
+ //
158
+ // The key is still hostile input either way: the annotation is appended to
159
+ // a queue a running Claude Code /loop drains, so the raw string must never
160
+ // survive onto the record.
161
+ const annotation = await ctx.annotations.add({ ...input, sessionId: ctx.testers.resolve(input.sessionId) }, { now: Date.now(), id: ctx.makeId() });
112
162
  // Fire the injector chain best-effort — a failed dispatch must not fail the
113
163
  // POST; the long-poll consumer still sees the persisted annotation.
114
164
  if (ctx.dispatcher) {
@@ -150,22 +200,40 @@ function handleFeedbackNext(res, url, ctx) {
150
200
  res.writeHead(400).end();
151
201
  });
152
202
  }
203
+ /**
204
+ * A `tester` filter that is not a well-formed id becomes a sentinel that matches
205
+ * no stored tag, so it filters to an EMPTY list rather than erroring or being
206
+ * dropped (which would silently widen the query to everyone). The sentinel
207
+ * cannot collide with a real id: the registry only ever mints `t<n>`.
208
+ */
209
+ const NO_SUCH_TESTER = "none";
210
+ function normalizeTester(q) {
211
+ if (q.tester === undefined)
212
+ return q;
213
+ return isTesterId(q.tester) ? q : { ...q, tester: NO_SUCH_TESTER };
214
+ }
153
215
  async function readQuery(req, url) {
154
216
  if (req.method === "POST") {
155
217
  const body = await readBody(req);
156
- return body ? JSON.parse(body) : {};
218
+ return normalizeTester(body ? JSON.parse(body) : {});
157
219
  }
158
220
  const q = {};
159
221
  const p = url.searchParams;
160
- if (p.get("sessionId"))
161
- q.sessionId = p.get("sessionId");
162
222
  if (p.get("kind"))
163
223
  q.kind = p.get("kind");
164
224
  if (p.get("since"))
165
225
  q.since = Number(p.get("since"));
166
226
  if (p.get("limit"))
167
227
  q.limit = Number(p.get("limit"));
168
- return q;
228
+ // `tester` and `viewer` both filter to NOTHING when malformed rather than
229
+ // being ignored: silently widening a filter would show the creator several
230
+ // testers' events under a heading naming one. There is no `sessionId` param —
231
+ // it was the only surface that would teach a creator the raw key exists.
232
+ if (p.get("tester"))
233
+ q.tester = p.get("tester");
234
+ if (p.get("viewer"))
235
+ q.viewer = p.get("viewer");
236
+ return normalizeTester(q);
169
237
  }
170
238
  function readBody(req, maxBytes = Infinity) {
171
239
  return new Promise((resolve, reject) => {
@@ -2,12 +2,41 @@ import type { DiagEvent, RelayQuery, RelayResult } from "@vincentt-xr/harness/ev
2
2
  export declare class EventStore {
3
3
  private readonly capacity;
4
4
  private readonly buf;
5
- private readonly sessionsSeen;
5
+ /**
6
+ * Insertion-ordered, so the roster reads in arrival order rather than in
7
+ * whatever order a hash lands. Labels are never removed: a viewer that
8
+ * disconnected 20 seconds ago still has events in this buffer, and dropping
9
+ * its label would make them unfilterable.
10
+ */
11
+ private readonly viewersSeen;
12
+ /**
13
+ * Same rule, same reason, for testers — and it matters MORE here, because
14
+ * `--tester t1` is what a creator runs at the moment the tester who hit the
15
+ * bug has already closed their tab.
16
+ *
17
+ * Note what is NOT here: a set of raw client session keys. The store used to
18
+ * hold one and return it in `RelayResult.sessions`, which put a
19
+ * viewer-controlled string on the terminal and in MCP tool output. The raw key
20
+ * never enters this file; the relay maps it to a tester id first.
21
+ */
22
+ private readonly testersSeen;
6
23
  private latest;
7
24
  constructor(capacity?: number);
8
- /** Ingest a batch from a client. Tracks the session and advances latestSeq. */
9
- ingest(sessionId: string, events: DiagEvent[]): void;
25
+ /**
26
+ * Ingest a batch from a client. Both the VIEWER and the TESTER come from the
27
+ * connection's own resolution, never from the payload — the device never
28
+ * proposes its own label (B22), and an identifier taken from the body would be
29
+ * a viewer-supplied string flowing to the terminal and to MCP tool output.
30
+ */
31
+ ingest(events: DiagEvent[], tags?: {
32
+ viewer?: string;
33
+ tester?: string;
34
+ }): void;
10
35
  /** Answer an MCP query against the buffer. Newest-biased when limited. */
11
36
  query(q?: RelayQuery): RelayResult;
37
+ /** The label an event carries, for the live terminal stream. */
38
+ viewerFor(e: DiagEvent): string | undefined;
39
+ /** The tester an event carries, for the live terminal stream. */
40
+ testerFor(e: DiagEvent): string | undefined;
12
41
  size(): number;
13
42
  }