@prohost/cli 0.13.2 → 0.14.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/CHANGELOG.md +95 -0
- package/README.md +129 -0
- package/dist/agent/claude.d.ts +28 -0
- package/dist/agent/claude.js +83 -4
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +311 -16
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/hooks.d.ts +178 -0
- package/dist/codehost/hooks.js +726 -0
- package/dist/codehost/host.d.ts +290 -0
- package/dist/codehost/host.js +625 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +170 -0
- package/dist/codehost/relay.js +548 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +163 -0
- package/dist/codehost/sizing.js +394 -0
- package/dist/codehost/terminals.d.ts +390 -0
- package/dist/codehost/terminals.js +965 -0
- package/dist/codehost/tmux.d.ts +196 -0
- package/dist/codehost/tmux.js +391 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Mac's side of the code-host relay (`api_public/websockets/code_host_relay.py`).
|
|
3
|
+
*
|
|
4
|
+
* One **control** socket to `/v1/code-hosts/control`, authenticated with the
|
|
5
|
+
* paired agent's API key, open while Code-thread hosting is on. It says the
|
|
6
|
+
* machine is online, reports its stats and its `allowRemoteTerminal` switch,
|
|
7
|
+
* and receives:
|
|
8
|
+
*
|
|
9
|
+
* - `host_token` — the narrow `host_events` credential the hooks post with,
|
|
10
|
+
* written to `hook.json` (see `events.ts`);
|
|
11
|
+
* - `dial` — a viewer wants a terminal. Each dial opens a **fresh stream
|
|
12
|
+
* socket** to `/v1/code-hosts/stream` for that one viewer: terminal bytes are
|
|
13
|
+
* never multiplexed on the control socket, so one slow viewer can't stall
|
|
14
|
+
* another or the control channel.
|
|
15
|
+
*
|
|
16
|
+
* The relay refuses viewers unless this machine last said
|
|
17
|
+
* `allow_remote_terminal: true`; this side checks the switch again, on disk,
|
|
18
|
+
* at every dial, so turning it off takes effect for the next attach even
|
|
19
|
+
* before the relay has heard.
|
|
20
|
+
*/
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import WebSocket from 'ws';
|
|
23
|
+
import { endpointProblem } from '../agent/base-url.js';
|
|
24
|
+
import { USER_AGENT } from '../version.js';
|
|
25
|
+
import { isValidId, parseControl, TERMINAL_WINDOWS } from './tmux.js';
|
|
26
|
+
export const CONTROL_PATH = '/v1/code-hosts/control';
|
|
27
|
+
export const STREAM_PATH = '/v1/code-hosts/stream';
|
|
28
|
+
/** How often a status frame refreshes the relay's view of this machine. */
|
|
29
|
+
export const STATUS_INTERVAL_MS = 30_000;
|
|
30
|
+
/**
|
|
31
|
+
* A flipped switch is sent at once and again this long after: the relay takes a
|
|
32
|
+
* flip inside its status window but at most once a second
|
|
33
|
+
* (`STATUS_SWITCH_MIN_INTERVAL_SECONDS`), so one that landed within a second of
|
|
34
|
+
* another status is dropped, and the repeat carries it.
|
|
35
|
+
*/
|
|
36
|
+
export const STATUS_SWITCH_RESEND_MS = 1_200;
|
|
37
|
+
/** No frame from the relay for this long means the control socket is dead. */
|
|
38
|
+
export const CONTROL_SILENCE_MS = 75_000;
|
|
39
|
+
const RECONNECT_MIN_MS = 1_000;
|
|
40
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
41
|
+
const STREAM_READY_TIMEOUT_MS = 10_000;
|
|
42
|
+
/** A relay that upgraded but never answered the auth frame is a dead socket. */
|
|
43
|
+
export const CONTROL_AUTH_TIMEOUT_MS = 20_000;
|
|
44
|
+
/** Output buffered toward a slow relay before the stream is dropped (tmux redraws on reconnect). */
|
|
45
|
+
export const MAX_STREAM_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
46
|
+
/** `ws(s)://host/prefix<route>` from the API base URL; `undefined` when unusable. */
|
|
47
|
+
export function relayUrl(baseUrl, route) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(baseUrl);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
56
|
+
return undefined;
|
|
57
|
+
const url = `${parsed.protocol === 'http:' ? 'ws' : 'wss'}://${parsed.host}${parsed.pathname.replace(/\/+$/, '')}${route}`;
|
|
58
|
+
return endpointProblem(url, 'ws') ? undefined : url;
|
|
59
|
+
}
|
|
60
|
+
/** A `dial` frame, or `null` when it is not one this host can act on. */
|
|
61
|
+
export function parseDial(frame) {
|
|
62
|
+
const { stream_id: streamId, stream_token: streamToken, terminal_id: terminalId } = frame;
|
|
63
|
+
if (typeof streamId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(streamId))
|
|
64
|
+
return null;
|
|
65
|
+
if (typeof streamToken !== 'string' || !streamToken)
|
|
66
|
+
return null;
|
|
67
|
+
if (!isValidId(terminalId))
|
|
68
|
+
return null;
|
|
69
|
+
const window = typeof frame.window === 'string' && TERMINAL_WINDOWS.has(frame.window) ? frame.window : 'agent';
|
|
70
|
+
return {
|
|
71
|
+
streamId,
|
|
72
|
+
streamToken,
|
|
73
|
+
terminalId,
|
|
74
|
+
window: window,
|
|
75
|
+
cols: typeof frame.cols === 'number' ? frame.cols : undefined,
|
|
76
|
+
rows: typeof frame.rows === 'number' ? frame.rows : undefined,
|
|
77
|
+
mode: frame.mode === 'watch' ? 'watch' : 'type',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Pipe one terminal stream over one open socket until either side ends. */
|
|
81
|
+
export function pipeStream(ws, stream, onClosed) {
|
|
82
|
+
let done = false;
|
|
83
|
+
const finish = (reason) => {
|
|
84
|
+
if (done)
|
|
85
|
+
return;
|
|
86
|
+
done = true;
|
|
87
|
+
stream.close();
|
|
88
|
+
try {
|
|
89
|
+
ws.close(reason === 'slow_consumer' ? 1013 : 1000, reason);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* already closing */
|
|
93
|
+
}
|
|
94
|
+
onClosed(reason);
|
|
95
|
+
};
|
|
96
|
+
stream.onData((chunk) => {
|
|
97
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
98
|
+
return;
|
|
99
|
+
if (ws.bufferedAmount > MAX_STREAM_BUFFERED_BYTES) {
|
|
100
|
+
finish('slow_consumer');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
ws.send(chunk, { binary: true });
|
|
104
|
+
});
|
|
105
|
+
stream.onExit(() => {
|
|
106
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
107
|
+
try {
|
|
108
|
+
ws.send(JSON.stringify({ type: 'exit' }));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* closing anyway */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finish('exit');
|
|
115
|
+
});
|
|
116
|
+
ws.on('message', (data, isBinary) => {
|
|
117
|
+
if (done)
|
|
118
|
+
return;
|
|
119
|
+
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
120
|
+
if (isBinary) {
|
|
121
|
+
stream.write(buffer);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const control = parseControl(buffer.toString('utf8'));
|
|
125
|
+
if (!control)
|
|
126
|
+
return;
|
|
127
|
+
if (control.type === 'resize')
|
|
128
|
+
stream.resize(control.cols, control.rows);
|
|
129
|
+
else if (control.type === 'input')
|
|
130
|
+
stream.write(Buffer.from(control.data, 'utf8'));
|
|
131
|
+
else if (control.type === 'hello')
|
|
132
|
+
stream.hello?.(control.client);
|
|
133
|
+
else if (control.type === 'claim')
|
|
134
|
+
stream.claim?.();
|
|
135
|
+
else if (control.type === 'ping')
|
|
136
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
137
|
+
});
|
|
138
|
+
// Who sizes the window, for a viewer that said hello (`sizing.ts` sends nothing to one that didn't).
|
|
139
|
+
stream.onSizer?.((frame) => {
|
|
140
|
+
if (done || ws.readyState !== WebSocket.OPEN)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
ws.send(JSON.stringify(frame));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* closing anyway */
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
ws.on('close', () => finish('closed'));
|
|
150
|
+
ws.on('error', () => finish('error'));
|
|
151
|
+
}
|
|
152
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
153
|
+
/** The pairing identity in an `auth_ok`, or `undefined` from a relay that sends none. */
|
|
154
|
+
export function parseIdentity(frame) {
|
|
155
|
+
const { account_id: accountId, owner_user_id: ownerUserId } = frame;
|
|
156
|
+
if (typeof accountId !== 'string' || !UUID.test(accountId))
|
|
157
|
+
return undefined;
|
|
158
|
+
if (typeof ownerUserId !== 'string' || !UUID.test(ownerUserId))
|
|
159
|
+
return undefined;
|
|
160
|
+
return { accountId: accountId.toLowerCase(), ownerUserId: ownerUserId.toLowerCase() };
|
|
161
|
+
}
|
|
162
|
+
/** How long the relay gets to answer a `verify_viewer`. */
|
|
163
|
+
export const VERIFY_VIEWER_TIMEOUT_MS = 5_000;
|
|
164
|
+
/** A `viewer_verified` frame, or `null` when it is malformed (treated as a refusal). */
|
|
165
|
+
export function parseViewerVerdict(frame) {
|
|
166
|
+
if (frame.ok !== true)
|
|
167
|
+
return { ok: false, reason: typeof frame.reason === 'string' ? frame.reason : 'not_allowed' };
|
|
168
|
+
const { user_id: userId, terminal_id: terminalId } = frame;
|
|
169
|
+
if (typeof userId !== 'string' || !isValidId(terminalId))
|
|
170
|
+
return null;
|
|
171
|
+
// Only an explicit null leaves the window open; anything else unknown refuses (fail closed).
|
|
172
|
+
let window;
|
|
173
|
+
if (frame.window === null)
|
|
174
|
+
window = null;
|
|
175
|
+
else if (typeof frame.window === 'string' && TERMINAL_WINDOWS.has(frame.window))
|
|
176
|
+
window = frame.window;
|
|
177
|
+
else
|
|
178
|
+
return null;
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
userId,
|
|
182
|
+
codeThreadId: typeof frame.code_thread_id === 'string' ? frame.code_thread_id : null,
|
|
183
|
+
terminalId,
|
|
184
|
+
window,
|
|
185
|
+
// Anything but an explicit `type` watches: a verdict never widens what the token said.
|
|
186
|
+
mode: frame.mode === 'type' ? 'type' : 'watch',
|
|
187
|
+
local: frame.local === true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function defaultTimer(fn, ms) {
|
|
191
|
+
const timer = setTimeout(fn, ms);
|
|
192
|
+
timer.unref?.();
|
|
193
|
+
return () => clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
export class CodeHostRelay {
|
|
196
|
+
options;
|
|
197
|
+
ws;
|
|
198
|
+
stopped = true;
|
|
199
|
+
attempt = 0;
|
|
200
|
+
timers = new Set();
|
|
201
|
+
streams = new Set();
|
|
202
|
+
lastFrameAt = 0;
|
|
203
|
+
authed = false;
|
|
204
|
+
/** `verify_viewer` requests awaiting the relay's answer, by request id. */
|
|
205
|
+
verifying = new Map();
|
|
206
|
+
/** Bumped by {@link dropStreams}: a dial begun before a drop never opens. */
|
|
207
|
+
generation = 0;
|
|
208
|
+
refused;
|
|
209
|
+
log;
|
|
210
|
+
Ctor;
|
|
211
|
+
setTimer;
|
|
212
|
+
constructor(options) {
|
|
213
|
+
this.options = options;
|
|
214
|
+
this.log = options.log ?? (() => { });
|
|
215
|
+
this.Ctor = options.WebSocketCtor ?? WebSocket;
|
|
216
|
+
this.setTimer = options.setTimer ?? defaultTimer;
|
|
217
|
+
}
|
|
218
|
+
/** Whether the control socket is authenticated right now. Exposed for assertions. */
|
|
219
|
+
connected() {
|
|
220
|
+
return this.authed && this.ws?.readyState === WebSocket.OPEN;
|
|
221
|
+
}
|
|
222
|
+
/** Open streams. Exposed for assertions. */
|
|
223
|
+
openStreams() {
|
|
224
|
+
return this.streams.size;
|
|
225
|
+
}
|
|
226
|
+
start() {
|
|
227
|
+
if (!this.stopped)
|
|
228
|
+
return;
|
|
229
|
+
this.stopped = false;
|
|
230
|
+
this.connect();
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* End every attached viewer now. A terminal is attached only while this
|
|
234
|
+
* machine is authorized: losing the control socket, the relay refusing the
|
|
235
|
+
* pairing (revoked key, `pairing_revoked`), or the owner turning the remote
|
|
236
|
+
* terminal off all drop what is open — viewers reconnect through a fresh,
|
|
237
|
+
* fully checked attach.
|
|
238
|
+
*/
|
|
239
|
+
/**
|
|
240
|
+
* Ask the relay who a viewer token's bearer is (the desktop fast path's only
|
|
241
|
+
* source of identity: the app page's own word is never taken). Resolves a
|
|
242
|
+
* refusal when the relay is unreachable, slow, or says no. Never rejects.
|
|
243
|
+
*/
|
|
244
|
+
verifyViewer(token) {
|
|
245
|
+
if (!this.authed || this.stopped || !token)
|
|
246
|
+
return Promise.resolve({ ok: false, reason: 'relay_unavailable' });
|
|
247
|
+
const requestId = randomBytes(12).toString('hex');
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
const cancel = this.setTimer(() => {
|
|
250
|
+
this.verifying.delete(requestId);
|
|
251
|
+
resolve({ ok: false, reason: 'relay_unavailable' });
|
|
252
|
+
}, VERIFY_VIEWER_TIMEOUT_MS);
|
|
253
|
+
this.verifying.set(requestId, (verdict) => {
|
|
254
|
+
cancel();
|
|
255
|
+
resolve(verdict);
|
|
256
|
+
});
|
|
257
|
+
this.send({ type: 'verify_viewer', request_id: requestId, token });
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
refuseVerifications() {
|
|
261
|
+
for (const resolve of this.verifying.values())
|
|
262
|
+
resolve({ ok: false, reason: 'relay_unavailable' });
|
|
263
|
+
this.verifying.clear();
|
|
264
|
+
}
|
|
265
|
+
dropStreams(reason) {
|
|
266
|
+
this.generation += 1;
|
|
267
|
+
if (this.streams.size > 0)
|
|
268
|
+
this.log(` ⏹ closing ${this.streams.size} remote terminal(s): ${reason}`);
|
|
269
|
+
for (const stream of this.streams)
|
|
270
|
+
stream.terminate();
|
|
271
|
+
this.streams.clear();
|
|
272
|
+
}
|
|
273
|
+
/** The owner's switch changed: tell the relay now, and once more a moment later, not at the next 30 s status. */
|
|
274
|
+
statusChanged() {
|
|
275
|
+
if (!this.authed || this.stopped)
|
|
276
|
+
return;
|
|
277
|
+
void this.sendStatus();
|
|
278
|
+
this.later(() => {
|
|
279
|
+
if (this.authed && !this.stopped)
|
|
280
|
+
void this.sendStatus();
|
|
281
|
+
}, STATUS_SWITCH_RESEND_MS);
|
|
282
|
+
}
|
|
283
|
+
stop() {
|
|
284
|
+
this.stopped = true;
|
|
285
|
+
this.refuseVerifications();
|
|
286
|
+
for (const cancel of this.timers)
|
|
287
|
+
cancel();
|
|
288
|
+
this.timers.clear();
|
|
289
|
+
for (const stream of this.streams)
|
|
290
|
+
stream.terminate();
|
|
291
|
+
this.streams.clear();
|
|
292
|
+
try {
|
|
293
|
+
this.ws?.close();
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* already closed */
|
|
297
|
+
}
|
|
298
|
+
this.ws = undefined;
|
|
299
|
+
}
|
|
300
|
+
later(fn, ms) {
|
|
301
|
+
const cancel = this.setTimer(() => {
|
|
302
|
+
this.timers.delete(cancel);
|
|
303
|
+
fn();
|
|
304
|
+
}, ms);
|
|
305
|
+
this.timers.add(cancel);
|
|
306
|
+
}
|
|
307
|
+
reconnect() {
|
|
308
|
+
if (this.stopped)
|
|
309
|
+
return;
|
|
310
|
+
this.ws = undefined;
|
|
311
|
+
this.attempt += 1;
|
|
312
|
+
const ceiling = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** Math.min(this.attempt, 6));
|
|
313
|
+
this.later(() => this.connect(), Math.round(ceiling * (0.5 + (this.options.random ?? Math.random)() / 2)));
|
|
314
|
+
}
|
|
315
|
+
send(frame) {
|
|
316
|
+
try {
|
|
317
|
+
if (this.ws?.readyState === WebSocket.OPEN)
|
|
318
|
+
this.ws.send(JSON.stringify(frame));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
/* the close handler reconnects */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async sendStatus() {
|
|
325
|
+
const host = (await this.options.stats().catch(() => undefined)) ?? null;
|
|
326
|
+
// Read after the (up to seconds-long) stats sample, at the moment of sending: of two
|
|
327
|
+
// overlapping sends, the one that lands last carries the switch as it is then, never an
|
|
328
|
+
// older value it read before waiting.
|
|
329
|
+
this.send({ type: 'status', allow_remote_terminal: this.options.settings().allowRemoteTerminal, host });
|
|
330
|
+
}
|
|
331
|
+
statusLoop(ws) {
|
|
332
|
+
this.later(() => {
|
|
333
|
+
if (this.ws !== ws || this.stopped)
|
|
334
|
+
return;
|
|
335
|
+
if (Date.now() - this.lastFrameAt > CONTROL_SILENCE_MS) {
|
|
336
|
+
this.log(' … code-host relay went quiet — reconnecting');
|
|
337
|
+
ws.terminate();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
void this.sendStatus();
|
|
341
|
+
this.statusLoop(ws);
|
|
342
|
+
}, STATUS_INTERVAL_MS);
|
|
343
|
+
}
|
|
344
|
+
connect() {
|
|
345
|
+
if (this.stopped)
|
|
346
|
+
return;
|
|
347
|
+
const url = relayUrl(this.options.baseUrl, CONTROL_PATH);
|
|
348
|
+
if (!url) {
|
|
349
|
+
this.log(' ! code-host relay disabled: the API base URL is not https (or loopback http)');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const ws = new this.Ctor(url, { headers: { 'User-Agent': USER_AGENT }, handshakeTimeout: 15_000 });
|
|
353
|
+
this.ws = ws;
|
|
354
|
+
ws.on('open', () => {
|
|
355
|
+
this.lastFrameAt = Date.now();
|
|
356
|
+
// Until auth_ok the status loop (the silence watchdog) isn't running.
|
|
357
|
+
this.later(() => {
|
|
358
|
+
if (this.ws === ws && !this.authed && !this.stopped) {
|
|
359
|
+
this.log(' … code-host relay never answered auth — reconnecting');
|
|
360
|
+
ws.terminate();
|
|
361
|
+
}
|
|
362
|
+
}, CONTROL_AUTH_TIMEOUT_MS);
|
|
363
|
+
void (async () => {
|
|
364
|
+
const settings = this.options.settings();
|
|
365
|
+
const host = (await this.options.stats().catch(() => undefined)) ?? null;
|
|
366
|
+
try {
|
|
367
|
+
ws.send(JSON.stringify({
|
|
368
|
+
type: 'auth',
|
|
369
|
+
api_key: this.options.apiKey,
|
|
370
|
+
machine_id: this.options.machineId,
|
|
371
|
+
...(this.options.instanceId ? { instance_id: this.options.instanceId } : {}),
|
|
372
|
+
machine: this.options.machine,
|
|
373
|
+
client_version: this.options.clientVersion,
|
|
374
|
+
allow_remote_terminal: settings.allowRemoteTerminal,
|
|
375
|
+
host,
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
/* close follows */
|
|
380
|
+
}
|
|
381
|
+
})();
|
|
382
|
+
});
|
|
383
|
+
ws.on('message', (data) => {
|
|
384
|
+
// After stop() — or from a socket this relay has replaced — nothing counts.
|
|
385
|
+
if (this.stopped || this.ws !== ws)
|
|
386
|
+
return;
|
|
387
|
+
this.lastFrameAt = Date.now();
|
|
388
|
+
let frame;
|
|
389
|
+
try {
|
|
390
|
+
const parsed = JSON.parse(data.toString());
|
|
391
|
+
if (!parsed || typeof parsed !== 'object')
|
|
392
|
+
return;
|
|
393
|
+
frame = parsed;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
this.onFrame(ws, frame);
|
|
399
|
+
});
|
|
400
|
+
ws.on('close', () => {
|
|
401
|
+
if (this.ws !== ws)
|
|
402
|
+
return;
|
|
403
|
+
this.authed = false;
|
|
404
|
+
this.dropStreams('the relay control socket closed');
|
|
405
|
+
this.refuseVerifications();
|
|
406
|
+
if (!this.stopped)
|
|
407
|
+
this.options.onDisconnected?.();
|
|
408
|
+
if (this.refused) {
|
|
409
|
+
// Auth was refused: retrying the same key cannot succeed. Try again
|
|
410
|
+
// slowly in case the pairing is restored, without a log line per try.
|
|
411
|
+
this.attempt = Math.max(this.attempt, 6);
|
|
412
|
+
}
|
|
413
|
+
this.reconnect();
|
|
414
|
+
});
|
|
415
|
+
ws.on('error', () => {
|
|
416
|
+
/* close follows */
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
onFrame(ws, frame) {
|
|
420
|
+
switch (frame.type) {
|
|
421
|
+
case 'auth_ok': {
|
|
422
|
+
if (this.attempt > 0 || this.refused)
|
|
423
|
+
this.log(' ● code-host relay connected');
|
|
424
|
+
this.attempt = 0;
|
|
425
|
+
this.authed = true;
|
|
426
|
+
this.refused = undefined;
|
|
427
|
+
this.acceptToken(frame.host_token, frame.host_token_expires_at);
|
|
428
|
+
this.statusLoop(ws);
|
|
429
|
+
this.options.onAuthorization?.(true, undefined, parseIdentity(frame));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
case 'auth_error': {
|
|
433
|
+
const reason = String(frame.reason ?? 'unknown');
|
|
434
|
+
if (this.refused !== reason)
|
|
435
|
+
this.log(` ✗ code-host relay refused this machine: ${reason}`);
|
|
436
|
+
this.refused = reason;
|
|
437
|
+
this.authed = false;
|
|
438
|
+
this.dropStreams(`the relay refused this machine (${reason})`);
|
|
439
|
+
this.options.onAuthorization?.(false, reason);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
case 'host_token':
|
|
443
|
+
this.acceptToken(frame.token, frame.expires_at);
|
|
444
|
+
return;
|
|
445
|
+
case 'viewer_verified': {
|
|
446
|
+
const requestId = typeof frame.request_id === 'string' ? frame.request_id : '';
|
|
447
|
+
const resolve = this.verifying.get(requestId);
|
|
448
|
+
if (!resolve)
|
|
449
|
+
return;
|
|
450
|
+
this.verifying.delete(requestId);
|
|
451
|
+
resolve(parseViewerVerdict(frame) ?? { ok: false, reason: 'not_allowed' });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
case 'ping':
|
|
455
|
+
this.send({ type: 'pong' });
|
|
456
|
+
return;
|
|
457
|
+
case 'dial': {
|
|
458
|
+
const dial = parseDial(frame);
|
|
459
|
+
if (dial)
|
|
460
|
+
void this.onDial(dial);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
default:
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
acceptToken(token, expiresAt) {
|
|
468
|
+
if (typeof token !== 'string' || !token || typeof expiresAt !== 'number')
|
|
469
|
+
return;
|
|
470
|
+
try {
|
|
471
|
+
this.options.onHostToken(token, expiresAt);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
this.log(` ! could not store the hook token: ${err instanceof Error ? err.message : String(err)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
dialError(streamId, reason) {
|
|
478
|
+
this.send({ type: 'dial_error', stream_id: streamId, reason });
|
|
479
|
+
}
|
|
480
|
+
async onDial(dial) {
|
|
481
|
+
// The owner's switch, read from disk now — not whatever the relay last heard.
|
|
482
|
+
if (!this.options.settings().allowRemoteTerminal) {
|
|
483
|
+
this.dialError(dial.streamId, 'remote_terminal_disabled');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const url = relayUrl(this.options.baseUrl, STREAM_PATH);
|
|
487
|
+
if (!url)
|
|
488
|
+
return;
|
|
489
|
+
const generation = this.generation;
|
|
490
|
+
/** Still authorized: nothing dropped since the dial, control authed, switch on. */
|
|
491
|
+
const current = () => generation === this.generation && this.authed && !this.stopped && this.options.settings().allowRemoteTerminal;
|
|
492
|
+
const attached = await this.options.terminals.attach({
|
|
493
|
+
terminalId: dial.terminalId,
|
|
494
|
+
window: dial.window,
|
|
495
|
+
cols: dial.cols,
|
|
496
|
+
rows: dial.rows,
|
|
497
|
+
mode: dial.mode,
|
|
498
|
+
});
|
|
499
|
+
if (!attached.ok) {
|
|
500
|
+
this.dialError(dial.streamId, attached.reason);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const stream = attached.stream;
|
|
504
|
+
// Attaching took a moment; authorization may have ended in it.
|
|
505
|
+
if (!current()) {
|
|
506
|
+
stream.close();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const ws = new this.Ctor(url, { headers: { 'User-Agent': USER_AGENT }, handshakeTimeout: 15_000 });
|
|
510
|
+
this.streams.add(ws);
|
|
511
|
+
let ready = false;
|
|
512
|
+
const cancelReady = this.setTimer(() => {
|
|
513
|
+
if (!ready)
|
|
514
|
+
ws.terminate();
|
|
515
|
+
}, STREAM_READY_TIMEOUT_MS);
|
|
516
|
+
const abandon = () => {
|
|
517
|
+
cancelReady();
|
|
518
|
+
this.streams.delete(ws);
|
|
519
|
+
if (!ready)
|
|
520
|
+
stream.close();
|
|
521
|
+
};
|
|
522
|
+
ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', stream_token: dial.streamToken })));
|
|
523
|
+
ws.on('close', abandon);
|
|
524
|
+
ws.on('error', () => {
|
|
525
|
+
/* close follows */
|
|
526
|
+
});
|
|
527
|
+
const onFirst = (data, isBinary) => {
|
|
528
|
+
if (isBinary)
|
|
529
|
+
return;
|
|
530
|
+
let frame;
|
|
531
|
+
try {
|
|
532
|
+
frame = JSON.parse(data.toString());
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
frame = undefined;
|
|
536
|
+
}
|
|
537
|
+
if (frame?.type !== 'ready' || !current()) {
|
|
538
|
+
ws.terminate();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
ready = true;
|
|
542
|
+
cancelReady();
|
|
543
|
+
ws.off('message', onFirst);
|
|
544
|
+
pipeStream(ws, stream, () => this.streams.delete(ws));
|
|
545
|
+
};
|
|
546
|
+
ws.on('message', onFirst);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Mac's Code-thread hosting switches, and the private files the host keeps.
|
|
3
|
+
*
|
|
4
|
+
* Two switches, both off until the machine's owner turns them on here — the
|
|
5
|
+
* server can read them (the heartbeat and the relay socket report them) but has
|
|
6
|
+
* no way to change them:
|
|
7
|
+
*
|
|
8
|
+
* - `enabled` — this machine hosts Code threads: it takes
|
|
9
|
+
* `code_thread.start_requested` / `code_thread.input_requested` from the
|
|
10
|
+
* event stream, keeps their terminals in tmux, and holds the relay's control
|
|
11
|
+
* socket open.
|
|
12
|
+
* - `allowRemoteTerminal` — someone other than a shell on this machine may
|
|
13
|
+
* attach to those terminals and type: a relay viewer, or the desktop app
|
|
14
|
+
* with any viewer token. Off, the desktop app on this Mac still attaches
|
|
15
|
+
* over loopback, but only with a token the relay vouches for as minted for
|
|
16
|
+
* this Mac's own app (`local_server.ts`). A remote terminal is remote code
|
|
17
|
+
* execution on this machine; it is never on by default.
|
|
18
|
+
*
|
|
19
|
+
* Everything lives under `$PROHOST_HOME/code-host/` with owner-only modes.
|
|
20
|
+
*/
|
|
21
|
+
export interface CodeHostSettings {
|
|
22
|
+
version: 1;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
allowRemoteTerminal: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_SETTINGS: CodeHostSettings;
|
|
27
|
+
/** `$PROHOST_HOME/code-host`. */
|
|
28
|
+
export declare function codeHostDir(env?: NodeJS.ProcessEnv): string;
|
|
29
|
+
export declare function settingsPath(env?: NodeJS.ProcessEnv): string;
|
|
30
|
+
/** Create `dir` (and parents) owner-only. Idempotent. */
|
|
31
|
+
export declare function ensurePrivateDir(dir: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* Write `value` as JSON, atomically (a temp file beside it, then a rename) and
|
|
34
|
+
* owner-only, so a reader never sees half a file or a moment of wider mode.
|
|
35
|
+
*/
|
|
36
|
+
export declare function writePrivateJson(file: string, value: unknown, mode?: number): void;
|
|
37
|
+
/**
|
|
38
|
+
* This home's Code-host instance id: minted once per `PROHOST_HOME`, kept in
|
|
39
|
+
* `code-host/instance.json`. The server binds each Mac thread to the instance
|
|
40
|
+
* that accepted its start (`coding_sessions.host_instance_id`) and sends its
|
|
41
|
+
* later frames to that instance alone: two harnesses on one pairing each keep
|
|
42
|
+
* their own tmux windows.
|
|
43
|
+
*/
|
|
44
|
+
export declare function codeHostInstanceId(env?: NodeJS.ProcessEnv): string;
|
|
45
|
+
/** Parsed JSON object at `file`, or `undefined` for a missing or unreadable one. */
|
|
46
|
+
export declare function readJsonObject(file: string): Record<string, unknown> | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The switches as they are on disk now. A missing, unreadable or malformed file
|
|
49
|
+
* is the default — both off — and only a literal `true` turns a switch on.
|
|
50
|
+
*/
|
|
51
|
+
export declare function loadSettings(env?: NodeJS.ProcessEnv): CodeHostSettings;
|
|
52
|
+
export declare function saveSettings(settings: CodeHostSettings, env?: NodeJS.ProcessEnv): void;
|