pi-open-deskos 0.3.0 → 0.4.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 +86 -17
- package/README.zh-CN.md +74 -17
- package/fixtures/control-v2.json +2 -2
- package/package.json +7 -6
- package/session-control +38 -0
- package/src/config.ts +135 -15
- package/src/console-client.ts +47 -21
- package/src/console-extension.ts +84 -43
- package/src/control-transport.ts +3 -3
- package/src/desk-set.ts +69 -0
- package/src/events.ts +60 -17
- package/src/index.ts +118 -30
- package/src/processes.ts +117 -13
- package/src/reporter.ts +117 -39
- package/src/session-host-client.mjs +274 -0
- package/src/session-host.ts +632 -0
- package/src/transport.ts +34 -11
- package/src/types.ts +12 -9
package/src/reporter.ts
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
type
|
|
4
|
-
type
|
|
2
|
+
ODK_LINK_PROTOCOL,
|
|
3
|
+
type OdkLinkConfig,
|
|
4
|
+
type OdkLinkRecord,
|
|
5
5
|
type ReportedSession,
|
|
6
6
|
type SessionEvent,
|
|
7
7
|
type SessionStatus,
|
|
8
8
|
} from "./types.ts";
|
|
9
|
-
import { boundEventText, boundSessionEvent, retainEvents } from "./events.ts";
|
|
9
|
+
import { boundEventLines, boundEventText, boundSessionEvent, MAX_EVENT_LINES, retainEvents } from "./events.ts";
|
|
10
10
|
import { normalizeSessionId } from "./session-identity.ts";
|
|
11
11
|
import { type DeskTransport } from "./transport.ts";
|
|
12
12
|
|
|
13
13
|
export type LinkState = "offline" | "connecting" | "connected";
|
|
14
14
|
|
|
15
15
|
export interface DeskReporterOptions {
|
|
16
|
-
config:
|
|
17
|
-
createTransport: (config:
|
|
16
|
+
config: OdkLinkConfig;
|
|
17
|
+
createTransport: (config: OdkLinkConfig) => DeskTransport;
|
|
18
18
|
now?: () => number;
|
|
19
19
|
/** Injected so reconnect scheduling is asserted without waiting. */
|
|
20
20
|
schedule?: (run: () => void, delayMs: number) => (() => void) | void;
|
|
21
|
+
/** Authentication deadline, separate from capped reconnect scheduling. */
|
|
22
|
+
scheduleHandshake?: (run: () => void, delayMs: number) => (() => void) | void;
|
|
21
23
|
/** Called on every link-state change, so a surface can stop showing a stale one. */
|
|
22
|
-
onChange?: (snapshot:
|
|
24
|
+
onChange?: (snapshot: OdkLinkSnapshot) => void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export interface
|
|
27
|
+
export interface OdkLinkSnapshot {
|
|
26
28
|
link: LinkState;
|
|
27
29
|
machine: string;
|
|
28
30
|
sessions: number;
|
|
@@ -30,10 +32,17 @@ export interface DeskLinkSnapshot {
|
|
|
30
32
|
attempts: number;
|
|
31
33
|
omittedSessions: number;
|
|
32
34
|
lastError?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Present when this machine reports to more than one desk: each desk's own
|
|
37
|
+
* endpoint and link state, so a set that is only half up cannot be stated as
|
|
38
|
+
* a single healthy link.
|
|
39
|
+
*/
|
|
40
|
+
desks?: { endpoint: string; link: LinkState; lastError?: string }[];
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
const RECONNECT_BASE_MS = 1000;
|
|
36
44
|
const RECONNECT_MAX_MS = 30000;
|
|
45
|
+
const HANDSHAKE_TIMEOUT_MS = 10000;
|
|
37
46
|
const MAX_RECORD_BYTES = 65536;
|
|
38
47
|
const MAX_EVENTS_RECORD_BYTES = 1024 * 1024;
|
|
39
48
|
/** Sessions one machine may report at once. */
|
|
@@ -70,11 +79,12 @@ interface SessionEntry {
|
|
|
70
79
|
* An outage therefore costs freshness, never an unbounded backlog.
|
|
71
80
|
*/
|
|
72
81
|
export class DeskReporter {
|
|
73
|
-
readonly #config:
|
|
74
|
-
readonly #createTransport: (config:
|
|
82
|
+
readonly #config: OdkLinkConfig;
|
|
83
|
+
readonly #createTransport: (config: OdkLinkConfig) => DeskTransport;
|
|
75
84
|
readonly #now: () => number;
|
|
76
85
|
readonly #schedule: NonNullable<DeskReporterOptions["schedule"]>;
|
|
77
|
-
readonly #
|
|
86
|
+
readonly #scheduleHandshake: NonNullable<DeskReporterOptions["scheduleHandshake"]>;
|
|
87
|
+
readonly #onChange: ((snapshot: OdkLinkSnapshot) => void) | undefined;
|
|
78
88
|
readonly #sessions = new Map<string, SessionEntry>();
|
|
79
89
|
|
|
80
90
|
#transport: DeskTransport | null = null;
|
|
@@ -86,6 +96,7 @@ export class DeskReporter {
|
|
|
86
96
|
#active = false;
|
|
87
97
|
#generation = 0;
|
|
88
98
|
#cancelReconnect: (() => void) | void = undefined;
|
|
99
|
+
#cancelHandshake: (() => void) | void = undefined;
|
|
89
100
|
#currentSessionId = "";
|
|
90
101
|
|
|
91
102
|
constructor(options: DeskReporterOptions) {
|
|
@@ -98,6 +109,11 @@ export class DeskReporter {
|
|
|
98
109
|
timer.unref?.();
|
|
99
110
|
return () => clearTimeout(timer);
|
|
100
111
|
});
|
|
112
|
+
this.#scheduleHandshake = options.scheduleHandshake ?? ((run, delayMs) => {
|
|
113
|
+
const timer = setTimeout(run, delayMs);
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
return () => clearTimeout(timer);
|
|
116
|
+
});
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
start(): void {
|
|
@@ -128,7 +144,9 @@ export class DeskReporter {
|
|
|
128
144
|
this.#generation += 1;
|
|
129
145
|
this.#cancelReconnect?.();
|
|
130
146
|
this.#cancelReconnect = undefined;
|
|
131
|
-
|
|
147
|
+
this.#cancelHandshake?.();
|
|
148
|
+
this.#cancelHandshake = undefined;
|
|
149
|
+
if (this.#link === "connected") this.#send({ v: ODK_LINK_PROTOCOL, type: "bye", machine: this.#config.machine });
|
|
132
150
|
const transport = this.#transport;
|
|
133
151
|
this.#transport = null;
|
|
134
152
|
transport?.close();
|
|
@@ -231,6 +249,23 @@ export class DeskReporter {
|
|
|
231
249
|
entry.events = retainEvents([...entry.events, ...bounded]);
|
|
232
250
|
entry.pending = retainEvents([...entry.pending, ...bounded]);
|
|
233
251
|
entry.session = { ...entry.session, activity: boundEventText(bounded[bounded.length - 1]?.text) || entry.session.activity, updatedAt: this.#now() };
|
|
252
|
+
this.#sessionsDirty = true;
|
|
253
|
+
this.#flush();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What the session is doing right now, as the desk's card shows it. A running
|
|
258
|
+
* session executes several tools inside one turn, and until that turn ends its
|
|
259
|
+
* last message is still the prompt that started it: without this the card showed
|
|
260
|
+
* that prompt for the whole turn, which is what the desk owner reported.
|
|
261
|
+
*/
|
|
262
|
+
recordActivity(sessionId: string, text: string): void {
|
|
263
|
+
const entry = this.#sessions.get(sessionId);
|
|
264
|
+
if (entry === undefined) return;
|
|
265
|
+
const activity = boundEventText(text);
|
|
266
|
+
if (activity.length === 0 || activity === entry.session.activity) return;
|
|
267
|
+
entry.session = { ...entry.session, activity, updatedAt: this.#now() };
|
|
268
|
+
this.#sessionsDirty = true;
|
|
234
269
|
this.#flush();
|
|
235
270
|
}
|
|
236
271
|
|
|
@@ -246,7 +281,7 @@ export class DeskReporter {
|
|
|
246
281
|
return this.#sessions.get(sessionId)?.events ?? [];
|
|
247
282
|
}
|
|
248
283
|
|
|
249
|
-
snapshot():
|
|
284
|
+
snapshot(): OdkLinkSnapshot {
|
|
250
285
|
const record = this.#sessionRecord();
|
|
251
286
|
let events = 0;
|
|
252
287
|
for (const entry of this.#sessions.values()) events += entry.events.length;
|
|
@@ -269,41 +304,73 @@ export class DeskReporter {
|
|
|
269
304
|
if (!this.#active || this.#stopped || this.#transport !== null) return;
|
|
270
305
|
this.#link = "connecting";
|
|
271
306
|
this.#notify();
|
|
272
|
-
|
|
307
|
+
let transport: DeskTransport;
|
|
308
|
+
try {
|
|
309
|
+
transport = this.#createTransport(this.#config);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
this.#link = "offline";
|
|
312
|
+
this.#lastError = error instanceof Error ? error.message : "transport creation failed";
|
|
313
|
+
this.#notify();
|
|
314
|
+
this.#scheduleReconnect();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
273
317
|
this.#transport = transport;
|
|
318
|
+
let opened = false;
|
|
274
319
|
transport.onOpen(() => {
|
|
275
|
-
if (!this.#active || this.#transport !== transport) return;
|
|
276
|
-
|
|
277
|
-
this.#
|
|
278
|
-
this.#
|
|
279
|
-
this.#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
for (const entry of this.#sessions.values()) entry.pending = [...entry.events];
|
|
284
|
-
this.#sessionsDirty = true;
|
|
285
|
-
this.#flush();
|
|
320
|
+
if (!this.#active || this.#transport !== transport || opened) return;
|
|
321
|
+
opened = true;
|
|
322
|
+
this.#send({ v: ODK_LINK_PROTOCOL, type: "hello", machine: this.#config.machine, token: this.#config.token });
|
|
323
|
+
if (this.#transport !== transport || this.#link === "connected") return;
|
|
324
|
+
this.#cancelHandshake = this.#scheduleHandshake(() => {
|
|
325
|
+
if (this.#transport !== transport || this.#link !== "connecting") return;
|
|
326
|
+
this.#failTransport(transport, "reporting acknowledgement timeout");
|
|
327
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
286
328
|
});
|
|
287
329
|
transport.onClose((reason) => {
|
|
288
330
|
if (!this.#active || this.#transport !== transport) return;
|
|
289
|
-
this.#
|
|
290
|
-
this.#lastError = reason;
|
|
291
|
-
this.#notify();
|
|
292
|
-
if (this.#transport === transport) this.#transport = null;
|
|
293
|
-
this.#scheduleReconnect();
|
|
331
|
+
this.#failTransport(transport, reason);
|
|
294
332
|
});
|
|
295
333
|
transport.onRecord((record) => {
|
|
296
334
|
if (!this.#active || this.#transport !== transport) return;
|
|
297
335
|
// The service's ack is the only proof the link was accepted. Resetting the
|
|
298
336
|
// backoff on a bare TCP connect would make a refused token retry forever.
|
|
299
|
-
if (typeof record
|
|
337
|
+
if (typeof record !== "object" || record === null) return;
|
|
338
|
+
const reply = record as { v?: unknown; type?: unknown; reason?: unknown };
|
|
339
|
+
if (reply.v !== ODK_LINK_PROTOCOL) return;
|
|
340
|
+
if (reply.type === "error") {
|
|
341
|
+
this.#failTransport(transport, boundEventText(reply.reason) || "reporting link refused");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (reply.type === "ack" && opened && this.#link === "connecting") {
|
|
345
|
+
this.#cancelHandshake?.();
|
|
346
|
+
this.#cancelHandshake = undefined;
|
|
300
347
|
this.#attempts = 0;
|
|
348
|
+
this.#lastError = undefined;
|
|
349
|
+
this.#link = "connected";
|
|
350
|
+
this.#notify();
|
|
351
|
+
// A server restart forgets the machine's state: replay one retained tail.
|
|
352
|
+
// v1 has no IDs, so a surviving sibling link can retain duplicates.
|
|
353
|
+
for (const entry of this.#sessions.values()) entry.pending = [...entry.events];
|
|
354
|
+
this.#sessionsDirty = true;
|
|
355
|
+
this.#flush();
|
|
301
356
|
}
|
|
302
357
|
// v1 is report-only. A future `prompt` record is the reserved control hook
|
|
303
358
|
// and is deliberately not handled here.
|
|
304
359
|
});
|
|
305
360
|
}
|
|
306
361
|
|
|
362
|
+
#failTransport(transport: DeskTransport, reason: string): void {
|
|
363
|
+
if (this.#transport !== transport) return;
|
|
364
|
+
this.#cancelHandshake?.();
|
|
365
|
+
this.#cancelHandshake = undefined;
|
|
366
|
+
this.#transport = null;
|
|
367
|
+
this.#link = "offline";
|
|
368
|
+
this.#lastError = reason;
|
|
369
|
+
transport.close();
|
|
370
|
+
this.#notify();
|
|
371
|
+
this.#scheduleReconnect();
|
|
372
|
+
}
|
|
373
|
+
|
|
307
374
|
#scheduleReconnect(): void {
|
|
308
375
|
if (this.#stopped || !this.#active) return;
|
|
309
376
|
const generation = this.#generation;
|
|
@@ -316,9 +383,9 @@ export class DeskReporter {
|
|
|
316
383
|
}, delayMs);
|
|
317
384
|
}
|
|
318
385
|
|
|
319
|
-
#sessionRecord(): Extract<
|
|
320
|
-
const record: Extract<
|
|
321
|
-
v:
|
|
386
|
+
#sessionRecord(): Extract<OdkLinkRecord, { type: "sessions" }> {
|
|
387
|
+
const record: Extract<OdkLinkRecord, { type: "sessions" }> = {
|
|
388
|
+
v: ODK_LINK_PROTOCOL,
|
|
322
389
|
type: "sessions",
|
|
323
390
|
machine: this.#config.machine,
|
|
324
391
|
sessions: [...this.#sessions.values()].map((entry) => entry.session),
|
|
@@ -331,10 +398,15 @@ export class DeskReporter {
|
|
|
331
398
|
record.sessions = record.sessions.map((session) => {
|
|
332
399
|
if (!session.discovered) return session;
|
|
333
400
|
const short = (text: string): string => max === 0 ? "" : boundEventText(text, max);
|
|
401
|
+
// The prompt keeps its lines up to the card's own reading budget: a person
|
|
402
|
+
// who pastes several lines expects the desk to show the lines rather than
|
|
403
|
+
// the first one and nothing else. The activity stays the single short line
|
|
404
|
+
// it is for, because it answers what the session is doing right now.
|
|
405
|
+
const prompt = (text: string): string => max === 0 ? "" : boundEventLines(text, MAX_EVENT_LINES, max);
|
|
334
406
|
return {
|
|
335
407
|
...session,
|
|
336
408
|
...(session.name === undefined ? {} : { name: short(session.name) }),
|
|
337
|
-
...(session.latestGoal === undefined ? {} : { latestGoal:
|
|
409
|
+
...(session.latestGoal === undefined ? {} : { latestGoal: prompt(session.latestGoal) }),
|
|
338
410
|
...(session.activity === undefined ? {} : { activity: short(session.activity) }),
|
|
339
411
|
};
|
|
340
412
|
});
|
|
@@ -370,8 +442,8 @@ export class DeskReporter {
|
|
|
370
442
|
|
|
371
443
|
/** Unlike replacing sessions frames, append-only events may split between events. */
|
|
372
444
|
#sendEventBatches(sessionId: string, events: readonly SessionEvent[]): void {
|
|
373
|
-
const record: Extract<
|
|
374
|
-
v:
|
|
445
|
+
const record: Extract<OdkLinkRecord, { type: "events" }> = {
|
|
446
|
+
v: ODK_LINK_PROTOCOL,
|
|
375
447
|
type: "events",
|
|
376
448
|
machine: this.#config.machine,
|
|
377
449
|
sessionId,
|
|
@@ -400,7 +472,13 @@ export class DeskReporter {
|
|
|
400
472
|
if (record.events.length > 0 && this.#link === "connected") this.#send(record);
|
|
401
473
|
}
|
|
402
474
|
|
|
403
|
-
#send(record:
|
|
404
|
-
this.#transport
|
|
475
|
+
#send(record: OdkLinkRecord): void {
|
|
476
|
+
const transport = this.#transport;
|
|
477
|
+
if (transport === null) return;
|
|
478
|
+
try {
|
|
479
|
+
transport.send(record);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
this.#failTransport(transport, error instanceof Error ? error.message : "transport write failed");
|
|
482
|
+
}
|
|
405
483
|
}
|
|
406
484
|
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-shot session-control relay. A desk runs this on the far side of its own SSH session, so it
|
|
3
|
+
// reads one bounded JSON frame from stdin, writes it to the session's socket, prints the reply
|
|
4
|
+
// frame, and exits. It needs no build step, no configuration of its own, and no credential:
|
|
5
|
+
// ownership of the socket file is the whole gate.
|
|
6
|
+
//
|
|
7
|
+
// The frame contract is `integrations/personal-bot/src/task-protocol.mjs` in the Open DeskOS
|
|
8
|
+
// repository, spoken here rather than forked.
|
|
9
|
+
import { createConnection } from 'node:net'
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
11
|
+
import { realpath, stat } from 'node:fs/promises'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { isAbsolute, join, resolve as resolvePath, sep } from 'node:path'
|
|
14
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
15
|
+
|
|
16
|
+
const REQUEST_LIMIT = 64 * 1024
|
|
17
|
+
const RESPONSE_LIMIT = 256 * 1024
|
|
18
|
+
const REQUEST_TIMEOUT = 8000
|
|
19
|
+
const DEADLINE = REQUEST_TIMEOUT + 1000
|
|
20
|
+
const FAILURE = '会话控制通信失败,结果未知'
|
|
21
|
+
const USAGE = '用法: session-host-client.mjs <session-control socket path> | --find,stdin 上一个 JSON 请求帧'
|
|
22
|
+
// The protocol's own admission refusal, so a project no session serves is answered in
|
|
23
|
+
// the frame a desk already reads rather than as a client usage error.
|
|
24
|
+
const PROJECT_NOT_SERVED = '项目不在允许的开发目录内'
|
|
25
|
+
const TASK_NOT_FOUND = '未找到任务'
|
|
26
|
+
const NOT_A_SESSION_HOST = '此端点是当前 Pi 会话,不是 Hosted Pi 任务服务'
|
|
27
|
+
const ENDPOINT_LIMIT = 64
|
|
28
|
+
const ROOT_LIMIT = 16
|
|
29
|
+
const PROTOCOL_INVALID = '任务协议无效'
|
|
30
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
31
|
+
|
|
32
|
+
/** Fixed operator arguments only; SSH_ORIGINAL_COMMAND is never an input. */
|
|
33
|
+
async function relayOptions(args) {
|
|
34
|
+
const restricted = args.some(arg => arg.startsWith('--allow-root'))
|
|
35
|
+
if (!restricted) return { restricted: false, endpoint: args[0] ?? '--find', roots: [] }
|
|
36
|
+
const roots = []
|
|
37
|
+
const remaining = [...args]
|
|
38
|
+
const endpoint = remaining[0] === '--allow-root' ? '--find' : remaining.shift()
|
|
39
|
+
try {
|
|
40
|
+
while (remaining.length) {
|
|
41
|
+
if (remaining.shift() !== '--allow-root' || roots.length >= ROOT_LIMIT) throw Error(PROTOCOL_INVALID)
|
|
42
|
+
const root = remaining.shift()
|
|
43
|
+
if (typeof root !== 'string' || !isAbsolute(root) || /[\x00-\x1f\x7f]/.test(root)) throw Error(PROTOCOL_INVALID)
|
|
44
|
+
const canonical = await realpath(root)
|
|
45
|
+
if (!(await stat(canonical)).isDirectory()) throw Error(PROTOCOL_INVALID)
|
|
46
|
+
roots.push(canonical)
|
|
47
|
+
}
|
|
48
|
+
if (endpoint !== '--find' && (typeof endpoint !== 'string' || !isAbsolute(endpoint) || /[\x00-\x1f\x7f]/.test(endpoint))) throw Error(PROTOCOL_INVALID)
|
|
49
|
+
return { restricted: true, endpoint, roots }
|
|
50
|
+
} catch { return { restricted: true, endpoint, roots: [], invalid: true } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Admit one protocol request before discovery or socket contact. */
|
|
54
|
+
async function restrictedRefusal(request, options) {
|
|
55
|
+
if (options.invalid || !request || typeof request !== 'object' || Array.isArray(request)
|
|
56
|
+
|| request.version !== 1 || typeof request.requestId !== 'string' || !request.requestId.length || request.requestId.length > 128
|
|
57
|
+
|| !['list', 'status', 'prompt', 'cancel'].includes(request.command)) return PROTOCOL_INVALID
|
|
58
|
+
const fields = ['version', 'requestId', 'command', 'project', ...(request.command === 'list' ? [] : ['taskId']),
|
|
59
|
+
...(['prompt', 'cancel'].includes(request.command) ? ['mutationId'] : []), ...(request.command === 'prompt' ? ['prompt', 'streamingBehavior'] : [])]
|
|
60
|
+
if (Object.keys(request).some(key => !fields.includes(key))) return PROTOCOL_INVALID
|
|
61
|
+
if (request.command !== 'list' && (typeof request.taskId !== 'string' || !UUID.test(request.taskId))) return PROTOCOL_INVALID
|
|
62
|
+
if (request.mutationId !== undefined && (typeof request.mutationId !== 'string' || !UUID.test(request.mutationId))) return PROTOCOL_INVALID
|
|
63
|
+
if (request.command === 'prompt' && (typeof request.prompt !== 'string' || !request.prompt.trim() || request.prompt.length > 16_384
|
|
64
|
+
|| Buffer.from(request.prompt).toString('utf8') !== request.prompt || (request.streamingBehavior !== undefined && !['steer', 'followUp'].includes(request.streamingBehavior)))) return PROTOCOL_INVALID
|
|
65
|
+
if (typeof request.project !== 'string' || !isAbsolute(request.project) || /[\x00-\x1f\x7f]/.test(request.project)) return PROJECT_NOT_SERVED
|
|
66
|
+
try {
|
|
67
|
+
const project = await realpath(request.project)
|
|
68
|
+
if (!(await stat(project)).isDirectory() || !options.roots.some(root => admits(root, project))) return PROJECT_NOT_SERVED
|
|
69
|
+
} catch { return PROJECT_NOT_SERVED }
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function refusal(request, error) {
|
|
74
|
+
const requestId = typeof request?.requestId === 'string' && request.requestId.length <= 128 ? request.requestId : ''
|
|
75
|
+
return { version: 1, requestId, ok: false, error }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read exactly one bounded JSON line, as the protocol's own one-shot client does. */
|
|
79
|
+
function readFrame(stream) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const decoder = new StringDecoder('utf8')
|
|
82
|
+
let pending = ''
|
|
83
|
+
const cleanup = () => { stream.off('data', onData); stream.off('end', onEnd); stream.off('close', onEnd); stream.off('error', onError) }
|
|
84
|
+
const fail = () => { cleanup(); reject(new Error(FAILURE)) }
|
|
85
|
+
const onEnd = () => fail()
|
|
86
|
+
const onError = () => fail()
|
|
87
|
+
const onData = chunk => {
|
|
88
|
+
pending += decoder.write(chunk)
|
|
89
|
+
if (Buffer.byteLength(pending) > REQUEST_LIMIT) return fail()
|
|
90
|
+
const newline = pending.indexOf('\n')
|
|
91
|
+
if (newline < 0) return
|
|
92
|
+
if (pending.slice(newline + 1).trim()) return fail()
|
|
93
|
+
const line = pending.slice(0, newline)
|
|
94
|
+
let request
|
|
95
|
+
try { request = JSON.parse(line) } catch { return fail() }
|
|
96
|
+
cleanup()
|
|
97
|
+
resolve(request)
|
|
98
|
+
}
|
|
99
|
+
stream.on('data', onData)
|
|
100
|
+
stream.once('end', onEnd)
|
|
101
|
+
stream.once('close', onEnd)
|
|
102
|
+
stream.once('error', onError)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Connect, write the one frame, and read the one frame back. */
|
|
107
|
+
function exchange(socketPath, request) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const frame = `${JSON.stringify(request)}\n`
|
|
110
|
+
const socket = createConnection(socketPath)
|
|
111
|
+
const decoder = new StringDecoder('utf8')
|
|
112
|
+
let pending = ''
|
|
113
|
+
const cleanup = () => { clearTimeout(timer); socket.off('data', onData); socket.off('end', onEnd); socket.off('close', onEnd); socket.off('error', onError) }
|
|
114
|
+
const fail = () => { cleanup(); socket.destroy(); reject(new Error(FAILURE)) }
|
|
115
|
+
const onEnd = () => fail()
|
|
116
|
+
const onError = () => fail()
|
|
117
|
+
const onData = chunk => {
|
|
118
|
+
pending += decoder.write(chunk)
|
|
119
|
+
if (Buffer.byteLength(pending) > RESPONSE_LIMIT) return fail()
|
|
120
|
+
const newline = pending.indexOf('\n')
|
|
121
|
+
if (newline < 0) return
|
|
122
|
+
cleanup()
|
|
123
|
+
socket.destroy()
|
|
124
|
+
resolve(pending.slice(0, newline))
|
|
125
|
+
}
|
|
126
|
+
const timer = setTimeout(fail, REQUEST_TIMEOUT)
|
|
127
|
+
socket.on('data', onData)
|
|
128
|
+
socket.once('end', onEnd)
|
|
129
|
+
socket.once('close', onEnd)
|
|
130
|
+
socket.once('error', onError)
|
|
131
|
+
socket.once('connect', () => socket.write(frame))
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The directory this machine's sessions publish into, by the same rule the host binds with. */
|
|
136
|
+
function sessionsDir() {
|
|
137
|
+
const override = process.env.ODK_SESSION_HOST_SOCKET ?? ''
|
|
138
|
+
if (isAbsolute(override)) return override
|
|
139
|
+
const runtimeDir = process.env.XDG_RUNTIME_DIR
|
|
140
|
+
if (typeof runtimeDir === 'string' && runtimeDir.length > 0 && isAbsolute(runtimeDir)) return join(runtimeDir, 'odk', 'sessions')
|
|
141
|
+
return process.platform === 'win32'
|
|
142
|
+
? join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'odk', 'sessions')
|
|
143
|
+
: join(homedir(), '.local', 'run', 'odk', 'sessions')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True when a requested project is a session's own project or lies inside it. */
|
|
147
|
+
function admits(declared, requested) {
|
|
148
|
+
if (typeof declared !== 'string' || declared.length === 0) return false
|
|
149
|
+
if (typeof requested !== 'string' || requested.length === 0) return false
|
|
150
|
+
const root = resolvePath(declared)
|
|
151
|
+
const target = resolvePath(requested)
|
|
152
|
+
return target === root || target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Which of this machine's sessions answers this frame. Every session publishes its own
|
|
157
|
+
* project and its own socket, so the choice follows what each one declares; a project no
|
|
158
|
+
* session serves has no answer, which the desk reads as the host's own project refusal
|
|
159
|
+
* rather than as some other session's reply.
|
|
160
|
+
*/
|
|
161
|
+
async function findSessions(request) {
|
|
162
|
+
const project = typeof request?.project === 'string' ? request.project : ''
|
|
163
|
+
if (!isAbsolute(project) || /[\x00-\x1f\x7f]/.test(project)) return []
|
|
164
|
+
let scope
|
|
165
|
+
try { scope = await realpath(project) } catch { return [] }
|
|
166
|
+
let names
|
|
167
|
+
try {
|
|
168
|
+
names = readdirSync(sessionsDir()).filter((name) => name.endsWith('.json')).sort()
|
|
169
|
+
} catch {
|
|
170
|
+
return []
|
|
171
|
+
}
|
|
172
|
+
const sessions = []
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
let descriptor
|
|
175
|
+
try {
|
|
176
|
+
const file = join(sessionsDir(), name)
|
|
177
|
+
if (statSync(file).size > REQUEST_LIMIT) continue
|
|
178
|
+
descriptor = JSON.parse(readFileSync(file, 'utf8'))
|
|
179
|
+
} catch {
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (descriptor?.version !== 1) continue
|
|
183
|
+
if (request.command !== 'list' && descriptor.sessionId !== request.taskId) continue
|
|
184
|
+
if (typeof descriptor.socketPath !== 'string' || !isAbsolute(descriptor.socketPath)) continue
|
|
185
|
+
if (request.command === 'list') {
|
|
186
|
+
let declared
|
|
187
|
+
try { declared = await realpath(descriptor.project) } catch { continue }
|
|
188
|
+
if (!admits(scope, declared)) continue
|
|
189
|
+
} else if (descriptor.project !== project) continue
|
|
190
|
+
sessions.push(descriptor)
|
|
191
|
+
}
|
|
192
|
+
return sessions
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function discoveredResponse(request) {
|
|
196
|
+
if (['start', 'launch', 'end', 'history'].includes(request.command)) {
|
|
197
|
+
return { version: 1, requestId: request.requestId, ok: false, error: NOT_A_SESSION_HOST }
|
|
198
|
+
}
|
|
199
|
+
if (request.command !== 'list' && typeof request.taskId !== 'string') {
|
|
200
|
+
return { version: 1, requestId: request.requestId, ok: false, error: TASK_NOT_FOUND }
|
|
201
|
+
}
|
|
202
|
+
const descriptors = await findSessions(request)
|
|
203
|
+
if (!descriptors.length) return { version: 1, requestId: request.requestId, ok: false, error: request.command === 'list' ? PROJECT_NOT_SERVED : TASK_NOT_FOUND }
|
|
204
|
+
if (request.command !== 'list') {
|
|
205
|
+
if (descriptors.length !== 1) return { version: 1, requestId: request.requestId, ok: false, error: TASK_NOT_FOUND }
|
|
206
|
+
return JSON.parse(await exchange(descriptors[0].socketPath, request))
|
|
207
|
+
}
|
|
208
|
+
const frames = await Promise.all(descriptors.slice(0, ENDPOINT_LIMIT).map(async descriptor => {
|
|
209
|
+
try {
|
|
210
|
+
const frame = JSON.parse(await exchange(descriptor.socketPath, { ...request, project: descriptor.project }))
|
|
211
|
+
if (frame?.version !== 1 || frame.requestId !== request.requestId || frame.ok !== true || !Array.isArray(frame.tasks)) return { tasks: [], unavailable: true }
|
|
212
|
+
const tasks = frame.tasks.filter(task => task?.taskId === descriptor.sessionId && task.project === descriptor.project)
|
|
213
|
+
return { tasks, unavailable: tasks.length === 0, incomplete: frame.truncated === true || frame.incomplete === true }
|
|
214
|
+
} catch { return { tasks: [], unavailable: true } }
|
|
215
|
+
}))
|
|
216
|
+
const tasks = []
|
|
217
|
+
const unavailable = frames.filter(frame => frame.unavailable).length
|
|
218
|
+
let truncated = descriptors.length > ENDPOINT_LIMIT
|
|
219
|
+
for (const task of frames.flatMap(frame => frame.tasks)) {
|
|
220
|
+
// false is one byte longer than true. Budget both longest metadata values,
|
|
221
|
+
// including the newline, so a complete inventory also fits the wire bound.
|
|
222
|
+
if (Buffer.byteLength(JSON.stringify({ version: 1, requestId: request.requestId, ok: true, tasks: [...tasks, task], truncated: false, unavailable, incomplete: false })) + 1 > RESPONSE_LIMIT) { truncated = true; break }
|
|
223
|
+
tasks.push(task)
|
|
224
|
+
}
|
|
225
|
+
return { version: 1, requestId: request.requestId, ok: true, tasks, truncated, unavailable, incomplete: truncated || unavailable > 0 || frames.some(frame => frame.incomplete) }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let options = { restricted: process.argv.slice(2).some(arg => arg.startsWith('--allow-root')) }
|
|
229
|
+
let admitted = false
|
|
230
|
+
const deadline = setTimeout(() => {
|
|
231
|
+
process.stderr.write(`${FAILURE}\n`)
|
|
232
|
+
process.exit(1)
|
|
233
|
+
}, DEADLINE)
|
|
234
|
+
try {
|
|
235
|
+
options = await relayOptions(process.argv.slice(2))
|
|
236
|
+
const request = await readFrame(process.stdin)
|
|
237
|
+
process.stdin.pause()
|
|
238
|
+
const rejected = options.restricted ? await restrictedRefusal(request, options) : undefined
|
|
239
|
+
if (rejected) {
|
|
240
|
+
process.stdout.write(`${JSON.stringify(refusal(request, rejected))}\n`)
|
|
241
|
+
} else {
|
|
242
|
+
admitted = true
|
|
243
|
+
// A desk declares one executable and sends one frame, so finding the session is this
|
|
244
|
+
// client's work: --find asks each session's own descriptor which one owns the requested
|
|
245
|
+
// project and relays the frame to that session. A socket path named directly still
|
|
246
|
+
// addresses exactly one session.
|
|
247
|
+
// A desk declares one executable and sends one frame, so with no socket named the
|
|
248
|
+
// client finds the session that owns the frame's project by itself.
|
|
249
|
+
const first = options.endpoint
|
|
250
|
+
const finding = first === '--find'
|
|
251
|
+
const socketPath = first
|
|
252
|
+
if (!finding && (typeof socketPath !== 'string' || !socketPath.length || !isAbsolute(socketPath) || /[\x00-\x1f\x7f]/.test(socketPath))) {
|
|
253
|
+
process.stderr.write(`${USAGE}\n`)
|
|
254
|
+
process.exitCode = 2
|
|
255
|
+
} else {
|
|
256
|
+
const response = finding ? JSON.stringify(await discoveredResponse(request)) : await exchange(socketPath, request)
|
|
257
|
+
// A frame that is not this request's answer is not an answer: printing it would let a desk
|
|
258
|
+
// read someone else's reply as its own.
|
|
259
|
+
if (response === '' || Buffer.byteLength(response) > RESPONSE_LIMIT) throw new Error(FAILURE)
|
|
260
|
+
const answer = JSON.parse(response)
|
|
261
|
+
if (answer?.version !== 1 || answer.requestId !== request?.requestId || typeof answer.ok !== 'boolean') throw new Error(FAILURE)
|
|
262
|
+
process.stdout.write(`${response}\n`)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
if (options.restricted && !admitted) process.stdout.write(`${JSON.stringify(refusal(undefined, PROTOCOL_INVALID))}\n`)
|
|
267
|
+
else {
|
|
268
|
+
process.stderr.write(`${FAILURE}\n`)
|
|
269
|
+
process.exitCode = 1
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(deadline)
|
|
273
|
+
process.stdin.destroy()
|
|
274
|
+
}
|