pi-open-deskos 0.2.1 → 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 +88 -19
- package/README.zh-CN.md +76 -19
- 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 +77 -19
- package/src/index.ts +172 -29
- 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/session-replay.ts +38 -0
- package/src/transport.ts +34 -11
- package/src/types.ts +18 -9
package/src/config.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
2
4
|
import os from "node:os";
|
|
3
|
-
import
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { type OdkLinkConfig } from "./types.ts";
|
|
4
7
|
|
|
5
8
|
/** A reporting machine identity stable across restarts of the same machine. */
|
|
6
9
|
export function defaultMachineName(hostname = os.hostname()): string {
|
|
@@ -9,28 +12,41 @@ export function defaultMachineName(hostname = os.hostname()): string {
|
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
function readPort(raw: string): number | null {
|
|
12
|
-
|
|
15
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
16
|
+
const port = Number(raw);
|
|
13
17
|
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
function readEndpoint(address: string): { host: string; port: number } | null {
|
|
21
|
+
const separator = address.lastIndexOf(":");
|
|
22
|
+
if (separator <= 0) return null;
|
|
23
|
+
let host = address.slice(0, separator).trim();
|
|
24
|
+
const port = readPort(address.slice(separator + 1).trim());
|
|
25
|
+
if (host.startsWith("[")) {
|
|
26
|
+
if (!host.endsWith("]")) return null;
|
|
27
|
+
host = host.slice(1, -1);
|
|
28
|
+
if (isIP(host) !== 6) return null;
|
|
29
|
+
} else if (host.includes(":")) {
|
|
30
|
+
if (isIP(host) !== 6) return null;
|
|
31
|
+
}
|
|
32
|
+
if (!host || /[\s/\\[\]?#@]/.test(host) || port === null) return null;
|
|
33
|
+
return { host, port };
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
/**
|
|
17
|
-
* Read the
|
|
37
|
+
* Read the Open DeskOS Link configuration. An unconfigured machine reports nothing:
|
|
18
38
|
* a missing address or token is silent, never a partially configured link.
|
|
19
39
|
*/
|
|
20
|
-
export function
|
|
21
|
-
const address = (env.
|
|
22
|
-
const token = (env.
|
|
40
|
+
export function readOdkLinkConfig(env: NodeJS.ProcessEnv = process.env): OdkLinkConfig | null {
|
|
41
|
+
const address = (env.ODK_LINK_ADDRESS ?? "").trim();
|
|
42
|
+
const token = (env.ODK_LINK_TOKEN ?? "").trim();
|
|
23
43
|
if (address.length === 0 || token.length === 0) return null;
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
const
|
|
27
|
-
const port = readPort(address.slice(separator + 1).trim());
|
|
28
|
-
if (host.length === 0 || port === null) return null;
|
|
29
|
-
const controlToken = (env.ODK_DESK_LINK_CONTROL_TOKEN ?? "").trim();
|
|
44
|
+
const endpoint = readEndpoint(address);
|
|
45
|
+
if (endpoint === null) return null;
|
|
46
|
+
const controlToken = (env.ODK_LINK_CONTROL_TOKEN ?? "").trim();
|
|
30
47
|
return {
|
|
31
|
-
machine: (env.
|
|
32
|
-
|
|
33
|
-
port,
|
|
48
|
+
machine: (env.ODK_LINK_MACHINE ?? "").trim() || defaultMachineName(),
|
|
49
|
+
...endpoint,
|
|
34
50
|
token,
|
|
35
51
|
...(controlToken.length > 0 ? { controlToken } : {}),
|
|
36
52
|
};
|
|
@@ -40,3 +56,107 @@ export function readDeskLinkConfig(env: NodeJS.ProcessEnv = process.env): DeskLi
|
|
|
40
56
|
export function tokenDigest(token: string): string {
|
|
41
57
|
return createHash("sha256").update(token).digest("hex").slice(0, 8);
|
|
42
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Where a machine that reports to more than one desk states them. */
|
|
61
|
+
export const DEFAULT_DESKS_FILE = path.join(os.homedir(), ".config", "odk", "desks.json");
|
|
62
|
+
|
|
63
|
+
export interface OdkLinkConfigSet {
|
|
64
|
+
configs: OdkLinkConfig[];
|
|
65
|
+
/** Entries a desks file stated that this machine will not use, with the reason. */
|
|
66
|
+
refusals: string[];
|
|
67
|
+
source: "desks-file" | "environment" | "none";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One entry of a desks file, or the reason it is not usable. */
|
|
71
|
+
function configFromEntry(entry: unknown, machine: string): OdkLinkConfig | string {
|
|
72
|
+
if (entry === null || typeof entry !== "object") return "an entry is not an object";
|
|
73
|
+
const record = entry as Record<string, unknown>;
|
|
74
|
+
const address = typeof record.address === "string" ? record.address.trim() : "";
|
|
75
|
+
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
76
|
+
if (address.length === 0) return "an entry states no address";
|
|
77
|
+
if (token.length === 0) return `the entry for ${address} states no token`;
|
|
78
|
+
const endpoint = readEndpoint(address);
|
|
79
|
+
if (endpoint === null) return `${address} is not host:port`;
|
|
80
|
+
const controlToken = typeof record.controlToken === "string" ? record.controlToken.trim() : "";
|
|
81
|
+
const entryMachine = typeof record.machine === "string" && record.machine.trim().length > 0 ? record.machine.trim() : machine;
|
|
82
|
+
return {
|
|
83
|
+
machine: entryMachine,
|
|
84
|
+
...endpoint,
|
|
85
|
+
token,
|
|
86
|
+
...(controlToken.length > 0 ? { controlToken } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function entriesFrom(text: string): unknown[] | null {
|
|
91
|
+
let parsed: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(parsed)) return parsed;
|
|
98
|
+
const stated = (parsed as { desks?: unknown } | null)?.desks;
|
|
99
|
+
return Array.isArray(stated) ? stated : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every desk this machine reports to.
|
|
104
|
+
*
|
|
105
|
+
* One disk file is the source of truth when there is one, because a machine that
|
|
106
|
+
* reports to several desks needs a token per desk and an environment variable
|
|
107
|
+
* cannot state that unambiguously. The single-desk environment form keeps
|
|
108
|
+
* working for a machine that reports to one. An operator who names a file and
|
|
109
|
+
* then has it not used is told, because silence there would look like a link
|
|
110
|
+
* that simply never connects.
|
|
111
|
+
*/
|
|
112
|
+
export function readOdkLinkConfigs(
|
|
113
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
114
|
+
options: { readFile?: (file: string) => string } = {},
|
|
115
|
+
): OdkLinkConfigSet {
|
|
116
|
+
const readFile = options.readFile ?? ((file: string) => readFileSync(file, "utf8"));
|
|
117
|
+
const explicit = (env.ODK_LINK_DESKS_FILE ?? "").trim();
|
|
118
|
+
const machine = (env.ODK_LINK_MACHINE ?? "").trim() || defaultMachineName();
|
|
119
|
+
const file = explicit.length > 0 ? explicit : DEFAULT_DESKS_FILE;
|
|
120
|
+
const refusals: string[] = [];
|
|
121
|
+
|
|
122
|
+
let text: string | null = null;
|
|
123
|
+
try {
|
|
124
|
+
text = readFile(file);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const code = (error as { code?: string } | null)?.code;
|
|
127
|
+
if (code !== "ENOENT" || explicit.length > 0) {
|
|
128
|
+
refusals.push(`refusing ${file}: it could not be read${code === undefined ? "" : ` (${code})`}`);
|
|
129
|
+
return { configs: [], refusals, source: "desks-file" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (text !== null) {
|
|
134
|
+
const entries = entriesFrom(text);
|
|
135
|
+
if (entries === null) {
|
|
136
|
+
refusals.push(`refusing ${file}: it states no list of desks`);
|
|
137
|
+
return { configs: [], refusals, source: "desks-file" };
|
|
138
|
+
}
|
|
139
|
+
const configs: OdkLinkConfig[] = [];
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const result = configFromEntry(entry, machine);
|
|
143
|
+
if (typeof result === "string") {
|
|
144
|
+
refusals.push(`refusing ${file}: ${result}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const endpoint = `${result.host}:${result.port}`;
|
|
148
|
+
if (seen.has(endpoint)) {
|
|
149
|
+
refusals.push(`${endpoint} is listed more than once in ${file}; the first entry is used`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
seen.add(endpoint);
|
|
153
|
+
configs.push(result);
|
|
154
|
+
}
|
|
155
|
+
return { configs, refusals, source: "desks-file" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const single = readOdkLinkConfig(env);
|
|
159
|
+
return single === null
|
|
160
|
+
? { configs: [], refusals, source: "none" }
|
|
161
|
+
: { configs: [single], refusals, source: "environment" };
|
|
162
|
+
}
|
package/src/console-client.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
2
|
+
import { ODK_LINK_CONTROL_PROTOCOL, type ControlTransport } from "./control-transport.ts";
|
|
3
|
+
import type { OdkLinkConfig, HistoryPage, HostedPiSession, PositionedSessionEvent } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const CONTROL_TIMEOUT_MS = 5000;
|
|
6
|
-
const CONTROL_DOMAIN = "
|
|
6
|
+
const CONTROL_DOMAIN = "odk-control-v2";
|
|
7
7
|
const MAX_ATTACH_BUFFER_EVENTS = 2048;
|
|
8
8
|
const MAX_ATTACH_BUFFER_BYTES = 1024 * 1024;
|
|
9
9
|
|
|
10
10
|
interface ConsoleClientOptions {
|
|
11
|
-
config:
|
|
11
|
+
config: OdkLinkConfig;
|
|
12
12
|
consoleSessionId: string;
|
|
13
|
-
createTransport: (config:
|
|
13
|
+
createTransport: (config: OdkLinkConfig) => ControlTransport;
|
|
14
14
|
timeoutMs?: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -47,8 +47,8 @@ function reasonFrom(record: Record<string, unknown>, fallback = "control request
|
|
|
47
47
|
|
|
48
48
|
function versionError(record: Record<string, unknown>): Error {
|
|
49
49
|
const received = String(record.received ?? record.v ?? "unknown");
|
|
50
|
-
const accepted = Array.isArray(record.accepted) ? record.accepted.join(", ") : String(
|
|
51
|
-
return new Error(`
|
|
50
|
+
const accepted = Array.isArray(record.accepted) ? record.accepted.join(", ") : String(ODK_LINK_CONTROL_PROTOCOL);
|
|
51
|
+
return new Error(`Open DeskOS Link control protocol version ${received} refused; accepted ${accepted}`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function unwrapReply(record: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -57,7 +57,7 @@ function unwrapReply(record: Record<string, unknown>): Record<string, unknown> {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function controlTranscript(machine: string, sessionId: string, nonce: string): string {
|
|
60
|
-
return `${CONTROL_DOMAIN}\n${
|
|
60
|
+
return `${CONTROL_DOMAIN}\n${ODK_LINK_CONTROL_PROTOCOL}\n${nonce}\n${machine}\n${sessionId}`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function createControlProof(controlCredential: string, machine: string, sessionId: string, nonce: string): string {
|
|
@@ -86,15 +86,17 @@ class ControlConnection {
|
|
|
86
86
|
readonly #transport: ControlTransport;
|
|
87
87
|
readonly #pending = new Map<string, PendingRequest>();
|
|
88
88
|
readonly #onUnsolicited: (record: Record<string, unknown>) => void;
|
|
89
|
+
readonly #onClosed: (error: Error) => void;
|
|
89
90
|
#ready: Promise<void>;
|
|
90
91
|
#readyResolve!: () => void;
|
|
91
92
|
#readyReject!: (error: Error) => void;
|
|
92
93
|
#closed = false;
|
|
93
94
|
#transportClosed = false;
|
|
94
95
|
|
|
95
|
-
constructor(options: ConsoleClientOptions, onUnsolicited: (record: Record<string, unknown>) => void = () => {}) {
|
|
96
|
+
constructor(options: ConsoleClientOptions, onUnsolicited: (record: Record<string, unknown>) => void = () => {}, onClosed: (error: Error) => void = () => {}) {
|
|
96
97
|
this.#options = options;
|
|
97
98
|
this.#onUnsolicited = onUnsolicited;
|
|
99
|
+
this.#onClosed = onClosed;
|
|
98
100
|
this.#ready = new Promise<void>((resolve, reject) => {
|
|
99
101
|
this.#readyResolve = resolve;
|
|
100
102
|
this.#readyReject = reject;
|
|
@@ -104,7 +106,7 @@ class ControlConnection {
|
|
|
104
106
|
readyTimer.unref?.();
|
|
105
107
|
this.#ready.then(() => clearTimeout(readyTimer), () => clearTimeout(readyTimer));
|
|
106
108
|
this.#transport.onOpen(() => this.#transport.send({
|
|
107
|
-
v:
|
|
109
|
+
v: ODK_LINK_CONTROL_PROTOCOL,
|
|
108
110
|
type: "control-hello",
|
|
109
111
|
token: options.config.token,
|
|
110
112
|
machine: options.config.machine,
|
|
@@ -129,7 +131,16 @@ class ControlConnection {
|
|
|
129
131
|
timer.unref?.();
|
|
130
132
|
this.#pending.set(requestId, { resolve, reject, timer, mutationId, sessionId, project });
|
|
131
133
|
});
|
|
132
|
-
|
|
134
|
+
try {
|
|
135
|
+
this.#transport.send({ ...fields, v: ODK_LINK_CONTROL_PROTOCOL, type, requestId });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const pending = this.#pending.get(requestId);
|
|
138
|
+
if (pending) {
|
|
139
|
+
clearTimeout(pending.timer);
|
|
140
|
+
this.#pending.delete(requestId);
|
|
141
|
+
pending.reject(new ControlRequestError(error instanceof Error ? error.message : "control write failed", { requestId, mutationId, sessionId, project }));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
133
144
|
return response;
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -138,9 +149,10 @@ class ControlConnection {
|
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
#receive(value: unknown): void {
|
|
152
|
+
if (this.#closed) return;
|
|
141
153
|
const record = asRecord(value);
|
|
142
154
|
if (!record) return;
|
|
143
|
-
if (record.type === "version-mismatch" || (record.v !== undefined && record.v !==
|
|
155
|
+
if (record.type === "version-mismatch" || (record.v !== undefined && record.v !== ODK_LINK_CONTROL_PROTOCOL)) {
|
|
144
156
|
this.#fail(versionError(record), true);
|
|
145
157
|
return;
|
|
146
158
|
}
|
|
@@ -149,7 +161,7 @@ class ControlConnection {
|
|
|
149
161
|
const algorithm = record.algorithm;
|
|
150
162
|
const credential = this.#options.config.controlToken ?? "";
|
|
151
163
|
if (!nonce || (algorithm !== undefined && algorithm !== "hmac-sha256")) {
|
|
152
|
-
this.#fail(new Error("invalid
|
|
164
|
+
this.#fail(new Error("invalid Open DeskOS Link control challenge"), true);
|
|
153
165
|
return;
|
|
154
166
|
}
|
|
155
167
|
if (!credential) {
|
|
@@ -157,7 +169,7 @@ class ControlConnection {
|
|
|
157
169
|
return;
|
|
158
170
|
}
|
|
159
171
|
this.#transport.send({
|
|
160
|
-
v:
|
|
172
|
+
v: ODK_LINK_CONTROL_PROTOCOL,
|
|
161
173
|
type: "control-proof",
|
|
162
174
|
proof: createControlProof(credential, this.#options.config.machine, this.#options.consoleSessionId, nonce),
|
|
163
175
|
});
|
|
@@ -196,6 +208,7 @@ class ControlConnection {
|
|
|
196
208
|
pending.reject(new ControlRequestError(error.message, { requestId, mutationId: pending.mutationId, sessionId: pending.sessionId, project: pending.project }));
|
|
197
209
|
}
|
|
198
210
|
this.#pending.clear();
|
|
211
|
+
this.#onClosed(error);
|
|
199
212
|
}
|
|
200
213
|
if (closeTransport && !this.#transportClosed) {
|
|
201
214
|
this.#transportClosed = true;
|
|
@@ -219,6 +232,7 @@ export interface ConsoleAttachment {
|
|
|
219
232
|
export class DeskConsoleClient {
|
|
220
233
|
readonly #options: ConsoleClientOptions;
|
|
221
234
|
#attachment: ConsoleAttachment | null = null;
|
|
235
|
+
#attaching: ControlConnection | null = null;
|
|
222
236
|
|
|
223
237
|
constructor(options: ConsoleClientOptions) {
|
|
224
238
|
this.#options = options;
|
|
@@ -246,7 +260,7 @@ export class DeskConsoleClient {
|
|
|
246
260
|
}
|
|
247
261
|
|
|
248
262
|
async attach(options: AttachOptions): Promise<ConsoleAttachment> {
|
|
249
|
-
this
|
|
263
|
+
this.closeAttachment();
|
|
250
264
|
const attachmentId = randomUUID();
|
|
251
265
|
let lastApplied = options.position ?? 0;
|
|
252
266
|
let streamError: string | undefined;
|
|
@@ -314,12 +328,22 @@ export class DeskConsoleClient {
|
|
|
314
328
|
...(typeof record.response === "string" ? { response: record.response } : {}),
|
|
315
329
|
});
|
|
316
330
|
}
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
331
|
+
}, (error) => { streamError ??= error.message; });
|
|
332
|
+
this.#attaching = connection;
|
|
333
|
+
let attached: Record<string, unknown>;
|
|
334
|
+
try {
|
|
335
|
+
attached = await connection.request("attach", {
|
|
336
|
+
sessionId: options.sessionId,
|
|
337
|
+
attachmentId,
|
|
338
|
+
after: options.position ?? null,
|
|
339
|
+
});
|
|
340
|
+
if (this.#attaching !== connection) throw new Error("control link closed");
|
|
341
|
+
} catch (error) {
|
|
342
|
+
connection.close();
|
|
343
|
+
throw error;
|
|
344
|
+
} finally {
|
|
345
|
+
if (this.#attaching === connection) this.#attaching = null;
|
|
346
|
+
}
|
|
323
347
|
acknowledged = true;
|
|
324
348
|
drainQueued();
|
|
325
349
|
const boundary = asPosition(attached.boundary);
|
|
@@ -363,6 +387,8 @@ export class DeskConsoleClient {
|
|
|
363
387
|
|
|
364
388
|
closeAttachment(): void {
|
|
365
389
|
this.#attachment?.close();
|
|
390
|
+
this.#attaching?.close();
|
|
391
|
+
this.#attaching = null;
|
|
366
392
|
}
|
|
367
393
|
|
|
368
394
|
async #oneShot(type: string, fields: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|