@xingular/harness 1.3.1-unstable.3 → 1.3.1-unstable.4

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 (77) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +2513 -1296
  3. package/dist/sdk/cli.js +2 -0
  4. package/dist/sdk/commands/registry.d.ts +15 -0
  5. package/dist/sdk/commands/registry.js +2 -0
  6. package/dist/sdk/remote/addresses.d.ts +19 -0
  7. package/dist/sdk/remote/addresses.js +52 -0
  8. package/dist/sdk/remote/pairing.d.ts +59 -0
  9. package/dist/sdk/remote/pairing.js +160 -0
  10. package/dist/sdk/remote/seal.d.ts +28 -0
  11. package/dist/sdk/remote/seal.js +84 -0
  12. package/dist/sdk/remote/server.d.ts +28 -0
  13. package/dist/sdk/remote/server.js +158 -0
  14. package/dist/sdk/remote/service.d.ts +125 -0
  15. package/dist/sdk/remote/service.js +330 -0
  16. package/dist/sdk/remote/webClient.d.ts +4 -0
  17. package/dist/sdk/remote/webClient.js +444 -0
  18. package/dist/sdk/sessions/planMode.d.ts +26 -1
  19. package/dist/sdk/sessions/planMode.js +48 -1
  20. package/dist/sdk/sessions/runner.d.ts +2 -0
  21. package/dist/sdk/sessions/runner.js +17 -2
  22. package/dist/sdk/sessions/sessions.js +1 -0
  23. package/dist/sdk/sessions/workSlice.d.ts +5 -9
  24. package/dist/sdk/sessions/workSlice.js +8 -149
  25. package/dist/sdk/terminal/client.d.ts +2 -0
  26. package/dist/sdk/terminal/client.js +2 -0
  27. package/dist/sdk/terminal/controller.d.ts +13 -0
  28. package/dist/sdk/terminal/controller.js +56 -2
  29. package/dist/sdk/terminal/piExtension.js +71 -3
  30. package/dist/sdk/terminal/stallWatch.d.ts +13 -0
  31. package/dist/sdk/terminal/stallWatch.js +16 -0
  32. package/dist/sdk/terminal/standalone.d.ts +2 -0
  33. package/dist/sdk/terminal/standalone.js +9 -2
  34. package/dist/sdk/terminal/terminals.d.ts +12 -0
  35. package/dist/sdk/terminal/terminals.js +87 -2
  36. package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
  37. package/dist/sdk/terminal/tui/commandPages.js +12 -0
  38. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  39. package/dist/sdk/terminal/tui/commands.js +18 -1
  40. package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
  41. package/dist/sdk/terminal/tui/markdown.js +55 -0
  42. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +8 -0
  43. package/dist/sdk/terminal/tui/pages/contextPage.js +130 -39
  44. package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
  45. package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
  46. package/dist/sdk/terminal/tui/qr.d.ts +4 -0
  47. package/dist/sdk/terminal/tui/qr.js +35 -0
  48. package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
  49. package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
  50. package/dist/sdk/terminal/tui/status.d.ts +6 -0
  51. package/dist/sdk/terminal/tui/status.js +15 -1
  52. package/dist/sdk/terminal/tui/theme.d.ts +9 -0
  53. package/dist/sdk/terminal/tui/theme.js +9 -0
  54. package/dist/terminal-opentui.js +959 -174
  55. package/dist/terminal-pi.js +114 -116
  56. package/docs/architecture/02-sessions-and-runs.md +46 -22
  57. package/docs/architecture/04-prompt.md +10 -0
  58. package/docs/architecture/07-tools-and-gate.md +3 -1
  59. package/docs/architecture/10-server-protocol.md +24 -0
  60. package/docs/architecture/11-terminal.md +40 -8
  61. package/docs/architecture/12-storage.md +5 -2
  62. package/docs/architecture/14-surfaces-and-parity.md +4 -0
  63. package/docs/architecture/15-remote-control.md +118 -0
  64. package/docs/architecture/README.md +2 -0
  65. package/docs/architecture/proposals/PROP-0049-context-attribution.md +28 -1
  66. package/docs/architecture/proposals/README.md +2 -2
  67. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
  68. package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
  69. package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
  70. package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
  71. package/docs/decisions/ADR-0056-remote-control.md +53 -0
  72. package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
  73. package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
  74. package/docs/parity.md +8 -4
  75. package/docs/releases/1.3.1-unstable.4.md +56 -0
  76. package/docs/terminal-tui.md +15 -3
  77. package/package.json +3 -1
package/dist/sdk/cli.js CHANGED
@@ -52,6 +52,8 @@ Standalone terminal options:
52
52
  Start in a new managed worktree of this repository, or in
53
53
  the named one
54
54
  --variant <scope> Run on this variant scope's head (default: interactive profile scope)
55
+ --remote-control, --rc
56
+ Turn on /remote-control at start, to continue from a phone or iPad
55
57
  `;
56
58
  function flags(argv) {
57
59
  const values = {};
@@ -69,6 +69,14 @@ export declare const CORE_COMMANDS: readonly [{
69
69
  readonly group: "Session";
70
70
  readonly kind: "session";
71
71
  readonly methods: readonly ["sessions.tree", "sessions.fork"];
72
+ }, {
73
+ readonly name: "remote-control";
74
+ readonly aliases: readonly ["rc"];
75
+ readonly args: "[off|tailscale|lan]";
76
+ readonly description: "Continue this session from your phone or iPad";
77
+ readonly group: "Session";
78
+ readonly kind: "page";
79
+ readonly methods: readonly ["terminal.remoteControl.start", "terminal.remoteControl.stop", "terminal.remoteControl.status"];
72
80
  }, {
73
81
  readonly name: "clone";
74
82
  readonly description: "Clone this branch into a new session";
@@ -228,6 +236,13 @@ export declare const CORE_COMMANDS: readonly [{
228
236
  readonly description: "Harness diagnostics and this conversation's run events";
229
237
  readonly group: "Inspect";
230
238
  readonly kind: "page";
239
+ }, {
240
+ readonly name: "context";
241
+ readonly args: "[growth]";
242
+ readonly description: "What the latest request was made of, and where the rest of the window went";
243
+ readonly group: "Inspect";
244
+ readonly kind: "page";
245
+ readonly methods: readonly ["context.attribution"];
231
246
  }, {
232
247
  readonly name: "hotkeys";
233
248
  readonly description: "Keyboard controls";
@@ -10,6 +10,7 @@ export const CORE_COMMANDS = [
10
10
  { name: 'session', aliases: ['resume'], args: '[path]', description: 'Choose a saved session in this account and folder', group: 'Session', kind: 'session' },
11
11
  { name: 'history', aliases: ['tree'], description: 'Navigate branches in the current session', group: 'Session', kind: 'session', methods: ['sessions.tree', 'sessions.navigate'] },
12
12
  { name: 'fork', description: 'Fork from an earlier message', group: 'Session', kind: 'session', methods: ['sessions.tree', 'sessions.fork'] },
13
+ { name: 'remote-control', aliases: ['rc'], args: '[off|tailscale|lan]', description: 'Continue this session from your phone or iPad', group: 'Session', kind: 'page', methods: ['terminal.remoteControl.start', 'terminal.remoteControl.stop', 'terminal.remoteControl.status'] },
13
14
  { name: 'clone', description: 'Clone this branch into a new session', group: 'Session', kind: 'session', methods: ['sessions.clone'] },
14
15
  { name: 'model', args: '[search]', description: 'Choose the model', group: 'Model', kind: 'session', methods: ['accounts.models'] },
15
16
  { name: 'effort', args: '[level]', description: 'Choose the thinking effort', group: 'Model', kind: 'session' },
@@ -37,6 +38,7 @@ export const CORE_COMMANDS = [
37
38
  { name: 'subagents', description: 'Watch current subagent tasks and activity', group: 'Harness', kind: 'page' },
38
39
  { name: 'agents', description: 'Browse and edit agent profiles', group: 'Harness', kind: 'page', methods: ['agents.list'] },
39
40
  { name: 'log', description: "Harness diagnostics and this conversation's run events", group: 'Inspect', kind: 'page' },
41
+ { name: 'context', args: '[growth]', description: 'What the latest request was made of, and where the rest of the window went', group: 'Inspect', kind: 'page', methods: ['context.attribution'] },
40
42
  { name: 'hotkeys', description: 'Keyboard controls', group: 'Core', kind: 'local' },
41
43
  { name: 'quit', description: 'Restore the terminal and exit', group: 'Core', kind: 'local' }
42
44
  ];
@@ -0,0 +1,19 @@
1
+ import { type NetworkInterfaceInfo } from 'node:os';
2
+ export type RemoteAddressKind = 'tailscale' | 'lan';
3
+ export type RemoteInterfaceChoice = 'auto' | RemoteAddressKind;
4
+ export interface RemoteAddress {
5
+ kind: RemoteAddressKind;
6
+ address: string;
7
+ family: 'IPv4' | 'IPv6';
8
+ interface: string;
9
+ }
10
+ /** Tailscale hands out 100.64.0.0/10 and fd7a:115c:a1e0::/48. */
11
+ export declare function isTailscale(address: string): boolean;
12
+ /** RFC 1918 private IPv4 ranges. */
13
+ export declare function isPrivateLan(address: string): boolean;
14
+ /** Every address remote control may bind, Tailscale first. */
15
+ export declare function remoteAddresses(interfaces?: NodeJS.Dict<NetworkInterfaceInfo[]>): RemoteAddress[];
16
+ /** The one address to listen on for a choice, or a clear error when there is none. */
17
+ export declare function pickRemoteAddress(choice: RemoteInterfaceChoice, candidates: readonly RemoteAddress[]): RemoteAddress;
18
+ /** How a URL names the address. */
19
+ export declare function hostPart(address: RemoteAddress): string;
@@ -0,0 +1,52 @@
1
+ // Where remote control may listen: a Tailscale address or a private LAN address, never a public
2
+ // one, never every interface (INV-RC-1).
3
+ import { networkInterfaces } from 'node:os';
4
+ import { HarnessError } from "../core/errors.js";
5
+ const octets = (address) => address.split('.').map(Number);
6
+ /** Tailscale hands out 100.64.0.0/10 and fd7a:115c:a1e0::/48. */
7
+ export function isTailscale(address) {
8
+ if (address.includes(':'))
9
+ return address.toLowerCase().startsWith('fd7a:115c:a1e0:');
10
+ const [a, b] = octets(address);
11
+ return a === 100 && b >= 64 && b <= 127;
12
+ }
13
+ /** RFC 1918 private IPv4 ranges. */
14
+ export function isPrivateLan(address) {
15
+ if (address.includes(':'))
16
+ return false;
17
+ const [a, b] = octets(address);
18
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
19
+ }
20
+ /** Every address remote control may bind, Tailscale first. */
21
+ export function remoteAddresses(interfaces = networkInterfaces()) {
22
+ const found = [];
23
+ for (const [name, entries] of Object.entries(interfaces)) {
24
+ for (const entry of entries ?? []) {
25
+ if (entry.internal)
26
+ continue;
27
+ const family = entry.family === 'IPv6' ? 'IPv6' : 'IPv4';
28
+ if (isTailscale(entry.address))
29
+ found.push({ kind: 'tailscale', address: entry.address, family, interface: name });
30
+ else if (isPrivateLan(entry.address))
31
+ found.push({ kind: 'lan', address: entry.address, family, interface: name });
32
+ }
33
+ }
34
+ // IPv4 first within a kind: it is what a person can type and what every phone resolves.
35
+ const rank = (address) => (address.kind === 'tailscale' ? 0 : 2) + (address.family === 'IPv4' ? 0 : 1);
36
+ return found.sort((a, b) => rank(a) - rank(b));
37
+ }
38
+ /** The one address to listen on for a choice, or a clear error when there is none. */
39
+ export function pickRemoteAddress(choice, candidates) {
40
+ const pick = choice === 'auto' ? candidates[0] : candidates.find((candidate) => candidate.kind === choice);
41
+ if (pick)
42
+ return pick;
43
+ if (choice === 'tailscale')
44
+ throw new HarnessError('remote_unavailable', 'No Tailscale address found. Start Tailscale on this computer, or use the Wi-Fi address instead.');
45
+ if (choice === 'lan')
46
+ throw new HarnessError('remote_unavailable', 'No private Wi-Fi or LAN address found. Join a network, or use Tailscale instead.');
47
+ throw new HarnessError('remote_unavailable', 'No Tailscale or private network address found. Join a Wi-Fi network or start Tailscale, then try /remote-control again.');
48
+ }
49
+ /** How a URL names the address. */
50
+ export function hostPart(address) {
51
+ return address.family === 'IPv6' ? `[${address.address}]` : address.address;
52
+ }
@@ -0,0 +1,59 @@
1
+ import { ReplayGuard } from "./seal.js";
2
+ export declare const MAX_DEVICES = 3;
3
+ /** Under a minute: a phone's browser gives up on a request it waits on much longer. */
4
+ export declare const CONFIRM_MS = 50000;
5
+ export interface RemoteDevice {
6
+ id: string;
7
+ label: string;
8
+ key: Uint8Array;
9
+ pairedAt: number;
10
+ lastSeen: number;
11
+ /** The counters this device has sent. */
12
+ replay: ReplayGuard;
13
+ /** The counter of the last frame sent to it. */
14
+ lastOut: number;
15
+ }
16
+ export interface PairRequest {
17
+ id: string;
18
+ label: string;
19
+ at: number;
20
+ }
21
+ /** A short, printable name for a browser from its user agent. Nothing from the phone is trusted. */
22
+ export declare function deviceLabel(userAgent: unknown): string;
23
+ export declare class Pairing {
24
+ private readonly changed;
25
+ private readonly clock;
26
+ private link;
27
+ private linkReplay;
28
+ private pending;
29
+ private readonly devices;
30
+ private hellos;
31
+ private failures;
32
+ private notice;
33
+ constructor(changed?: () => void, clock?: () => number);
34
+ private newLink;
35
+ /** What follows `#` in the pairing URL. */
36
+ get fragment(): string;
37
+ get request(): PairRequest | null;
38
+ /** A warning for the terminal page, such as a link replaced after repeated bad attempts. */
39
+ get warning(): string | null;
40
+ list(): RemoteDevice[];
41
+ device(deviceId: string): RemoteDevice | null;
42
+ /** A new link; every paired device is dropped and a waiting request denied. */
43
+ rotate(): void;
44
+ forget(deviceId: string): void;
45
+ /** The person at the terminal allowed or refused a waiting request. */
46
+ confirm(requestId: string, allow: boolean): void;
47
+ private fail;
48
+ /**
49
+ * A phone opened the pairing link. Waits until the person at the terminal decides, then returns
50
+ * the device's id and key, sealed with the link key.
51
+ */
52
+ hello(pairingId: unknown, frame: unknown, confirmMs?: number): Promise<string>;
53
+ /** Opens a paired device's frame, enforcing its counter. */
54
+ openFrom<T = unknown>(deviceId: unknown, frame: unknown): {
55
+ device: RemoteDevice;
56
+ body: T;
57
+ };
58
+ sealFor(device: RemoteDevice, body: unknown): string;
59
+ }
@@ -0,0 +1,160 @@
1
+ // Pairing for remote control. The QR code carries a pairing link: an id and a key, in the URL's
2
+ // fragment. A phone that opens it says hello, sealed with that key; the person at the terminal
3
+ // confirms; the phone receives its own device key. A link pairs one device and is then replaced,
4
+ // so the QR on screen always pairs the next one. Every key lives in memory only (INV-RC-2).
5
+ import { randomBytes } from 'node:crypto';
6
+ import { HarnessError } from "../core/errors.js";
7
+ import { encode, newKey, open, ReplayGuard, sameSecret, seal } from "./seal.js";
8
+ export const MAX_DEVICES = 3;
9
+ const HELLO_WINDOW_MS = 60_000;
10
+ const MAX_HELLOS = 10;
11
+ const MAX_FAILURES = 20;
12
+ /** Under a minute: a phone's browser gives up on a request it waits on much longer. */
13
+ export const CONFIRM_MS = 50_000;
14
+ /** A short, printable name for a browser from its user agent. Nothing from the phone is trusted. */
15
+ export function deviceLabel(userAgent) {
16
+ const ua = typeof userAgent === 'string' ? userAgent.slice(0, 400) : '';
17
+ const device = /iPad/.test(ua) ? 'iPad'
18
+ : /iPhone/.test(ua) ? 'iPhone'
19
+ : /Android/.test(ua) ? 'Android'
20
+ : /Macintosh|Mac OS X/.test(ua) ? (/Mobile/.test(ua) ? 'iPad' : 'Mac')
21
+ : /Windows/.test(ua) ? 'Windows'
22
+ : /Linux/.test(ua) ? 'Linux' : 'Browser';
23
+ const browser = /EdgiOS|EdgA|Edg\//.test(ua) ? 'Edge'
24
+ : /CriOS|Chrome\//.test(ua) ? 'Chrome'
25
+ : /FxiOS|Firefox\//.test(ua) ? 'Firefox'
26
+ : /Safari\//.test(ua) ? 'Safari' : '';
27
+ return browser ? `${device} · ${browser}` : device;
28
+ }
29
+ const id = () => randomBytes(12).toString('base64url');
30
+ export class Pairing {
31
+ changed;
32
+ clock;
33
+ link = { id: id(), key: newKey() };
34
+ linkReplay = new ReplayGuard();
35
+ pending = null;
36
+ devices = new Map();
37
+ hellos = [];
38
+ failures = 0;
39
+ notice = null;
40
+ constructor(changed = () => { }, clock = Date.now) {
41
+ this.changed = changed;
42
+ this.clock = clock;
43
+ }
44
+ newLink() {
45
+ this.link = { id: id(), key: newKey() };
46
+ this.linkReplay = new ReplayGuard();
47
+ }
48
+ /** What follows `#` in the pairing URL. */
49
+ get fragment() {
50
+ return `${this.link.id}.${encode(this.link.key)}`;
51
+ }
52
+ get request() {
53
+ return this.pending ? { id: this.pending.id, label: this.pending.label, at: this.pending.at } : null;
54
+ }
55
+ /** A warning for the terminal page, such as a link replaced after repeated bad attempts. */
56
+ get warning() {
57
+ return this.notice;
58
+ }
59
+ list() {
60
+ return [...this.devices.values()];
61
+ }
62
+ device(deviceId) {
63
+ for (const [known, device] of this.devices)
64
+ if (sameSecret(known, deviceId))
65
+ return device;
66
+ return null;
67
+ }
68
+ /** A new link; every paired device is dropped and a waiting request denied. */
69
+ rotate() {
70
+ this.newLink();
71
+ this.pending?.decide(false);
72
+ this.pending = null;
73
+ this.devices.clear();
74
+ this.failures = 0;
75
+ this.notice = null;
76
+ this.changed();
77
+ }
78
+ forget(deviceId) {
79
+ const device = this.device(deviceId);
80
+ if (device && this.devices.delete(device.id))
81
+ this.changed();
82
+ }
83
+ /** The person at the terminal allowed or refused a waiting request. */
84
+ confirm(requestId, allow) {
85
+ if (!this.pending || this.pending.id !== requestId)
86
+ throw new HarnessError('invalid_request', 'This pairing request is no longer waiting.');
87
+ this.pending.decide(allow);
88
+ }
89
+ fail() {
90
+ this.failures += 1;
91
+ if (this.failures >= MAX_FAILURES) {
92
+ this.newLink();
93
+ this.failures = 0;
94
+ this.notice = 'Too many failed pairing attempts. The link was replaced; scan the new QR code.';
95
+ this.changed();
96
+ }
97
+ return new HarnessError('unauthorized', 'This pairing link is not valid. Scan the QR code in the terminal again.');
98
+ }
99
+ /**
100
+ * A phone opened the pairing link. Waits until the person at the terminal decides, then returns
101
+ * the device's id and key, sealed with the link key.
102
+ */
103
+ async hello(pairingId, frame, confirmMs = CONFIRM_MS) {
104
+ const at = this.clock();
105
+ this.hellos = this.hellos.filter((time) => at - time < HELLO_WINDOW_MS);
106
+ if (this.hellos.length >= MAX_HELLOS)
107
+ throw new HarnessError('rate_limited', 'Too many pairing attempts. Wait a minute and try again.');
108
+ this.hellos.push(at);
109
+ if (typeof pairingId !== 'string' || typeof frame !== 'string' || !sameSecret(pairingId, this.link.id))
110
+ throw this.fail();
111
+ const link = this.link;
112
+ let body;
113
+ try {
114
+ body = open(link.key, frame, (n) => this.linkReplay.accept(n)).body ?? {};
115
+ }
116
+ catch {
117
+ throw this.fail();
118
+ }
119
+ if (this.pending)
120
+ throw new HarnessError('remote_busy', 'Another device is waiting for approval in the terminal.');
121
+ if (this.devices.size >= MAX_DEVICES)
122
+ throw new HarnessError('remote_full', `At most ${MAX_DEVICES} devices can control a session. Press n in /remote-control to start over.`);
123
+ const label = deviceLabel(body.userAgent);
124
+ const allowed = await new Promise((resolve) => {
125
+ const timer = setTimeout(() => decide(false), confirmMs);
126
+ timer.unref?.();
127
+ const decide = (allow) => {
128
+ clearTimeout(timer);
129
+ if (this.pending?.decide === decide)
130
+ this.pending = null;
131
+ resolve(allow);
132
+ this.changed();
133
+ };
134
+ this.pending = { id: id(), label, at, decide };
135
+ this.changed();
136
+ });
137
+ // A rotation while the phone waited replaced this link; its request is void.
138
+ if (!allowed || this.link !== link)
139
+ throw new HarnessError('remote_denied', 'Pairing was not allowed at the terminal.');
140
+ const device = { id: id(), label, key: newKey(), pairedAt: this.clock(), lastSeen: this.clock(), replay: new ReplayGuard(), lastOut: 0 };
141
+ this.devices.set(device.id, device);
142
+ // The link paired its device; the QR on screen now pairs the next one.
143
+ this.newLink();
144
+ this.changed();
145
+ return seal(link.key, 1, { deviceId: device.id, deviceKey: encode(device.key), label });
146
+ }
147
+ /** Opens a paired device's frame, enforcing its counter. */
148
+ openFrom(deviceId, frame) {
149
+ const device = typeof deviceId === 'string' ? this.device(deviceId) : null;
150
+ if (!device || typeof frame !== 'string')
151
+ throw new HarnessError('unauthorized', 'This device is not paired with the session. Scan the QR code in the terminal again.');
152
+ const opened = open(device.key, frame, (n) => device.replay.accept(n));
153
+ device.lastSeen = this.clock();
154
+ return { device, body: opened.body };
155
+ }
156
+ sealFor(device, body) {
157
+ device.lastOut += 1;
158
+ return seal(device.key, device.lastOut, body);
159
+ }
160
+ }
@@ -0,0 +1,28 @@
1
+ export declare const KEY_BYTES: number;
2
+ /** How far behind the highest counter a frame may arrive, for requests a phone sent at once. */
3
+ export declare const REPLAY_WINDOW = 256;
4
+ export interface Opened<T> {
5
+ n: number;
6
+ body: T;
7
+ }
8
+ export declare function newKey(): Uint8Array;
9
+ /** URL-safe base64 without padding: it travels in fragments, query strings and SSE lines. */
10
+ export declare function encode(bytes: Uint8Array): string;
11
+ export declare function decode(text: string): Uint8Array;
12
+ export declare function seal(key: Uint8Array, n: number, body: unknown): string;
13
+ /**
14
+ * Opens a frame and asks `fresh` whether its counter is new. A frame that fails here is a wrong
15
+ * key, a tampered body, or a replay; callers answer all three the same way.
16
+ */
17
+ export declare function open<T = unknown>(key: Uint8Array, frame: string, fresh: (n: number) => boolean): Opened<T>;
18
+ /**
19
+ * Remembers the counters a sender used: a counter is fresh when it is above the highest seen, or
20
+ * within the window below it and not seen before.
21
+ */
22
+ export declare class ReplayGuard {
23
+ private highest;
24
+ private readonly recent;
25
+ accept(n: number): boolean;
26
+ }
27
+ /** Compares two identifiers without leaking how much of them matched. */
28
+ export declare function sameSecret(a: string, b: string): boolean;
@@ -0,0 +1,84 @@
1
+ // Sealed frames for remote control. A LAN page is served over plain HTTP, where the browser has no
2
+ // WebCrypto and anyone on the Wi-Fi can read traffic, so every body is a NaCl secretbox
3
+ // (XSalsa20-Poly1305) under a key the phone got from the QR code's fragment, which a browser never
4
+ // sends. A random 24-byte nonce per frame; the counter inside the frame stops replays.
5
+ import { timingSafeEqual } from 'node:crypto';
6
+ import nacl from 'tweetnacl';
7
+ import { HarnessError } from "../core/errors.js";
8
+ export const KEY_BYTES = nacl.secretbox.keyLength;
9
+ /** How far behind the highest counter a frame may arrive, for requests a phone sent at once. */
10
+ export const REPLAY_WINDOW = 256;
11
+ const encoder = new TextEncoder();
12
+ const decoder = new TextDecoder();
13
+ export function newKey() {
14
+ return nacl.randomBytes(KEY_BYTES);
15
+ }
16
+ /** URL-safe base64 without padding: it travels in fragments, query strings and SSE lines. */
17
+ export function encode(bytes) {
18
+ return Buffer.from(bytes).toString('base64url');
19
+ }
20
+ export function decode(text) {
21
+ return new Uint8Array(Buffer.from(text, 'base64url'));
22
+ }
23
+ export function seal(key, n, body) {
24
+ const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
25
+ const box = nacl.secretbox(encoder.encode(JSON.stringify({ n, body })), nonce, key);
26
+ const frame = new Uint8Array(nonce.length + box.length);
27
+ frame.set(nonce);
28
+ frame.set(box, nonce.length);
29
+ return encode(frame);
30
+ }
31
+ const rejected = () => new HarnessError('unauthorized', 'This remote-control frame could not be opened.');
32
+ /**
33
+ * Opens a frame and asks `fresh` whether its counter is new. A frame that fails here is a wrong
34
+ * key, a tampered body, or a replay; callers answer all three the same way.
35
+ */
36
+ export function open(key, frame, fresh) {
37
+ if (typeof frame !== 'string' || frame.length > 1_500_000)
38
+ throw rejected();
39
+ const bytes = decode(frame);
40
+ const nonceLength = nacl.secretbox.nonceLength;
41
+ if (bytes.length <= nonceLength + nacl.secretbox.overheadLength)
42
+ throw rejected();
43
+ const plain = nacl.secretbox.open(bytes.subarray(nonceLength), bytes.subarray(0, nonceLength), key);
44
+ if (!plain)
45
+ throw rejected();
46
+ let value;
47
+ try {
48
+ value = JSON.parse(decoder.decode(plain));
49
+ }
50
+ catch {
51
+ throw rejected();
52
+ }
53
+ if (typeof value.n !== 'number' || !Number.isSafeInteger(value.n) || value.n < 1 || !fresh(value.n))
54
+ throw rejected();
55
+ return { n: value.n, body: value.body };
56
+ }
57
+ /**
58
+ * Remembers the counters a sender used: a counter is fresh when it is above the highest seen, or
59
+ * within the window below it and not seen before.
60
+ */
61
+ export class ReplayGuard {
62
+ highest = 0;
63
+ recent = new Set();
64
+ accept(n) {
65
+ if (n > this.highest) {
66
+ this.highest = n;
67
+ this.recent.add(n);
68
+ for (const seen of this.recent)
69
+ if (seen <= n - REPLAY_WINDOW)
70
+ this.recent.delete(seen);
71
+ return true;
72
+ }
73
+ if (n <= this.highest - REPLAY_WINDOW || this.recent.has(n))
74
+ return false;
75
+ this.recent.add(n);
76
+ return true;
77
+ }
78
+ }
79
+ /** Compares two identifiers without leaking how much of them matched. */
80
+ export function sameSecret(a, b) {
81
+ const left = Buffer.from(a);
82
+ const right = Buffer.from(b);
83
+ return left.length === right.length && timingSafeEqual(left, right);
84
+ }
@@ -0,0 +1,28 @@
1
+ export interface RemoteAsset {
2
+ type: string;
3
+ body: string;
4
+ }
5
+ export interface RemoteStream {
6
+ /** Sends one sealed frame as an SSE message. */
7
+ send(frame: string): void;
8
+ end(): void;
9
+ }
10
+ export interface RemoteRoutes {
11
+ /** The page and its scripts, by path. */
12
+ asset(path: string): RemoteAsset | null;
13
+ hello(body: Record<string, unknown>): Promise<{
14
+ f: string;
15
+ }>;
16
+ call(body: Record<string, unknown>): Promise<{
17
+ f: string;
18
+ }>;
19
+ /** Opens a stream for a paired device; returns what to run when the phone hangs up. */
20
+ stream(deviceId: string, frame: string, stream: RemoteStream): () => void;
21
+ }
22
+ export interface RemoteListening {
23
+ host: string;
24
+ port: number;
25
+ close(): Promise<void>;
26
+ }
27
+ /** Starts the server on one address. `port` 0 picks a free one. */
28
+ export declare function listenRemote(host: string, port: number, routes: RemoteRoutes): Promise<RemoteListening>;
@@ -0,0 +1,158 @@
1
+ // The remote-control HTTP server: the phone page, pairing, sealed calls and a sealed event stream.
2
+ // It listens on exactly one private or Tailscale address (INV-RC-1). Bodies are sealed (seal.ts),
3
+ // so this layer only checks the Host and Origin, caps sizes, and routes.
4
+ import { createServer } from 'node:http';
5
+ import { HarnessError, toErrorShape } from "../core/errors.js";
6
+ const MAX_HELLO = 64_000;
7
+ const MAX_CALL = 1_200_000;
8
+ const PING_MS = 15_000;
9
+ const HEADERS = {
10
+ 'cache-control': 'no-store',
11
+ 'referrer-policy': 'no-referrer',
12
+ 'x-content-type-options': 'nosniff',
13
+ 'x-frame-options': 'DENY',
14
+ 'cross-origin-resource-policy': 'same-origin'
15
+ };
16
+ const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
17
+ const STATUS = { unauthorized: 401, remote_denied: 403, rate_limited: 429, remote_busy: 409, remote_full: 409, session_busy: 409, terminal_has_draft: 409, too_large: 413, not_found: 404 };
18
+ function reply(response, status, body) {
19
+ response.writeHead(status, { ...HEADERS, 'content-type': 'application/json; charset=utf-8' });
20
+ response.end(JSON.stringify(body));
21
+ }
22
+ function failure(response, error) {
23
+ const shape = toErrorShape(error);
24
+ reply(response, STATUS[shape.code] ?? 400, { error: { code: shape.code, message: shape.message } });
25
+ }
26
+ async function readBody(request, limit) {
27
+ const chunks = [];
28
+ let size = 0;
29
+ for await (const chunk of request) {
30
+ size += chunk.length;
31
+ if (size > limit)
32
+ throw new HarnessError('too_large', 'This message is too large.');
33
+ chunks.push(chunk);
34
+ }
35
+ try {
36
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
37
+ if (value && typeof value === 'object' && !Array.isArray(value))
38
+ return value;
39
+ }
40
+ catch { }
41
+ throw new HarnessError('invalid_request', 'The request is not JSON.');
42
+ }
43
+ /** Starts the server on one address. `port` 0 picks a free one. */
44
+ export async function listenRemote(host, port, routes) {
45
+ const sockets = new Set();
46
+ const streams = new Set();
47
+ let authority = '';
48
+ const handle = async (request, response) => {
49
+ // A page on another site cannot reach this server through DNS it controls (rebinding), and no
50
+ // other origin may post to it.
51
+ if (request.headers.host !== authority)
52
+ return reply(response, 421, { error: { code: 'wrong_host', message: 'Open the link shown in the terminal.' } });
53
+ const origin = request.headers.origin;
54
+ if (origin !== undefined && origin !== `http://${authority}`)
55
+ return reply(response, 403, { error: { code: 'wrong_origin', message: 'Requests from other sites are refused.' } });
56
+ const url = new URL(request.url ?? '/', `http://${authority}`);
57
+ if (request.method === 'GET' && url.pathname === '/rc/events') {
58
+ const deviceId = url.searchParams.get('d') ?? '';
59
+ const frame = url.searchParams.get('f') ?? '';
60
+ let ended = false;
61
+ let opened = false;
62
+ let ping = null;
63
+ // The route sends its first frame as the stream opens, so the headers go out with it.
64
+ const begin = () => {
65
+ if (opened)
66
+ return;
67
+ opened = true;
68
+ response.writeHead(200, { ...HEADERS, 'content-type': 'text/event-stream; charset=utf-8', connection: 'keep-alive' });
69
+ response.write(': open\n\n');
70
+ };
71
+ const stream = {
72
+ send: (data) => {
73
+ if (ended)
74
+ return;
75
+ begin();
76
+ response.write(`data: ${data}\n\n`);
77
+ },
78
+ end: () => {
79
+ if (ended)
80
+ return;
81
+ ended = true;
82
+ streams.delete(stream);
83
+ if (ping)
84
+ clearInterval(ping);
85
+ response.end();
86
+ }
87
+ };
88
+ let release;
89
+ try {
90
+ release = routes.stream(deviceId, frame, stream);
91
+ }
92
+ catch (error) {
93
+ return failure(response, error);
94
+ }
95
+ begin();
96
+ if (ended)
97
+ return release();
98
+ streams.add(stream);
99
+ ping = setInterval(() => { if (!ended)
100
+ response.write(': ping\n\n'); }, PING_MS);
101
+ ping.unref?.();
102
+ request.on('close', () => {
103
+ stream.end();
104
+ release();
105
+ });
106
+ return;
107
+ }
108
+ if (request.method === 'POST' && (url.pathname === '/rc/hello' || url.pathname === '/rc/call')) {
109
+ try {
110
+ const hello = url.pathname === '/rc/hello';
111
+ const body = await readBody(request, hello ? MAX_HELLO : MAX_CALL);
112
+ return reply(response, 200, hello ? await routes.hello(body) : await routes.call(body));
113
+ }
114
+ catch (error) {
115
+ return failure(response, error);
116
+ }
117
+ }
118
+ if (request.method === 'GET') {
119
+ const asset = routes.asset(url.pathname);
120
+ if (asset) {
121
+ response.writeHead(200, { ...HEADERS, 'content-type': asset.type, 'content-security-policy': CSP });
122
+ response.end(asset.body);
123
+ return;
124
+ }
125
+ }
126
+ reply(response, 404, { error: { code: 'not_found', message: 'Not found.' } });
127
+ };
128
+ const server = createServer((request, response) => {
129
+ handle(request, response).catch((error) => { if (!response.headersSent)
130
+ failure(response, error); });
131
+ });
132
+ server.on('connection', (socket) => {
133
+ sockets.add(socket);
134
+ socket.on('close', () => sockets.delete(socket));
135
+ });
136
+ await new Promise((resolve, reject) => {
137
+ server.once('error', reject);
138
+ server.listen({ host, port, exclusive: true }, () => {
139
+ server.off('error', reject);
140
+ resolve();
141
+ });
142
+ });
143
+ const bound = server.address().port;
144
+ authority = `${host.includes(':') ? `[${host}]` : host}:${bound}`;
145
+ return {
146
+ host,
147
+ port: bound,
148
+ close: async () => {
149
+ for (const stream of [...streams])
150
+ stream.end();
151
+ await new Promise((resolve) => {
152
+ server.close(() => resolve());
153
+ for (const socket of sockets)
154
+ socket.destroy();
155
+ });
156
+ }
157
+ };
158
+ }