@vincentt-xr/harness 1.0.0 → 1.1.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.
@@ -12,7 +12,13 @@ export interface HarnessProviderProps {
12
12
  * app). Override for the production review-service sink.
13
13
  */
14
14
  relayUrl?: string;
15
- /** Session id grouping this preview run's events. Defaults to a per-load id. */
15
+ /**
16
+ * The key grouping this tab's events into one TESTER. Defaults to a per-tab
17
+ * CSPRNG id in `sessionStorage`, which is what you want; override only for the
18
+ * production review-service path. A value passed here is client-supplied input
19
+ * like any other — the relay validates it and never renders it, and passing
20
+ * another tab's value merges the two into one tester.
21
+ */
16
22
  sessionId?: string;
17
23
  captureConsole?: boolean;
18
24
  captureNetwork?: boolean;
@@ -23,4 +29,35 @@ export interface HarnessProviderProps {
23
29
  */
24
30
  feedback?: boolean;
25
31
  }
32
+ /**
33
+ * 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
34
+ *
35
+ * There is deliberately NO Math.random fallback. The previous generator's ~10^6
36
+ * space is exactly what this replaces, and a fallback reintroducing it would be
37
+ * the branch that actually runs on the oldest phone in the room. Both paths here
38
+ * are Web Crypto: randomUUID where it exists, getRandomValues otherwise — and
39
+ * getRandomValues has been in every browser that can run this app for a decade.
40
+ * If neither exists we throw rather than guess, because a weak id is a SILENT
41
+ * merge of two testers' streams, and no id at all is merely un-attributed.
42
+ */
43
+ export declare function generateClientSessionId(): string;
44
+ /** The slice of Storage this needs — so the rule below is testable without a DOM. */
45
+ export interface SessionIdStorage {
46
+ getItem(key: string): string | null;
47
+ setItem(key: string, value: string): void;
48
+ }
49
+ /**
50
+ * The key the relay groups this tab's events by — one TESTER.
51
+ *
52
+ * `sessionStorage`, not `localStorage`, and the choice is load-bearing on both
53
+ * ends: it is per TAB (so one QR scan is one tester even though the tab opens a
54
+ * second connection for assets) and it DIES WITH THE TAB (so the grouping is
55
+ * bounded by construction rather than by a rule someone has to remember, and
56
+ * cannot outlive the session's terminal window).
57
+ *
58
+ * A stored value that misses the current grammar is OVERWRITTEN, not migrated:
59
+ * it was minted at the entropy this replaces. The rewrite happens here at mount,
60
+ * before the first batch, so no id is ever renumbered mid-run.
61
+ */
62
+ export declare function resolveClientSessionId(storage: SessionIdStorage | undefined): string;
26
63
  export declare function HarnessProvider(props: HarnessProviderProps): ReactNode;
@@ -8,6 +8,7 @@
8
8
  // accident. The relay URL and session id default to what the preview loop sets,
9
9
  // but both are overridable for the production review-service path later.
10
10
  import { useEffect } from "react";
11
+ import { CLIENT_SESSION_ID_RE } from "../shared/events.js";
11
12
  function defaultRelayUrl() {
12
13
  if (typeof window === "undefined")
13
14
  return "ws://localhost:7331";
@@ -16,19 +17,56 @@ function defaultRelayUrl() {
16
17
  // so one cloudflared tunnel carries both. The relay CLI serves this path.
17
18
  return `${proto}://${window.location.host}/__harness`;
18
19
  }
20
+ const SESSION_STORAGE_KEY = "__vincentt_harness_session";
21
+ /**
22
+ * 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
23
+ *
24
+ * There is deliberately NO Math.random fallback. The previous generator's ~10^6
25
+ * space is exactly what this replaces, and a fallback reintroducing it would be
26
+ * the branch that actually runs on the oldest phone in the room. Both paths here
27
+ * are Web Crypto: randomUUID where it exists, getRandomValues otherwise — and
28
+ * getRandomValues has been in every browser that can run this app for a decade.
29
+ * If neither exists we throw rather than guess, because a weak id is a SILENT
30
+ * merge of two testers' streams, and no id at all is merely un-attributed.
31
+ */
32
+ export function generateClientSessionId() {
33
+ const c = globalThis.crypto;
34
+ if (c?.randomUUID)
35
+ return `s2_${c.randomUUID().replace(/-/g, "")}`;
36
+ if (c?.getRandomValues) {
37
+ const bytes = c.getRandomValues(new Uint8Array(16));
38
+ let hex = "";
39
+ for (const b of bytes)
40
+ hex += b.toString(16).padStart(2, "0");
41
+ return `s2_${hex}`;
42
+ }
43
+ throw new Error("harness: Web Crypto unavailable; cannot mint a session id");
44
+ }
45
+ /**
46
+ * The key the relay groups this tab's events by — one TESTER.
47
+ *
48
+ * `sessionStorage`, not `localStorage`, and the choice is load-bearing on both
49
+ * ends: it is per TAB (so one QR scan is one tester even though the tab opens a
50
+ * second connection for assets) and it DIES WITH THE TAB (so the grouping is
51
+ * bounded by construction rather than by a rule someone has to remember, and
52
+ * cannot outlive the session's terminal window).
53
+ *
54
+ * A stored value that misses the current grammar is OVERWRITTEN, not migrated:
55
+ * it was minted at the entropy this replaces. The rewrite happens here at mount,
56
+ * before the first batch, so no id is ever renumbered mid-run.
57
+ */
58
+ export function resolveClientSessionId(storage) {
59
+ const existing = storage?.getItem(SESSION_STORAGE_KEY);
60
+ if (existing && CLIENT_SESSION_ID_RE.test(existing))
61
+ return existing;
62
+ const id = generateClientSessionId();
63
+ storage?.setItem(SESSION_STORAGE_KEY, id);
64
+ return id;
65
+ }
19
66
  function defaultSessionId() {
20
67
  if (typeof window === "undefined")
21
68
  return "server";
22
- const key = "__vincentt_harness_session";
23
- const existing = window.sessionStorage?.getItem(key);
24
- if (existing)
25
- return existing;
26
- // Per-load id; not security-sensitive, just needs to be distinct enough that
27
- // two phones on one relay don't collide. No Math.random dependency in the
28
- // pure layer — this imperative shell may use it.
29
- const id = `s_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
30
- window.sessionStorage?.setItem(key, id);
31
- return id;
69
+ return resolveClientSessionId(window.sessionStorage);
32
70
  }
33
71
  const DEV = typeof import.meta !== "undefined" &&
34
72
  import.meta.env?.DEV;
@@ -59,7 +97,14 @@ export function HarnessProvider(props) {
59
97
  void import("./annotate.js").then(({ mountFeedbackButton }) => {
60
98
  if (cancelled)
61
99
  return;
62
- unmountFeedback = mountFeedbackButton({ sessionId: props.sessionId });
100
+ // The SAME resolved key the instrumentation sends, not the bare prop.
101
+ // The prop is undefined for every app that does not set it — i.e. the
102
+ // default — so passing it through left every real annotation
103
+ // un-attributed while the tests, which build the input by hand, never
104
+ // exercised this line.
105
+ unmountFeedback = mountFeedbackButton({
106
+ sessionId: props.sessionId ?? defaultSessionId(),
107
+ });
63
108
  });
64
109
  }
65
110
  return () => {
@@ -1,5 +1,30 @@
1
1
  /** Monotonic-ish wall-clock ms since epoch, stamped by the client at capture. */
2
2
  export type Timestamp = number;
3
+ /**
4
+ * The grammar of the client's session id — the key the relay groups a TESTER by.
5
+ *
6
+ * It lives here, in the file all three parts import, for the same reason
7
+ * VIEWER_LABEL_RE lives beside the codec that produces the label: the party that
8
+ * mints the value and the party that validates it must not be able to disagree
9
+ * about its shape. The client writes this shape; the relay accepts this shape and
10
+ * nothing else, with no sanitize-and-keep branch.
11
+ *
12
+ * `s2_` is generation two. Generation one was `s_<base36 time>_<base36 rand>` over
13
+ * ~10^6 of Math.random, which grouped a RUN. Grouping a TESTER makes a collision a
14
+ * silent merge of two people's streams, so the space is now 128 bits and the
15
+ * prefix is what lets a relay tell the two generations apart on sight.
16
+ */
17
+ export declare const CLIENT_SESSION_ID_RE: RegExp;
18
+ /**
19
+ * A relay-assigned tester id: `t1`, `t2`, … Minted on the CREATOR'S machine and
20
+ * never on the wire from the device — it is a local relabeling of a key the
21
+ * device supplied, which is what keeps the platform free of a correlator (B22).
22
+ *
23
+ * No leading zero. Ids are counted from 1 and never zero-padded, so `t01` is a
24
+ * string the registry cannot have produced; accepting it at the filter boundary
25
+ * would admit a second spelling for a value with one canonical form.
26
+ */
27
+ export declare const TESTER_ID_RE: RegExp;
3
28
  /** Kinds of thing the diagnostics limb observes. */
4
29
  export type DiagEventKind = "log" | "network" | "trace";
5
30
  interface DiagEventBase {
@@ -62,18 +87,69 @@ export interface ClientToRelay {
62
87
  }
63
88
  /** What the MCP server asks the relay for. `since` is an exclusive seq cursor. */
64
89
  export interface RelayQuery {
65
- sessionId?: string;
66
90
  kind?: DiagEventKind;
67
91
  since?: number;
68
92
  /** Cap the returned count (newest-biased); relay clamps to its buffer size. */
69
93
  limit?: number;
94
+ /**
95
+ * Filter to one TESTER — one browser tab, recognised across refreshes. This is
96
+ * the filter that answers "it breaks on their phone but not mine", because it
97
+ * survives the reload the tester does first.
98
+ *
99
+ * A malformed id filters to NOTHING rather than being ignored: silently
100
+ * widening a filter shows the creator several testers' events under a heading
101
+ * naming one.
102
+ */
103
+ tester?: string;
104
+ /**
105
+ * Filter to one viewer — one CONNECTION (2.8), not an identity (B22). The
106
+ * sub-coordinate below the tester: a single tab opens several of these under
107
+ * HTTP/1.1, so this narrows to one of them and is for connection-level
108
+ * debugging rather than for "whose phone".
109
+ */
110
+ viewer?: string;
70
111
  }
71
112
  export interface RelayResult {
72
113
  events: DiagEvent[];
114
+ /**
115
+ * `eventViewers[i]` is the label that produced `events[i]`, or undefined when
116
+ * the relay could not attribute it.
117
+ *
118
+ * It rides ALONGSIDE the events rather than on them because a viewer label is
119
+ * a fact about the CONNECTION that carried an observation, not a property of
120
+ * the observation — putting it on the event would make it look like something
121
+ * the device reported about itself, which is the identity promotion B22
122
+ * forbids.
123
+ */
124
+ eventViewers?: (string | undefined)[];
125
+ /**
126
+ * `eventTesters[i]` is the tester that produced `events[i]`, or undefined when
127
+ * the relay could not attribute it (the pre-mount window, a crashed app, a
128
+ * non-app request, or a session past the cap).
129
+ *
130
+ * Rides alongside for the same reason as `eventViewers`: which tester an
131
+ * observation came from is a fact about the CONNECTION that carried it, not a
132
+ * property of the observation.
133
+ */
134
+ eventTesters?: (string | undefined)[];
73
135
  /** Highest seq the relay currently holds, so the caller can advance `since`. */
74
136
  latestSeq: number;
75
- /** Sessions the relay has seen events for (so the agent can disambiguate). */
76
- sessions: string[];
137
+ /**
138
+ * Tester ids the relay has assigned, in arrival order. Every member matches
139
+ * TESTER_ID_RE.
140
+ *
141
+ * This REPLACED a `sessions` array that carried the raw client-supplied key to
142
+ * the terminal and to MCP tool output. That was a viewer-controlled string
143
+ * with no validation reaching the creator's coding agent — do not reintroduce
144
+ * it. The raw key is an opaque map key inside the relay and nothing else.
145
+ */
146
+ testers: string[];
147
+ /**
148
+ * Viewer labels the relay has seen. Labels churn — a reconnecting device gets
149
+ * a NEW label and the old one is never reused — so the caller needs the live
150
+ * set rather than remembering one.
151
+ */
152
+ viewers: string[];
77
153
  }
78
154
  /** A freehand stroke over the frame, in normalized [0,1] frame coordinates. */
79
155
  export interface AnnotationStroke {
@@ -105,7 +181,12 @@ export interface AnnotationInput {
105
181
  /** Annotated frame as a data URL (image/png). */
106
182
  screenshot: string;
107
183
  spec?: AnnotationSpec;
108
- /** Optional preview session id, to correlate with diag events. */
184
+ /**
185
+ * The client's session key, so the annotation can be correlated with the diag
186
+ * events from the same tab. Client-supplied and therefore hostile input: the
187
+ * relay REPLACES it with the tester id it resolves to, or drops it. It is
188
+ * never persisted or forwarded verbatim — this record reaches an agent.
189
+ */
109
190
  sessionId?: string;
110
191
  }
111
192
  /** A persisted annotation the agent consumes (via `vincentt feedback --wait`). */
@@ -123,6 +204,10 @@ export interface Annotation {
123
204
  /** Absolute path to the full-res annotated screenshot on disk. */
124
205
  screenshotPath: string;
125
206
  spec: AnnotationSpec;
207
+ /**
208
+ * The TESTER this annotation came from, or absent when the relay could not
209
+ * attribute it. Never the raw client key — see AnnotationInput.sessionId.
210
+ */
126
211
  sessionId?: string;
127
212
  }
128
213
  export {};
@@ -3,4 +3,28 @@
3
3
  // server (which serves them to the agent). All three import THIS file, so the
4
4
  // three can never drift on the shape of an event. Nothing here imports React,
5
5
  // node, or MCP — it is pure data.
6
- export {};
6
+ /**
7
+ * The grammar of the client's session id — the key the relay groups a TESTER by.
8
+ *
9
+ * It lives here, in the file all three parts import, for the same reason
10
+ * VIEWER_LABEL_RE lives beside the codec that produces the label: the party that
11
+ * mints the value and the party that validates it must not be able to disagree
12
+ * about its shape. The client writes this shape; the relay accepts this shape and
13
+ * nothing else, with no sanitize-and-keep branch.
14
+ *
15
+ * `s2_` is generation two. Generation one was `s_<base36 time>_<base36 rand>` over
16
+ * ~10^6 of Math.random, which grouped a RUN. Grouping a TESTER makes a collision a
17
+ * silent merge of two people's streams, so the space is now 128 bits and the
18
+ * prefix is what lets a relay tell the two generations apart on sight.
19
+ */
20
+ export const CLIENT_SESSION_ID_RE = /^s2_[0-9a-f]{32}$/;
21
+ /**
22
+ * A relay-assigned tester id: `t1`, `t2`, … Minted on the CREATOR'S machine and
23
+ * never on the wire from the device — it is a local relabeling of a key the
24
+ * device supplied, which is what keeps the platform free of a correlator (B22).
25
+ *
26
+ * No leading zero. Ids are counted from 1 and never zero-padded, so `t01` is a
27
+ * string the registry cannot have produced; accepting it at the filter boundary
28
+ * would admit a second spelling for a value with one canonical form.
29
+ */
30
+ export const TESTER_ID_RE = /^t[1-9][0-9]{0,2}$/;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The CBOR SUBSET, Node half. It mirrors `api/internal/edge/cbor.go` decision
3
+ * for decision, because two implementations of one wire format that were
4
+ * written independently are two implementations that drift.
5
+ *
6
+ * It decodes CONTROL payloads only — OPEN and HEAD. It NEVER touches a DATA
7
+ * payload.
8
+ *
9
+ * It is hand-written rather than a library for the same reason the Go one is: a
10
+ * general CBOR library's allocation behavior on hostile input would be someone
11
+ * else's decision, and the limits below are the whole point.
12
+ *
13
+ * Everything indefinite-length, every tag, every float, every byte string and
14
+ * every negative integer is REFUSED — not because they are dangerous in
15
+ * themselves, but because the encoder we own never emits them, so accepting
16
+ * them would only widen the input this parser has to be correct about.
17
+ */
18
+ /**
19
+ * VIEWER_LABEL_RE is the SAME regex the edge assigns against, and this client
20
+ * RE-VALIDATES rather than trusting the edge. That is closure (c) of the three
21
+ * that keep viewer-supplied bytes out of the creator's agent's context: the
22
+ * label flows viewerLabel -> the relay ring-buffer key -> CLI terminal output
23
+ * and MCP tool output, which is a terminal/log-injection path INTO the agent,
24
+ * and a Go-only check would pass on a client that trusted the edge.
25
+ */
26
+ export declare const VIEWER_LABEL_RE: RegExp;
27
+ export declare function parseViewerLabel(s: string): string | null;
28
+ /**
29
+ * UA_CLASSES is the CLOSED ENUM OF SIX, mirroring the Go edge. `unknown` is the
30
+ * TOTAL fallback. This client validates the class it receives against this set
31
+ * before stamping the relay handshake, so an unmatched or forged class becomes
32
+ * `unknown` rather than an echo.
33
+ */
34
+ export declare const UA_CLASSES: readonly ["ios_safari", "android_chrome", "desktop_chrome", "desktop_safari", "desktop_firefox", "unknown"];
35
+ export type UAClass = (typeof UA_CLASSES)[number];
36
+ export declare function parseUAClass(s: string): UAClass;
37
+ export interface OpenPayload {
38
+ viewerLabel: string;
39
+ method: string;
40
+ path: string;
41
+ headers: [string, string][];
42
+ upgrade?: string;
43
+ }
44
+ export interface HeadPayload {
45
+ status: number;
46
+ headers: [string, string][];
47
+ }
48
+ /**
49
+ * `viewer_gone` is the ONE non-terminal kind: the other three mean the session
50
+ * is ending, this one means a viewer's connection closed while the session
51
+ * continues. It is also the only kind carrying a second key.
52
+ */
53
+ export type ControlKind = "revoke" | "expiring" | "superseded" | "viewer_gone";
54
+ /** A decoded CONTROL payload. `viewerLabel` is present only on `viewer_gone`. */
55
+ export interface ControlPayload {
56
+ kind: ControlKind;
57
+ viewerLabel?: string;
58
+ }
59
+ export declare function decodeOpen(b: Uint8Array): OpenPayload;
60
+ export declare function decodeHead(b: Uint8Array): HeadPayload;
61
+ /**
62
+ * The map arity is asserted AGAINST THE KIND, so a payload claiming `revoke`
63
+ * with a trailing label is refused as malformed rather than quietly accepted
64
+ * with the extra key ignored.
65
+ */
66
+ export declare function decodeControl(b: Uint8Array): ControlPayload;
67
+ /** Canonical key order: viewerLabel, method, path, headers, [upgrade]. */
68
+ export declare function encodeOpen(p: OpenPayload): Uint8Array;
69
+ /** Canonical key order: status, headers. */
70
+ export declare function encodeHead(p: HeadPayload): Uint8Array;
71
+ export declare function encodeControl(kind: ControlKind, viewerLabel?: string): Uint8Array;