@dreamteamer/web 0.1.0 → 0.3.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 +202 -112
- package/UPDATING.md +45 -0
- package/bin/dt-web-hook.mjs +21 -0
- package/bin/dt-web.mjs +2 -1
- package/commands/web/show.command.md +6 -0
- package/package.json +10 -7
- package/skills/serving-the-web-ui/SKILL.md +67 -40
- package/src/cli.js +378 -39
- package/src/dispatch.js +16 -0
- package/src/extension.js +34 -8
- package/src/handoff.js +67 -0
- package/src/headers.js +15 -9
- package/src/hook.js +150 -0
- package/src/module-frames.js +3 -2
- package/src/pairing.js +28 -0
- package/src/pane-store.js +116 -0
- package/src/runtime.js +177 -15
- package/src/server.js +497 -170
- package/src/sessions.js +37 -0
- package/src/state.js +143 -0
- package/src/tokens.js +24 -0
package/src/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Where a running server keeps its state and its token: the per-user runtime directory, never the
|
|
2
2
|
// workspace (README: "nothing goes into the workspace, so it never lands in a synced folder"). One file per
|
|
3
3
|
// workspace, named by a hash of the workspace's real path; the directory is 0700 and the file 0600.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import os from 'node:os';
|
|
@@ -15,18 +16,53 @@ export function runtimeDir(env = process.env, platform = process.platform, home
|
|
|
15
16
|
return path.join(home, '.local', 'share', 'dreamteamer', 'web', 'runtime');
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** A workspace's key: the first 16 hex of the SHA-256 of its real path — the runtime file's name, the state
|
|
20
|
+
* file's (state.js) and the registry id. */
|
|
21
|
+
export function workspaceKey(root) {
|
|
22
|
+
return crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex').slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a start that could not take its stored port says (the runtime file carries `movedFrom` for the verbs). */
|
|
26
|
+
export const movedLine = (from, to) => `port ${from} was taken; moved to ${to} — open the pane again: dt web open`;
|
|
27
|
+
|
|
28
|
+
/** The runtime file of one workspace's server: `{ pid, port, started, owner, token, instance, pstart, movedFrom? }` — `pstart` the process's start in epoch milliseconds (ownStart). */
|
|
19
29
|
export function stateFile(root, dir = runtimeDir()) {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
return path.join(dir, `${workspaceKey(root)}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a write into a dir the person made read-only (or a dir of another user's) throws: the dir, its mode, and the
|
|
34
|
+
* two ways out. `envName` is the variable that moves the dir (DT_WEB_STATE_DIR, DT_WEB_RUNTIME_DIR). */
|
|
35
|
+
export function notWritable(dir, envName, mode = null) {
|
|
36
|
+
const m = mode ?? (() => { try { return fs.statSync(dir).mode & 0o777; } catch { return null; } })();
|
|
37
|
+
const kind = envName === 'DT_WEB_STATE_DIR' ? 'state' : 'runtime';
|
|
38
|
+
return Object.assign(new Error(`the dt web ${kind} dir ${dir} is not writable${m === null ? '' : ` (mode ${m.toString(8)})`} — dt web keeps its files there and never widens a mode you set: chmod 700 "${dir}", or point ${envName} at another folder`), { code: 'EDTWEBDIR' });
|
|
39
|
+
}
|
|
40
|
+
const DENIED = new Set(['EACCES', 'EPERM', 'EROFS']);
|
|
41
|
+
/** Run `fn` (a write into `dir`); a refusal by the file system becomes notWritable. */
|
|
42
|
+
export function writingInto(dir, envName, fn) {
|
|
43
|
+
try { return fn(); } catch (e) { throw DENIED.has(e?.code) ? notWritable(dir, envName) : e; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Make `dir` for this user's files: 0700 when created. A mode that lets the group or others in is tightened to the
|
|
48
|
+
* owner's bits — what is kept there is this user's alone. A mode the person narrowed (the owner cannot write into it or
|
|
49
|
+
* enter it) is theirs: it is never widened, and the write is refused naming the dir (notWritable).
|
|
50
|
+
*/
|
|
51
|
+
export function privateDir(dir, envName) {
|
|
52
|
+
writingInto(dir, envName, () => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }));
|
|
53
|
+
const mode = fs.statSync(dir).mode & 0o777;
|
|
54
|
+
if (mode & 0o077) writingInto(dir, envName, () => fs.chmodSync(dir, mode & 0o700));
|
|
55
|
+
if ((mode & 0o300) !== 0o300) throw notWritable(dir, envName, mode);
|
|
22
56
|
}
|
|
23
57
|
|
|
24
58
|
export function writeState(file, state) {
|
|
25
|
-
|
|
26
|
-
|
|
59
|
+
const dir = path.dirname(file);
|
|
60
|
+
privateDir(dir, 'DT_WEB_RUNTIME_DIR');
|
|
27
61
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
28
|
-
|
|
29
|
-
|
|
62
|
+
writingInto(dir, 'DT_WEB_RUNTIME_DIR', () => {
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, '\t') + '\n', { mode: 0o600 });
|
|
64
|
+
fs.renameSync(tmp, file);
|
|
65
|
+
});
|
|
30
66
|
}
|
|
31
67
|
|
|
32
68
|
export function readState(file) {
|
|
@@ -43,13 +79,139 @@ export function alive(pid) {
|
|
|
43
79
|
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
44
80
|
}
|
|
45
81
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
// ---- who answers on the runtime file's port ---------------------------------------------------------------------
|
|
83
|
+
// A pid outlives nothing: after a hard kill (SIGKILL, a crash, power loss) the runtime file stays, and its pid and its
|
|
84
|
+
// port can both belong to another program later. So a caller never trusts either: it sends a fresh random challenge to
|
|
85
|
+
// the unauthenticated GET /_dt/identity, and only the server that holds the runtime file's `instance` — a random id
|
|
86
|
+
// minted each start, kept in this 0600 file and nowhere else — can answer HMAC-SHA256(instance, challenge). The
|
|
87
|
+
// instance itself never crosses the wire, and the control token is sent only after the proof.
|
|
88
|
+
|
|
89
|
+
/** The path of the identity route, and what a challenge must look like (base64url, 16 to 128 characters). */
|
|
90
|
+
export const IDENTITY_PATH = '/_dt/identity';
|
|
91
|
+
export const CHALLENGE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
92
|
+
|
|
93
|
+
/** What the server holding `instance` answers to `challenge`. */
|
|
94
|
+
export function identityProof(instance, challenge) {
|
|
95
|
+
return crypto.createHmac('sha256', String(instance)).update(`dt-web identity ${challenge}`).digest('base64url');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Who answers on `rt.port`: `'proven'` — the server holding `rt.instance`; `'stranger'` — a refused connection, an answer
|
|
100
|
+
* that is not a 200, or a wrong proof (and a runtime file with no instance, an older server): this is not the server;
|
|
101
|
+
* `'silent'` — no answer in time (or a connection that broke): a process that may be this server, suspended or busy,
|
|
102
|
+
* and must not be taken for a stranger. Never throws. Nothing but the challenge is sent.
|
|
103
|
+
* @param {{ port?: unknown, instance?: unknown }} rt
|
|
104
|
+
* @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [o]
|
|
105
|
+
* @returns {Promise<'proven'|'stranger'|'silent'>}
|
|
106
|
+
*/
|
|
107
|
+
export async function identify(rt, { fetchImpl = fetch, timeoutMs = 2000 } = {}) {
|
|
108
|
+
if (!rt || !Number.isInteger(rt.port) || rt.port <= 0 || rt.port > 65535 || typeof rt.instance !== 'string' || !rt.instance) return 'stranger';
|
|
109
|
+
const challenge = crypto.randomBytes(24).toString('base64url');
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetchImpl(`http://127.0.0.1:${rt.port}${IDENTITY_PATH}?challenge=${challenge}`, { redirect: 'manual', signal: AbortSignal.timeout(Math.max(1, timeoutMs)) });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return e?.cause?.code === 'ECONNREFUSED' || e?.code === 'ECONNREFUSED' ? 'stranger' : 'silent';
|
|
115
|
+
}
|
|
116
|
+
if (res.status !== 200) return 'stranger';
|
|
117
|
+
let body;
|
|
118
|
+
try { body = await res.json(); } catch (e) { return e?.name === 'TimeoutError' || e?.name === 'AbortError' ? 'silent' : 'stranger'; }
|
|
119
|
+
const proof = body?.proof;
|
|
120
|
+
const want = identityProof(rt.instance, challenge);
|
|
121
|
+
return typeof proof === 'string' && proof.length === want.length && crypto.timingSafeEqual(Buffer.from(proof), Buffer.from(want)) ? 'proven' : 'stranger';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Does the server on `rt.port` hold `rt.instance`? (`identify` is 'proven'.) What the hook asks. */
|
|
125
|
+
export async function proves(rt, o) {
|
|
126
|
+
return (await identify(rt, o)) === 'proven';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- is that pid the process that wrote the runtime file? ------------------------------------------------------
|
|
130
|
+
// A start time is compared as an INSTANT, never as text: `ps -o lstart=` prints in the shell's language and timezone,
|
|
131
|
+
// so the same process reads differently from another terminal. The server records its own start in epoch milliseconds
|
|
132
|
+
// (`ownStart`); a verb derives the pid's start from its elapsed time (`ps -o etime=`, digits and colons under LC_ALL=C)
|
|
133
|
+
// as now minus elapsed. etime counts whole seconds and a wall clock can be stepped a little, hence the tolerance.
|
|
134
|
+
|
|
135
|
+
/** How far apart two readings of one process's start may be and still name the same process. */
|
|
136
|
+
export const START_TOLERANCE_MS = 3000;
|
|
137
|
+
/** How long `dt web stop --force` waits after SIGTERM and SIGCONT before it sends SIGKILL. */
|
|
138
|
+
export const FORCE_GRACE_MS = 5000;
|
|
139
|
+
|
|
140
|
+
/** This process's start, in epoch milliseconds — what the runtime file records as `pstart`. */
|
|
141
|
+
export function ownStart(now = Date.now()) {
|
|
142
|
+
return Math.round(now - process.uptime() * 1000);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `[[dd-]hh:]mm:ss` (ps's etime) → seconds, or null. */
|
|
146
|
+
export function etimeSeconds(text) {
|
|
147
|
+
const m = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(String(text ?? '').trim());
|
|
148
|
+
if (!m) return null;
|
|
149
|
+
const [, d = 0, h = 0, mm, ss] = m;
|
|
150
|
+
return ((Number(d) * 24 + Number(h)) * 60 + Number(mm)) * 60 + Number(ss);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** When `pid` started, in epoch milliseconds (now minus its elapsed time), or null when it cannot be read. */
|
|
154
|
+
export function processStartMs(pid, now = Date.now()) {
|
|
155
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
156
|
+
try {
|
|
157
|
+
const r = spawnSync('ps', ['-o', 'etime=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } });
|
|
158
|
+
const secs = r.status === 0 ? etimeSeconds(r.stdout) : null;
|
|
159
|
+
return secs === null ? null : now - secs * 1000;
|
|
160
|
+
} catch { return null; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** true — `pid` started when `recorded` says; false — it did not (a reused pid); null — it cannot be shown. */
|
|
164
|
+
export function sameStart(pid, recorded) {
|
|
165
|
+
if (!Number.isFinite(recorded)) return null;
|
|
166
|
+
const now = processStartMs(pid);
|
|
167
|
+
return now === null ? null : Math.abs(now - recorded) <= START_TOLERANCE_MS;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The server a runtime file names, and in what state: null when there is no file or its process is gone (that file is
|
|
172
|
+
* removed); otherwise the file's contents plus `file`, `state` and `proven`:
|
|
173
|
+
* - `'running'` (proven) — it proved this start's instance;
|
|
174
|
+
* - `'stale'` — a refused connection or a wrong proof, or a silent process whose start time is not the one recorded
|
|
175
|
+
* (its pid reused): not this server, so its runtime file may be cleared and a new server started;
|
|
176
|
+
* - `'silent'` — alive, no answer in time, and not shown to be another process: NOT ANSWERING. Nothing may clear its
|
|
177
|
+
* runtime file or lock, or start a server beside it. `sameProcess` is true when the recorded start (`pstart`) is this
|
|
178
|
+
* pid's own, within START_TOLERANCE_MS (what lets `dt web stop --force` signal it), null when that cannot be shown.
|
|
179
|
+
* Every verb reads the server through this.
|
|
180
|
+
*/
|
|
181
|
+
export async function probeServer(file, o) {
|
|
49
182
|
const s = readState(file);
|
|
50
|
-
if (!s) return null;
|
|
183
|
+
if (!s || typeof s !== 'object') return null;
|
|
51
184
|
if (!alive(s.pid)) { fs.rmSync(file, { force: true }); return null; }
|
|
52
|
-
|
|
185
|
+
const answer = await identify(s, o);
|
|
186
|
+
if (answer === 'proven') return { ...s, file, state: 'running', proven: true, sameProcess: true };
|
|
187
|
+
if (answer === 'stranger') return { ...s, file, state: 'stale', proven: false, sameProcess: null };
|
|
188
|
+
const sameProcess = sameStart(s.pid, s.pstart);
|
|
189
|
+
return { ...s, file, state: sameProcess === false ? 'stale' : 'silent', proven: false, sameProcess };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The one line every verb prints for a server that is alive and not answering (probeServer state 'silent'). */
|
|
193
|
+
export function notAnsweringLine(p) {
|
|
194
|
+
return `not answering: pid ${p.pid} at http://127.0.0.1:${p.port} is alive but gave no identity proof within 2 s — it may be suspended or busy; nothing was started, stopped or removed. Try again, or end it: dt web stop --force`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Remove a stale runtime file — only while it still names that pid and instance — and its lock when that pid holds it. */
|
|
198
|
+
export function clearStale(file, stale) {
|
|
199
|
+
const now = readState(file);
|
|
200
|
+
if (now && now.pid === stale.pid && now.instance === stale.instance) fs.rmSync(file, { force: true });
|
|
201
|
+
const lock = `${file}.lock`;
|
|
202
|
+
try { if (Number(fs.readFileSync(lock, 'utf8')) === stale.pid) fs.rmSync(lock, { force: true }); } catch { /* no lock */ }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Create `file` holding `pid`, failing with EEXIST when it exists — and never visible empty. An exclusive create
|
|
207
|
+
* (flag 'wx') opens the file before it writes the pid, and a rival that reads it in between gets '' — pid 0, "not
|
|
208
|
+
* alive" — and takes the lock over: two servers on two ports, seen on Linux CI (cr-modules-017, four starts at once).
|
|
209
|
+
* So the pid is written to a temp file of this claimant's first, and the lock appears by link(2), whole or not at all.
|
|
210
|
+
*/
|
|
211
|
+
function createHolding(file, pid) {
|
|
212
|
+
const tmp = `${file}.${pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
213
|
+
fs.writeFileSync(tmp, String(pid), { flag: 'wx', mode: 0o600 });
|
|
214
|
+
try { fs.linkSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
53
215
|
}
|
|
54
216
|
|
|
55
217
|
/**
|
|
@@ -62,10 +224,10 @@ export function claimStart(file, pid = process.pid) {
|
|
|
62
224
|
const lock = `${file}.lock`;
|
|
63
225
|
const take = `${lock}.takeover`;
|
|
64
226
|
const holder = (f) => { try { return Number(fs.readFileSync(f, 'utf8')); } catch { return null; } };
|
|
65
|
-
|
|
227
|
+
privateDir(path.dirname(lock), 'DT_WEB_RUNTIME_DIR');
|
|
66
228
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
67
229
|
try {
|
|
68
|
-
|
|
230
|
+
writingInto(path.dirname(lock), 'DT_WEB_RUNTIME_DIR', () => createHolding(lock, pid));
|
|
69
231
|
let released = false;
|
|
70
232
|
return { ok: true, release: () => { if (released) return; released = true; if (holder(lock) === pid) fs.rmSync(lock, { force: true }); } };
|
|
71
233
|
} catch (e) {
|
|
@@ -74,7 +236,7 @@ export function claimStart(file, pid = process.pid) {
|
|
|
74
236
|
if (held !== null && alive(held)) return { ok: false, pid: held };
|
|
75
237
|
const taker = holder(take);
|
|
76
238
|
if (taker !== null && !alive(taker)) fs.rmSync(take, { force: true });
|
|
77
|
-
try {
|
|
239
|
+
try { createHolding(take, pid); } catch { return { ok: false, pid: held }; }
|
|
78
240
|
try { if (holder(lock) === held) fs.rmSync(lock, { force: true }); } finally { fs.rmSync(take, { force: true }); }
|
|
79
241
|
}
|
|
80
242
|
}
|