@vincentt-xr/cli 0.1.5 → 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.
- package/README.md +94 -0
- package/dist/cli/args.d.ts +13 -0
- package/dist/cli/args.js +41 -0
- package/dist/cli/index.js +362 -92
- package/dist/lib/backend.d.ts +45 -5
- package/dist/lib/backend.js +99 -18
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +73 -15
- package/dist/lib/lifecycle.js +111 -114
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +47 -20
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- package/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +2 -3
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- 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
|
+
}
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/relay/server.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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) => {
|
package/dist/relay/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
9
|
-
|
|
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
|
}
|