sdocs-dev 1.6.2 → 1.12.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/bin/sdocs-bridge.js +974 -0
- package/bin/sdocs-dev.js +145 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +111 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2706 -0
- package/lib/io.js +173 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +280 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +332 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -16
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
// Local Bridge: lets the smalldocs.org page read and write files on the user's
|
|
2
|
+
// own machine. Started by `sdoc watch | edit | compose --wait`.
|
|
3
|
+
//
|
|
4
|
+
// Shape of a session:
|
|
5
|
+
// - One bridge per `sdoc` invocation.
|
|
6
|
+
// - Binds to 127.0.0.1 on a kernel-picked port (`--port 0`).
|
|
7
|
+
// - Per-session 32-byte random token in the URL fragment.
|
|
8
|
+
// - At most one live WebSocket connection.
|
|
9
|
+
// - Path allowlist of files the caller passed, resolved via fs.realpath at
|
|
10
|
+
// startup and re-checked on every write so a swapped symlink can't escape.
|
|
11
|
+
// - Atomic writes (write to a tempfile in the same directory, then rename).
|
|
12
|
+
// - Parent-directory watch (not file-watch) filtered by filename, with a
|
|
13
|
+
// stat-poll backstop. The same disk content hash is compared on every
|
|
14
|
+
// event so the bridge's own writes don't get echoed back as external
|
|
15
|
+
// changes, and timestamp-equal-but-different writes are still caught.
|
|
16
|
+
// - On WebSocket drop, wait `RECONNECT_GRACE_MS` for a reconnect with the
|
|
17
|
+
// same token before treating the session as ended (chunk 2 reload survival).
|
|
18
|
+
//
|
|
19
|
+
// Hand-rolled WebSocket framing because the CLI package has zero runtime deps.
|
|
20
|
+
// We only need: handshake, text frames (incl. fragmentation), ping, pong, close.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const http = require('http');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const FormBlock = require('../shared/sdocs-form-block.js');
|
|
29
|
+
const { isWrappedFile, wrapForDisplay } = require('../lib/cells-transclude');
|
|
30
|
+
|
|
31
|
+
// ── Constants ─────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
34
|
+
const RECONNECT_GRACE_MS = 8000; // wait this long for a reconnect after drop
|
|
35
|
+
// (covers refresh + service-worker dance)
|
|
36
|
+
const STAT_POLL_MS = 3000; // stat-poll backstop (fs.watch coalesces)
|
|
37
|
+
const WATCH_DEBOUNCE_MS = 50; // collapse rapid fs.watch bursts
|
|
38
|
+
const NO_CONNECT_TIMEOUT_MS = 30000; // exit if the browser never connects
|
|
39
|
+
const IDLE_TIMEOUT_MS = 0; // 0 = off. Background-throttled tabs
|
|
40
|
+
// otherwise stall pings and trip this.
|
|
41
|
+
const MAX_MESSAGE_BYTES = 20 * 1024 * 1024;
|
|
42
|
+
const ALLOWED_ROOT = 'smalldocs.org';
|
|
43
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost'];
|
|
44
|
+
|
|
45
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function hashContent(buf) {
|
|
48
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Origin pin: parse the header as a URL and compare structured fields. A
|
|
52
|
+
// string-suffix check would let through smalldocs.org.attacker.com; the
|
|
53
|
+
// hostname-equality + leading-dot subdomain check below does not.
|
|
54
|
+
function isAllowedOrigin(origin, extra) {
|
|
55
|
+
if (!origin) return false;
|
|
56
|
+
let u;
|
|
57
|
+
try { u = new URL(origin); } catch (_) { return false; }
|
|
58
|
+
// Origin headers carry no path / search / hash. Reject anything weird that
|
|
59
|
+
// still parses (some browsers will tolerate junk).
|
|
60
|
+
if (u.pathname && u.pathname !== '/') return false;
|
|
61
|
+
if (u.search || u.hash) return false;
|
|
62
|
+
const host = u.hostname.toLowerCase();
|
|
63
|
+
// Production: HTTPS only, exact root or any subdomain. The leading dot in
|
|
64
|
+
// the suffix is what closes smalldocs.org.attacker.com style footguns.
|
|
65
|
+
if (u.protocol === 'https:') {
|
|
66
|
+
if (host === ALLOWED_ROOT) return true;
|
|
67
|
+
if (host.endsWith('.' + ALLOWED_ROOT)) return true;
|
|
68
|
+
}
|
|
69
|
+
// Loopback (dev + tests): page on localhost talks to a loopback bridge.
|
|
70
|
+
if ((u.protocol === 'http:' || u.protocol === 'https:') && LOOPBACK_HOSTS.indexOf(host) >= 0) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// CLI escape hatch: exact-string allowlist passed via `--allowed-origin`.
|
|
74
|
+
if (extra && extra.indexOf(origin) >= 0) return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Host header check: blocks DNS-rebinding. The browser sends whatever
|
|
79
|
+
// hostname it used to reach us in the Host header; if that isn't a loopback
|
|
80
|
+
// hostname on our exact bound port, the request didn't legitimately come
|
|
81
|
+
// from a local page.
|
|
82
|
+
function isAllowedHost(hostHeader, boundPort) {
|
|
83
|
+
if (!hostHeader || boundPort == null) return false;
|
|
84
|
+
const m = /^([^:]+)(?::(\d+))?$/.exec(String(hostHeader).trim());
|
|
85
|
+
if (!m) return false;
|
|
86
|
+
const host = m[1].toLowerCase();
|
|
87
|
+
const port = m[2] ? parseInt(m[2], 10) : null;
|
|
88
|
+
if (port !== boundPort) return false;
|
|
89
|
+
return LOOPBACK_HOSTS.indexOf(host) >= 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Resolve a path through realpath. For files that don't exist yet, realpath
|
|
93
|
+
// the parent directory (which must exist) and append the basename so a later
|
|
94
|
+
// rename of the parent can't shift us out of the allowlist.
|
|
95
|
+
function resolveAllowedPath(p) {
|
|
96
|
+
const abs = path.resolve(p);
|
|
97
|
+
if (fs.existsSync(abs)) {
|
|
98
|
+
return fs.realpathSync(abs);
|
|
99
|
+
}
|
|
100
|
+
const dir = path.dirname(abs);
|
|
101
|
+
if (!fs.existsSync(dir)) {
|
|
102
|
+
throw new Error('directory does not exist: ' + dir);
|
|
103
|
+
}
|
|
104
|
+
return path.join(fs.realpathSync(dir), path.basename(abs));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Open the allowlisted file read-only and capture (dev, ino) so we can
|
|
108
|
+
// confirm it's still the same file at every write. O_NOFOLLOW where
|
|
109
|
+
// supported so a symlink replacement at the resolved path can't grab a
|
|
110
|
+
// handle to whatever the symlink points at. The fd is held purely as an
|
|
111
|
+
// identity anchor; writes still go through the tmp+rename path.
|
|
112
|
+
function openIdentity(filepath) {
|
|
113
|
+
let flags = fs.constants.O_RDONLY;
|
|
114
|
+
if (typeof fs.constants.O_NOFOLLOW === 'number') flags |= fs.constants.O_NOFOLLOW;
|
|
115
|
+
let fd;
|
|
116
|
+
try { fd = fs.openSync(filepath, flags); }
|
|
117
|
+
catch (_) { return null; }
|
|
118
|
+
let stat;
|
|
119
|
+
try { stat = fs.fstatSync(fd); }
|
|
120
|
+
catch (_) {
|
|
121
|
+
try { fs.closeSync(fd); } catch (_) {}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return { fd: fd, dev: stat.dev, ino: stat.ino };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function closeIdentity(id) {
|
|
128
|
+
if (!id) return;
|
|
129
|
+
try { fs.closeSync(id.fd); } catch (_) {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Stat the resolved path and compare (dev, ino) to a captured identity.
|
|
133
|
+
// True only when the file on disk is the exact inode we opened.
|
|
134
|
+
function sameIdentity(resolvedPath, id) {
|
|
135
|
+
if (!id) return false;
|
|
136
|
+
let st;
|
|
137
|
+
try { st = fs.lstatSync(resolvedPath); }
|
|
138
|
+
catch (_) { return false; }
|
|
139
|
+
return st.dev === id.dev && st.ino === id.ino;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Atomic write inside the same directory, then rename. The temp filename is
|
|
143
|
+
// hidden (dot-prefixed) so editors that scan the directory don't pick it up.
|
|
144
|
+
function atomicWrite(target, content) {
|
|
145
|
+
const dir = path.dirname(target);
|
|
146
|
+
const tmp = path.join(
|
|
147
|
+
dir,
|
|
148
|
+
'.' + path.basename(target) + '.sdocs-tmp-' + crypto.randomBytes(6).toString('hex')
|
|
149
|
+
);
|
|
150
|
+
fs.writeFileSync(tmp, content);
|
|
151
|
+
try {
|
|
152
|
+
fs.renameSync(tmp, target);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── WebSocket framing (RFC 6455, server side) ─────────────
|
|
160
|
+
|
|
161
|
+
class WsParser {
|
|
162
|
+
constructor(opts) {
|
|
163
|
+
opts = opts || {};
|
|
164
|
+
this.onMessage = opts.onMessage;
|
|
165
|
+
this.onClose = opts.onClose;
|
|
166
|
+
this.onPing = opts.onPing;
|
|
167
|
+
this.onPong = opts.onPong;
|
|
168
|
+
this.onError = opts.onError;
|
|
169
|
+
this.maxBytes = opts.maxBytes || MAX_MESSAGE_BYTES;
|
|
170
|
+
// RFC 6455: client-to-server frames MUST be masked, server-to-client
|
|
171
|
+
// frames MUST NOT be masked. Defaults to the server-side rule because
|
|
172
|
+
// that's what the bridge needs in production. Set false in tests that
|
|
173
|
+
// are parsing the bridge's own outbound frames.
|
|
174
|
+
this.expectMasked = opts.expectMasked !== false;
|
|
175
|
+
this._buf = Buffer.alloc(0);
|
|
176
|
+
this._frag = null;
|
|
177
|
+
this._dead = false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
feed(chunk) {
|
|
181
|
+
if (this._dead) return;
|
|
182
|
+
this._buf = this._buf.length === 0 ? chunk : Buffer.concat([this._buf, chunk]);
|
|
183
|
+
while (this._tryFrame()) {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_tryFrame() {
|
|
187
|
+
const b = this._buf;
|
|
188
|
+
if (b.length < 2) return false;
|
|
189
|
+
const fin = (b[0] & 0x80) !== 0;
|
|
190
|
+
const opcode = b[0] & 0x0F;
|
|
191
|
+
const masked = (b[1] & 0x80) !== 0;
|
|
192
|
+
let len = b[1] & 0x7F;
|
|
193
|
+
let off = 2;
|
|
194
|
+
|
|
195
|
+
if (len === 126) {
|
|
196
|
+
if (b.length < off + 2) return false;
|
|
197
|
+
len = b.readUInt16BE(off);
|
|
198
|
+
off += 2;
|
|
199
|
+
} else if (len === 127) {
|
|
200
|
+
if (b.length < off + 8) return false;
|
|
201
|
+
const hi = b.readUInt32BE(off);
|
|
202
|
+
const lo = b.readUInt32BE(off + 4);
|
|
203
|
+
if (hi !== 0) return this._fatal('frame exceeds 32-bit length');
|
|
204
|
+
len = lo;
|
|
205
|
+
off += 8;
|
|
206
|
+
}
|
|
207
|
+
if (this.expectMasked && !masked) return this._fatal('client frame must be masked');
|
|
208
|
+
if (len > this.maxBytes) return this._fatal('frame exceeds size cap');
|
|
209
|
+
if (b.length < off + (masked ? 4 : 0) + len) return false;
|
|
210
|
+
|
|
211
|
+
let data;
|
|
212
|
+
if (masked) {
|
|
213
|
+
const mask = b.slice(off, off + 4);
|
|
214
|
+
off += 4;
|
|
215
|
+
data = Buffer.alloc(len);
|
|
216
|
+
for (let i = 0; i < len; i++) data[i] = b[off + i] ^ mask[i & 3];
|
|
217
|
+
} else {
|
|
218
|
+
data = b.slice(off, off + len);
|
|
219
|
+
}
|
|
220
|
+
this._buf = b.slice(off + len);
|
|
221
|
+
|
|
222
|
+
if (opcode === 0x8) { this._dead = true; this.onClose && this.onClose(data); return false; }
|
|
223
|
+
if (opcode === 0x9) { this.onPing && this.onPing(data); return true; }
|
|
224
|
+
if (opcode === 0xA) { this.onPong && this.onPong(data); return true; }
|
|
225
|
+
|
|
226
|
+
if (opcode === 0x1 || opcode === 0x2 || opcode === 0x0) {
|
|
227
|
+
if (opcode === 0x0) {
|
|
228
|
+
if (!this._frag) return this._fatal('continuation with no start frame');
|
|
229
|
+
this._frag.chunks.push(data);
|
|
230
|
+
this._frag.length += data.length;
|
|
231
|
+
if (this._frag.length > this.maxBytes) return this._fatal('fragmented message exceeds size cap');
|
|
232
|
+
} else {
|
|
233
|
+
if (this._frag) return this._fatal('new data frame during fragmentation');
|
|
234
|
+
this._frag = { opcode, chunks: [data], length: data.length };
|
|
235
|
+
}
|
|
236
|
+
if (fin) {
|
|
237
|
+
const f = this._frag;
|
|
238
|
+
this._frag = null;
|
|
239
|
+
const full = f.chunks.length === 1 ? f.chunks[0] : Buffer.concat(f.chunks, f.length);
|
|
240
|
+
this.onMessage && this.onMessage(f.opcode, full);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return this._fatal('unknown opcode 0x' + opcode.toString(16));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_fatal(msg) {
|
|
249
|
+
this._dead = true;
|
|
250
|
+
this.onError && this.onError(new Error(msg));
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function encodeFrame(opcode, payload) {
|
|
256
|
+
const len = payload.length;
|
|
257
|
+
let header;
|
|
258
|
+
if (len < 126) {
|
|
259
|
+
header = Buffer.alloc(2);
|
|
260
|
+
header[1] = len;
|
|
261
|
+
} else if (len < 65536) {
|
|
262
|
+
header = Buffer.alloc(4);
|
|
263
|
+
header[1] = 126;
|
|
264
|
+
header.writeUInt16BE(len, 2);
|
|
265
|
+
} else {
|
|
266
|
+
header = Buffer.alloc(10);
|
|
267
|
+
header[1] = 127;
|
|
268
|
+
header.writeUInt32BE(0, 2);
|
|
269
|
+
header.writeUInt32BE(len, 6);
|
|
270
|
+
}
|
|
271
|
+
header[0] = 0x80 | opcode; // FIN + opcode
|
|
272
|
+
return Buffer.concat([header, payload]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function wsSend(socket, opcode, payload) {
|
|
276
|
+
if (!socket || socket.destroyed || !socket.writable) return;
|
|
277
|
+
try { socket.write(encodeFrame(opcode, payload)); } catch (_) {}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function wsSendJson(socket, obj) {
|
|
281
|
+
wsSend(socket, 0x1, Buffer.from(JSON.stringify(obj), 'utf-8'));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function wsSendClose(socket, code, reason) {
|
|
285
|
+
if (!socket || socket.destroyed) return;
|
|
286
|
+
const body = Buffer.alloc(2);
|
|
287
|
+
body.writeUInt16BE(code || 1000, 0);
|
|
288
|
+
const payload = reason ? Buffer.concat([body, Buffer.from(reason, 'utf-8')]) : body;
|
|
289
|
+
wsSend(socket, 0x8, payload);
|
|
290
|
+
try { socket.end(); } catch (_) {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── File watching ─────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
// Watch the parent directory filtered by filename. The watcher is the fast
|
|
296
|
+
// path; the stat-poll is the backstop fs.watch coalescing eats. Either path
|
|
297
|
+
// re-reads the file and hash-compares, so the bridge's own writes can be
|
|
298
|
+
// suppressed without timestamp games.
|
|
299
|
+
function startWatch(filepath, getKnownHash, onExternal) {
|
|
300
|
+
const dir = path.dirname(filepath);
|
|
301
|
+
const base = path.basename(filepath);
|
|
302
|
+
let stopped = false;
|
|
303
|
+
let scheduled = false;
|
|
304
|
+
|
|
305
|
+
function check() {
|
|
306
|
+
if (stopped) return;
|
|
307
|
+
scheduled = false;
|
|
308
|
+
let content;
|
|
309
|
+
try {
|
|
310
|
+
content = fs.readFileSync(filepath);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (e.code === 'ENOENT') {
|
|
313
|
+
if (getKnownHash() !== null) onExternal({ deleted: true });
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const h = hashContent(content);
|
|
318
|
+
if (h === getKnownHash()) return;
|
|
319
|
+
onExternal({ content, hash: h });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function trigger() {
|
|
323
|
+
if (stopped || scheduled) return;
|
|
324
|
+
scheduled = true;
|
|
325
|
+
setTimeout(check, WATCH_DEBOUNCE_MS);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let watcher = null;
|
|
329
|
+
try {
|
|
330
|
+
watcher = fs.watch(dir, (_eventType, filename) => {
|
|
331
|
+
// On some platforms `filename` is null; trigger anyway and let the hash
|
|
332
|
+
// compare decide. When it's present, filter by our basename.
|
|
333
|
+
if (filename && filename !== base) return;
|
|
334
|
+
trigger();
|
|
335
|
+
});
|
|
336
|
+
watcher.on('error', () => { /* keep the stat-poll backstop running */ });
|
|
337
|
+
} catch (_) { /* same */ }
|
|
338
|
+
|
|
339
|
+
const interval = setInterval(check, STAT_POLL_MS);
|
|
340
|
+
|
|
341
|
+
return function stop() {
|
|
342
|
+
stopped = true;
|
|
343
|
+
if (watcher) { try { watcher.close(); } catch (_) {} }
|
|
344
|
+
clearInterval(interval);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Bridge ────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
// startBridge returns a Promise<Bridge>. Bridge shape:
|
|
351
|
+
// {
|
|
352
|
+
// port, token, mode, files,
|
|
353
|
+
// close(), // shuts everything down
|
|
354
|
+
// onSubmit(cb), onClose(cb), onConnect(cb),
|
|
355
|
+
// awaitTerminal() // resolves to { kind, code, ... } when
|
|
356
|
+
// // the session ends (submit / close /
|
|
357
|
+
// // no-connect / error).
|
|
358
|
+
// }
|
|
359
|
+
function startBridge(opts) {
|
|
360
|
+
opts = opts || {};
|
|
361
|
+
if (!Array.isArray(opts.files) || opts.files.length === 0) {
|
|
362
|
+
throw new Error('startBridge: opts.files (array of paths) is required');
|
|
363
|
+
}
|
|
364
|
+
// Two session shapes:
|
|
365
|
+
// - 'open': standard connected-to-disk. Tab close exits 0.
|
|
366
|
+
// - 'feedback': agent handoff. Done returns 0, close-without-Done returns
|
|
367
|
+
// 2. An optional `opts.message` is sent to the browser and
|
|
368
|
+
// rendered as a banner above the document.
|
|
369
|
+
const mode = opts.mode || 'open';
|
|
370
|
+
if (['open', 'feedback'].indexOf(mode) < 0) {
|
|
371
|
+
throw new Error('startBridge: mode must be open | feedback');
|
|
372
|
+
}
|
|
373
|
+
const message = typeof opts.message === 'string' ? opts.message : null;
|
|
374
|
+
// keepOpen=true keeps the bridge alive across non-final submits, so
|
|
375
|
+
// an agent can write more state into the file and the user can keep
|
|
376
|
+
// answering without re-launching the CLI.
|
|
377
|
+
const keepOpen = !!opts.keepOpen;
|
|
378
|
+
|
|
379
|
+
// Event sinks for "user clicked a submit button". Stdout receives a
|
|
380
|
+
// JSON line per submit so agents can tail the process. logFile receives
|
|
381
|
+
// the same line appended to a named file, for harnesses that can't
|
|
382
|
+
// stream a background process's stdout but can read a file. onEvent is
|
|
383
|
+
// an in-process callback used by tests.
|
|
384
|
+
const eventLogFile = typeof opts.logFile === 'string' && opts.logFile ? opts.logFile : null;
|
|
385
|
+
if (eventLogFile) {
|
|
386
|
+
// Fail-fast: confirm we can append to the path before the browser
|
|
387
|
+
// ever sees the form, so the user doesn't fill in a form and then
|
|
388
|
+
// discover the events have nowhere to land.
|
|
389
|
+
try {
|
|
390
|
+
fs.appendFileSync(eventLogFile, '');
|
|
391
|
+
} catch (e) {
|
|
392
|
+
throw new Error('startBridge: --log-file is not writable: ' + e.message);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null;
|
|
396
|
+
// Every successful submit emits at minimum one JSON line on stdout.
|
|
397
|
+
// In single-shot mode the process exits right after, so the agent
|
|
398
|
+
// reads stdout once and is done — no tailing, no log file required.
|
|
399
|
+
// --keep-open keeps the bridge alive across many submits; the agent
|
|
400
|
+
// tails stdout to react per click.
|
|
401
|
+
|
|
402
|
+
const token = opts.token || crypto.randomBytes(32).toString('base64url');
|
|
403
|
+
const port = opts.port || 0;
|
|
404
|
+
const extra = opts.allowedOrigins || [];
|
|
405
|
+
const idleMs = opts.idleTimeoutMs != null ? opts.idleTimeoutMs : IDLE_TIMEOUT_MS;
|
|
406
|
+
const noConnMs = opts.noConnectTimeoutMs != null ? opts.noConnectTimeoutMs : NO_CONNECT_TIMEOUT_MS;
|
|
407
|
+
const reconnectMs = opts.reconnectGraceMs != null ? opts.reconnectGraceMs : RECONNECT_GRACE_MS;
|
|
408
|
+
|
|
409
|
+
const allowlist = opts.files.map(resolveAllowedPath);
|
|
410
|
+
if (new Set(allowlist).size !== allowlist.length) {
|
|
411
|
+
throw new Error('startBridge: duplicate files in allowlist');
|
|
412
|
+
}
|
|
413
|
+
// v1 ships single-file sessions; the array shape leaves room for multi-file.
|
|
414
|
+
const filepath = allowlist[0];
|
|
415
|
+
|
|
416
|
+
const state = { content: Buffer.alloc(0), hash: null };
|
|
417
|
+
// Identity anchor: (dev, ino) of the file we were authorized to touch.
|
|
418
|
+
// Mutated when the watcher detects a legitimate external save or after
|
|
419
|
+
// our own atomic rename (rename always changes the inode by design).
|
|
420
|
+
// Null until the file first exists on disk (compose mode).
|
|
421
|
+
let identity = null;
|
|
422
|
+
if (fs.existsSync(filepath)) {
|
|
423
|
+
state.content = fs.readFileSync(filepath);
|
|
424
|
+
state.hash = hashContent(state.content);
|
|
425
|
+
identity = openIdentity(filepath);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Subscribers — the CLI listens to onSubmit/onClose/onConnect to decide
|
|
429
|
+
// whether to exit 0 (submit) or non-zero (cancel / no-connect).
|
|
430
|
+
const subs = { submit: [], close: [], connect: [], external: [] };
|
|
431
|
+
function emit(kind, payload) {
|
|
432
|
+
(subs[kind] || []).forEach(fn => { try { fn(payload); } catch (_) {} });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let socket = null;
|
|
436
|
+
let parser = null;
|
|
437
|
+
let everConnected = false;
|
|
438
|
+
let reconnectTimer = null;
|
|
439
|
+
let noConnectTimer = null;
|
|
440
|
+
let idleTimer = null;
|
|
441
|
+
let terminated = false;
|
|
442
|
+
let terminal = null;
|
|
443
|
+
let watchStop = null;
|
|
444
|
+
const terminalWaiters = [];
|
|
445
|
+
|
|
446
|
+
function clearAllTimers() {
|
|
447
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
448
|
+
if (noConnectTimer) { clearTimeout(noConnectTimer); noConnectTimer = null; }
|
|
449
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
450
|
+
}
|
|
451
|
+
function bumpIdle() {
|
|
452
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
453
|
+
if (idleMs > 0) idleTimer = setTimeout(() => terminate({ kind: 'idle', code: 4 }), idleMs);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function terminate(t) {
|
|
457
|
+
if (terminated) return;
|
|
458
|
+
terminated = true;
|
|
459
|
+
terminal = t;
|
|
460
|
+
clearAllTimers();
|
|
461
|
+
if (watchStop) { try { watchStop(); } catch (_) {} watchStop = null; }
|
|
462
|
+
closeIdentity(identity);
|
|
463
|
+
identity = null;
|
|
464
|
+
if (socket && !socket.destroyed) {
|
|
465
|
+
wsSendClose(socket, 1000, '');
|
|
466
|
+
try { socket.destroy(); } catch (_) {}
|
|
467
|
+
}
|
|
468
|
+
try { server.close(); } catch (_) {}
|
|
469
|
+
emit(t.kind, t);
|
|
470
|
+
terminalWaiters.splice(0).forEach(fn => fn(t));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function handleMessage(raw) {
|
|
474
|
+
let msg;
|
|
475
|
+
try { msg = JSON.parse(raw.toString('utf-8')); }
|
|
476
|
+
catch (_) { return wsSendJson(socket, { type: 'error', code: 'EBADJSON', message: 'invalid JSON' }); }
|
|
477
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
478
|
+
bumpIdle();
|
|
479
|
+
|
|
480
|
+
if (msg.type === 'ping') {
|
|
481
|
+
wsSendJson(socket, { type: 'pong' });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (msg.type === 'pong') return;
|
|
485
|
+
|
|
486
|
+
// Read a file the document references (e.g. a {{report.csv}} cells block),
|
|
487
|
+
// for display only. Resolved relative to the document's folder; absolute
|
|
488
|
+
// paths are honoured as-is. No folder restriction by design - the gate is
|
|
489
|
+
// this authenticated, localhost-only socket. This never touches the
|
|
490
|
+
// document's own content/sync, so it can't affect what gets saved back.
|
|
491
|
+
if (msg.type === 'read-file') {
|
|
492
|
+
const rid = msg.id;
|
|
493
|
+
const rel = typeof msg.path === 'string' ? msg.path : '';
|
|
494
|
+
try {
|
|
495
|
+
const target = path.resolve(path.dirname(filepath), rel);
|
|
496
|
+
const content = fs.readFileSync(target, 'utf-8');
|
|
497
|
+
wsSendJson(socket, { type: 'file', id: rid, ok: true, path: rel, content: content });
|
|
498
|
+
} catch (e) {
|
|
499
|
+
wsSendJson(socket, {
|
|
500
|
+
type: 'file', id: rid, ok: false,
|
|
501
|
+
error: (e && e.code === 'ENOENT') ? 'not found' : ((e && e.message) || 'read failed'),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (msg.type === 'write' || msg.type === 'submit') {
|
|
508
|
+
// A wrapped file's document (a .csv shown as a ```cells sheet, a .mmd
|
|
509
|
+
// shown as a diagram) is a derived view, not the file. Writing it back
|
|
510
|
+
// would replace the data with fence markup, so saves are refused. The
|
|
511
|
+
// browser also gets canSave: false in hello; this is the backstop.
|
|
512
|
+
if (isWrappedFile(filepath)) {
|
|
513
|
+
wsSendJson(socket, { type: 'error', code: 'EREADONLY',
|
|
514
|
+
message: path.basename(filepath) + ' opens as a read-only view; edit the file itself', id: msg.id });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const body = typeof msg.content === 'string' ? msg.content : '';
|
|
518
|
+
const buf = Buffer.from(body, 'utf-8');
|
|
519
|
+
// Re-resolve through realpath every write to defeat a symlink swap.
|
|
520
|
+
let resolved;
|
|
521
|
+
try { resolved = resolveAllowedPath(filepath); }
|
|
522
|
+
catch (e) {
|
|
523
|
+
wsSendJson(socket, { type: 'error', code: 'EPATH', message: e.message, id: msg.id });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (resolved !== filepath) {
|
|
527
|
+
wsSendJson(socket, { type: 'error', code: 'EPATH', message: 'path now resolves outside the allowlist', id: msg.id });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// Identity gate: realpath catches symlink swaps; this catches the
|
|
531
|
+
// in-place inode swap (unlink + recreate at the same path).
|
|
532
|
+
if (identity && !sameIdentity(filepath, identity)) {
|
|
533
|
+
wsSendJson(socket, { type: 'error', code: 'EPATH', message: 'file identity changed since session start', id: msg.id });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
state.hash = hashContent(buf); // set before rename: the watcher will
|
|
538
|
+
state.content = buf; // see our own write and suppress it
|
|
539
|
+
atomicWrite(filepath, buf);
|
|
540
|
+
} catch (e) {
|
|
541
|
+
wsSendJson(socket, { type: 'error', code: e.code || 'EWRITE', message: e.message, id: msg.id });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// Atomic rename always changes the inode. Recapture so the next write
|
|
545
|
+
// compares against fresh identity, not the orphaned one.
|
|
546
|
+
closeIdentity(identity);
|
|
547
|
+
identity = openIdentity(filepath);
|
|
548
|
+
if (msg.id) wsSendJson(socket, { type: 'ack', for: msg.id });
|
|
549
|
+
|
|
550
|
+
if (msg.type === 'submit') {
|
|
551
|
+
wsSendJson(socket, { type: 'submitted' });
|
|
552
|
+
terminate({ kind: 'submit', code: 0 });
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (msg.type === 'submitForm') {
|
|
558
|
+
handleFormSubmit(msg);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (msg.type === 'close') {
|
|
563
|
+
// Tab is closing intentionally. No reconnect grace — exit immediately
|
|
564
|
+
// with the right code for our mode.
|
|
565
|
+
const t = (mode === 'feedback')
|
|
566
|
+
? { kind: 'cancel', code: 2 }
|
|
567
|
+
: { kind: 'close', code: 0 };
|
|
568
|
+
terminate(t);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Unknown types are ignored.
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Handle a `submitForm` message from the browser. The flow:
|
|
576
|
+
// 1. Re-read the file from disk (the file is the source of truth,
|
|
577
|
+
// not anything we have in memory).
|
|
578
|
+
// 2. Find the named form block, recompute its revision token.
|
|
579
|
+
// 3. If the token doesn't match what the browser submitted, the
|
|
580
|
+
// schema has changed under the user's hands. Reject with
|
|
581
|
+
// `form-stale`; the next external-change push will refresh
|
|
582
|
+
// their view to the new schema.
|
|
583
|
+
// 4. Apply the user's values (scoped) and append a submission
|
|
584
|
+
// entry. Re-serialise the block and splice it back into the
|
|
585
|
+
// document.
|
|
586
|
+
// 5. Verify boundary stability: the only bytes that may differ
|
|
587
|
+
// between pre and post are inside the fenced region.
|
|
588
|
+
// 6. Atomic-write the file.
|
|
589
|
+
// 7. Ack to the browser. If the button was final or the CLI was
|
|
590
|
+
// not started with keepOpen, terminate.
|
|
591
|
+
function handleFormSubmit(msg) {
|
|
592
|
+
const formId = typeof msg.form_id === 'string' ? msg.form_id : '';
|
|
593
|
+
const buttonName = typeof msg.button_name === 'string' ? msg.button_name : '';
|
|
594
|
+
const values = (msg.values && typeof msg.values === 'object') ? msg.values : {};
|
|
595
|
+
const scope = Array.isArray(msg.scope) ? msg.scope : [];
|
|
596
|
+
const submittedToken = typeof msg.token === 'string' ? msg.token : '';
|
|
597
|
+
const final = !!msg.final;
|
|
598
|
+
|
|
599
|
+
if (!formId || !buttonName) {
|
|
600
|
+
return wsSendJson(socket, { type: 'error', code: 'EBADFORM', message: 'missing form_id or button_name' });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Fresh disk re-read.
|
|
604
|
+
let diskBuf;
|
|
605
|
+
try { diskBuf = fs.readFileSync(filepath); }
|
|
606
|
+
catch (e) {
|
|
607
|
+
return wsSendJson(socket, { type: 'error', code: 'EREAD', message: e.message });
|
|
608
|
+
}
|
|
609
|
+
const docText = diskBuf.toString('utf-8');
|
|
610
|
+
|
|
611
|
+
const blocks = FormBlock.findFormBlocks(docText);
|
|
612
|
+
const target = blocks.find(b => b.id === formId);
|
|
613
|
+
if (!target) {
|
|
614
|
+
return wsSendJson(socket, { type: 'error', code: 'EFORM_MISSING', message: 'no form with id ' + formId });
|
|
615
|
+
}
|
|
616
|
+
if (target.error || !target.parsed) {
|
|
617
|
+
return wsSendJson(socket, { type: 'error', code: 'EFORM_PARSE', message: target.error || 'unparseable form' });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const currentToken = FormBlock.formRevisionToken(target.parsed.fields, target.parsed.buttons);
|
|
621
|
+
if (currentToken !== submittedToken) {
|
|
622
|
+
return wsSendJson(socket, { type: 'form-stale', form_id: formId, expected: currentToken, got: submittedToken });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Apply the submit: merge in-scope values, append submission entry.
|
|
626
|
+
const next = {
|
|
627
|
+
id: target.parsed.id,
|
|
628
|
+
fields: target.parsed.fields,
|
|
629
|
+
buttons: target.parsed.buttons,
|
|
630
|
+
answers: Object.assign({}, target.parsed.answers || {}),
|
|
631
|
+
submissions: (target.parsed.submissions || []).slice(),
|
|
632
|
+
};
|
|
633
|
+
Object.keys(values).forEach(k => {
|
|
634
|
+
// Defence in depth: only apply values for fields that exist in
|
|
635
|
+
// the schema. The browser-side renderer already enforces this
|
|
636
|
+
// but a misbehaving client shouldn't be able to smuggle keys.
|
|
637
|
+
if (next.fields.some(f => f.name === k)) {
|
|
638
|
+
next.answers[k] = values[k];
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
next.submissions.push({
|
|
642
|
+
by: buttonName,
|
|
643
|
+
at: new Date().toISOString(),
|
|
644
|
+
scope: scope.length ? scope : next.fields.map(f => f.name),
|
|
645
|
+
values: scopedValuesOnly(values, next.fields.map(f => f.name)),
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const spliced = FormBlock.spliceFormBlock(docText, target, next);
|
|
649
|
+
if (spliced.error) {
|
|
650
|
+
return wsSendJson(socket, { type: 'error', code: 'EFORM_SPLICE', message: spliced.error });
|
|
651
|
+
}
|
|
652
|
+
const newDoc = spliced.doc;
|
|
653
|
+
|
|
654
|
+
// Boundary stability: same start byte; bytes outside [start, newEnd]
|
|
655
|
+
// identical to bytes outside [start, end] in the original.
|
|
656
|
+
const pre = docText.slice(0, target.startByte);
|
|
657
|
+
const post = docText.slice(target.endByte);
|
|
658
|
+
const newPre = newDoc.slice(0, spliced.startByte);
|
|
659
|
+
const newPost = newDoc.slice(spliced.endByte);
|
|
660
|
+
if (pre !== newPre || post !== newPost) {
|
|
661
|
+
return wsSendJson(socket, { type: 'error', code: 'EFORM_BOUNDARY', message: 'form write would shift surrounding bytes' });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Belt + braces: re-parse and confirm the block still parses and
|
|
665
|
+
// its id is unchanged. A serializer that produced subtly invalid
|
|
666
|
+
// YAML would slip through the boundary check.
|
|
667
|
+
const reBlocks = FormBlock.findFormBlocks(newDoc);
|
|
668
|
+
const reTarget = reBlocks.find(b => b.id === formId);
|
|
669
|
+
if (!reTarget || reTarget.error) {
|
|
670
|
+
return wsSendJson(socket, { type: 'error', code: 'EFORM_REPARSE', message: 'spliced form failed to re-parse' });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Identity gate before writing (same as the write/submit handler).
|
|
674
|
+
if (identity && !sameIdentity(filepath, identity)) {
|
|
675
|
+
return wsSendJson(socket, { type: 'error', code: 'EPATH', message: 'file identity changed since session start' });
|
|
676
|
+
}
|
|
677
|
+
// Atomic write. The watcher will see the change, hash will match
|
|
678
|
+
// what we just wrote, no echo.
|
|
679
|
+
const buf = Buffer.from(newDoc, 'utf-8');
|
|
680
|
+
try {
|
|
681
|
+
state.hash = hashContent(buf);
|
|
682
|
+
state.content = buf;
|
|
683
|
+
atomicWrite(filepath, buf);
|
|
684
|
+
} catch (e) {
|
|
685
|
+
return wsSendJson(socket, { type: 'error', code: e.code || 'EWRITE', message: e.message });
|
|
686
|
+
}
|
|
687
|
+
closeIdentity(identity);
|
|
688
|
+
identity = openIdentity(filepath);
|
|
689
|
+
|
|
690
|
+
wsSendJson(socket, {
|
|
691
|
+
type: 'form-submitted',
|
|
692
|
+
form_id: formId,
|
|
693
|
+
button_name: buttonName,
|
|
694
|
+
final: final,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Emit one event per successful submit. Agents reading the process's
|
|
698
|
+
// stdout (Claude Code, Codex, etc.) get a clean trigger; weak-shell
|
|
699
|
+
// harnesses can read --log-file instead.
|
|
700
|
+
const lastSubmission = next.submissions[next.submissions.length - 1];
|
|
701
|
+
emitSubmitEvent({
|
|
702
|
+
event: 'submit',
|
|
703
|
+
form_id: formId,
|
|
704
|
+
by: buttonName,
|
|
705
|
+
at: lastSubmission.at,
|
|
706
|
+
scope: lastSubmission.scope,
|
|
707
|
+
values: lastSubmission.values,
|
|
708
|
+
final: final,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
if (final || !keepOpen) {
|
|
712
|
+
wsSendJson(socket, { type: 'submitted' });
|
|
713
|
+
terminate({ kind: 'submit', code: 0 });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function emitSubmitEvent(ev) {
|
|
718
|
+
const line = JSON.stringify(ev) + '\n';
|
|
719
|
+
// Always write to stdout. In single-shot mode this lands right
|
|
720
|
+
// before exit, so the agent reads its full output once the process
|
|
721
|
+
// is done. In --keep-open mode each click writes another line and
|
|
722
|
+
// the agent tails. Startup chatter is on stderr so stdout is a
|
|
723
|
+
// clean event channel either way.
|
|
724
|
+
try { process.stdout.write(line); } catch (_) {}
|
|
725
|
+
if (eventLogFile) {
|
|
726
|
+
try { fs.appendFileSync(eventLogFile, line); } catch (_) {}
|
|
727
|
+
}
|
|
728
|
+
if (onEvent) {
|
|
729
|
+
try { onEvent(ev); } catch (_) {}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function scopedValuesOnly(values, allowed) {
|
|
734
|
+
const out = {};
|
|
735
|
+
Object.keys(values).forEach(k => {
|
|
736
|
+
if (allowed.indexOf(k) >= 0) out[k] = values[k];
|
|
737
|
+
});
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function pushExternal(change) {
|
|
742
|
+
if (!socket) return;
|
|
743
|
+
if (change.deleted) {
|
|
744
|
+
wsSendJson(socket, { type: 'error', code: 'ENOENT', message: 'file was deleted' });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
state.content = change.content;
|
|
748
|
+
state.hash = change.hash;
|
|
749
|
+
wsSendJson(socket, {
|
|
750
|
+
type: 'external-change',
|
|
751
|
+
// Same display transform as hello: wrapped files travel as their
|
|
752
|
+
// renderable fenced-block document.
|
|
753
|
+
content: wrapForDisplay(change.content.toString('utf-8'), filepath),
|
|
754
|
+
file: path.basename(filepath),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function attachSocket(sock) {
|
|
759
|
+
socket = sock;
|
|
760
|
+
everConnected = true;
|
|
761
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
762
|
+
if (noConnectTimer) { clearTimeout(noConnectTimer); noConnectTimer = null; }
|
|
763
|
+
bumpIdle();
|
|
764
|
+
|
|
765
|
+
parser = new WsParser({
|
|
766
|
+
onMessage: (_op, payload) => handleMessage(payload),
|
|
767
|
+
onPing: (d) => wsSend(sock, 0xA, d),
|
|
768
|
+
onPong: () => bumpIdle(),
|
|
769
|
+
onClose: () => detachSocket('peer-close'),
|
|
770
|
+
onError: () => detachSocket('parser-error'),
|
|
771
|
+
});
|
|
772
|
+
sock.on('data', (chunk) => parser.feed(chunk));
|
|
773
|
+
sock.on('error', () => detachSocket('socket-error'));
|
|
774
|
+
sock.on('end', () => detachSocket('socket-end'));
|
|
775
|
+
sock.on('close', () => detachSocket('socket-close'));
|
|
776
|
+
|
|
777
|
+
// Initial hello with the current file content. `message` is the agent's
|
|
778
|
+
// free-text prompt for feedback sessions; the browser renders it as a
|
|
779
|
+
// banner above the document. `null` outside feedback mode.
|
|
780
|
+
//
|
|
781
|
+
// `path` + `fullPath` mirror the legacy &local= URL fragment that the
|
|
782
|
+
// non-bridged `sdoc <file>` flow used to populate. They feed the
|
|
783
|
+
// "Rel. Path" / "Abs. Path" rows on the file-info card. Both are derived
|
|
784
|
+
// here (process.cwd is the CLI's working dir) so the browser never has
|
|
785
|
+
// to know about the user's filesystem layout.
|
|
786
|
+
const relFromCwd = path.relative(process.cwd(), filepath);
|
|
787
|
+
const localPath = (!relFromCwd.startsWith('..') && !path.isAbsolute(relFromCwd))
|
|
788
|
+
? './' + relFromCwd
|
|
789
|
+
: null;
|
|
790
|
+
wsSendJson(sock, {
|
|
791
|
+
type: 'hello',
|
|
792
|
+
file: path.basename(filepath),
|
|
793
|
+
// Wrapped files (.csv -> ```cells sheet, .mmd -> ```mermaid diagram)
|
|
794
|
+
// are sent as their renderable document, matching what the URL-snapshot
|
|
795
|
+
// path (io.js readContent) builds. Markdown goes through raw.
|
|
796
|
+
content: wrapForDisplay(state.content.toString('utf-8'), filepath),
|
|
797
|
+
mode,
|
|
798
|
+
message,
|
|
799
|
+
path: localPath,
|
|
800
|
+
fullPath: filepath,
|
|
801
|
+
capabilities: capsForMode(mode, filepath),
|
|
802
|
+
});
|
|
803
|
+
emit('connect', { firstTime: subs.connect.length === 0 || !subs._everEmitted });
|
|
804
|
+
// Mark first connect so reconnects don't re-emit "first" semantics if a
|
|
805
|
+
// caller cares.
|
|
806
|
+
subs._everEmitted = true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function detachSocket(_reason) {
|
|
810
|
+
if (!socket) return;
|
|
811
|
+
const s = socket;
|
|
812
|
+
socket = null;
|
|
813
|
+
parser = null;
|
|
814
|
+
try { s.destroy(); } catch (_) {}
|
|
815
|
+
if (terminated) return;
|
|
816
|
+
|
|
817
|
+
// Reload survival: wait for the same token to reconnect before exiting.
|
|
818
|
+
if (reconnectMs > 0) {
|
|
819
|
+
reconnectTimer = setTimeout(() => {
|
|
820
|
+
reconnectTimer = null;
|
|
821
|
+
const t = (mode === 'feedback')
|
|
822
|
+
? { kind: 'cancel', code: 2 }
|
|
823
|
+
: { kind: 'close', code: 0 };
|
|
824
|
+
terminate(t);
|
|
825
|
+
}, reconnectMs);
|
|
826
|
+
} else {
|
|
827
|
+
const t = (mode === 'feedback')
|
|
828
|
+
? { kind: 'cancel', code: 2 }
|
|
829
|
+
: { kind: 'close', code: 0 };
|
|
830
|
+
terminate(t);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ── HTTP + Upgrade ─────────────────────────────────────
|
|
835
|
+
const server = http.createServer((req, res) => {
|
|
836
|
+
// The bridge has no plain HTTP endpoints. Anything that isn't a WS
|
|
837
|
+
// upgrade gets 404.
|
|
838
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('bridge: not found');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
server.on('upgrade', (req, sock, head) => {
|
|
842
|
+
if (terminated) {
|
|
843
|
+
sock.write('HTTP/1.1 410 Gone\r\n\r\n');
|
|
844
|
+
try { sock.destroy(); } catch (_) {}
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const reject = (status, msg) => {
|
|
848
|
+
sock.write('HTTP/1.1 ' + status + '\r\nContent-Type: text/plain\r\nContent-Length: ' + Buffer.byteLength(msg) + '\r\n\r\n' + msg);
|
|
849
|
+
try { sock.destroy(); } catch (_) {}
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
// Origin pin: rejects other websites and other origins on this machine.
|
|
853
|
+
const origin = req.headers['origin'];
|
|
854
|
+
if (!isAllowedOrigin(origin, extra)) {
|
|
855
|
+
return reject('403 Forbidden', 'bridge: origin not allowed');
|
|
856
|
+
}
|
|
857
|
+
// Host header: blocks DNS-rebinding. The request must claim it's talking
|
|
858
|
+
// to our exact loopback host + bound port.
|
|
859
|
+
const addr = server.address();
|
|
860
|
+
const boundPort = addr ? addr.port : null;
|
|
861
|
+
if (!isAllowedHost(req.headers['host'], boundPort)) {
|
|
862
|
+
return reject('403 Forbidden', 'bridge: host not allowed');
|
|
863
|
+
}
|
|
864
|
+
// Token gate: 32-byte session secret in the query string.
|
|
865
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
866
|
+
const got = url.searchParams.get('token');
|
|
867
|
+
if (!got || got.length !== token.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(token))) {
|
|
868
|
+
return reject('401 Unauthorized', 'bridge: bad token');
|
|
869
|
+
}
|
|
870
|
+
// Single live connection per bridge.
|
|
871
|
+
if (socket) {
|
|
872
|
+
return reject('409 Conflict', 'bridge: already connected');
|
|
873
|
+
}
|
|
874
|
+
const key = req.headers['sec-websocket-key'];
|
|
875
|
+
const version = req.headers['sec-websocket-version'];
|
|
876
|
+
if (!key || version !== '13') {
|
|
877
|
+
return reject('400 Bad Request', 'bridge: bad WS handshake');
|
|
878
|
+
}
|
|
879
|
+
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
880
|
+
const lines = [
|
|
881
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
882
|
+
'Upgrade: websocket',
|
|
883
|
+
'Connection: Upgrade',
|
|
884
|
+
'Sec-WebSocket-Accept: ' + accept,
|
|
885
|
+
'', '',
|
|
886
|
+
];
|
|
887
|
+
sock.write(lines.join('\r\n'));
|
|
888
|
+
sock.setNoDelay(true);
|
|
889
|
+
attachSocket(sock);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// ── Listen ─────────────────────────────────────────────
|
|
893
|
+
return new Promise((resolve, reject) => {
|
|
894
|
+
server.once('error', reject);
|
|
895
|
+
server.listen(port, '127.0.0.1', () => {
|
|
896
|
+
const addr = server.address();
|
|
897
|
+
// Start watching now — the agent or another editor may write before the
|
|
898
|
+
// browser connects, and we want the hello message to carry fresh content.
|
|
899
|
+
// opts.watch === false skips the watcher entirely. Test escape hatch
|
|
900
|
+
// so tests can exercise the identity gate without racing the debounce.
|
|
901
|
+
if (opts.watch !== false) {
|
|
902
|
+
watchStop = startWatch(filepath, () => state.hash, (change) => {
|
|
903
|
+
if (change.deleted) {
|
|
904
|
+
if (socket) pushExternal(change);
|
|
905
|
+
state.content = Buffer.alloc(0);
|
|
906
|
+
state.hash = hashContent(state.content);
|
|
907
|
+
closeIdentity(identity);
|
|
908
|
+
identity = null;
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
state.content = change.content;
|
|
912
|
+
state.hash = change.hash;
|
|
913
|
+
// External editors (vim's backupcopy, JetBrains, etc.) commonly
|
|
914
|
+
// change the inode on save. Recapture so the user's next write
|
|
915
|
+
// doesn't trip the identity gate on a legitimate edit.
|
|
916
|
+
closeIdentity(identity);
|
|
917
|
+
identity = openIdentity(filepath);
|
|
918
|
+
if (socket) pushExternal(change);
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (noConnMs > 0) {
|
|
923
|
+
noConnectTimer = setTimeout(() => {
|
|
924
|
+
if (!everConnected) terminate({ kind: 'no-connect', code: 3 });
|
|
925
|
+
}, noConnMs);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
resolve({
|
|
929
|
+
port: addr.port,
|
|
930
|
+
token,
|
|
931
|
+
mode,
|
|
932
|
+
files: allowlist.slice(),
|
|
933
|
+
close() { terminate({ kind: 'close', code: 0 }); },
|
|
934
|
+
on(kind, cb) { (subs[kind] = subs[kind] || []).push(cb); return this; },
|
|
935
|
+
onSubmit(cb) { this.on('submit', cb); return this; },
|
|
936
|
+
onClose(cb) { this.on('close', cb); return this; },
|
|
937
|
+
onCancel(cb) { this.on('cancel', cb); return this; },
|
|
938
|
+
onConnect(cb) { this.on('connect', cb); return this; },
|
|
939
|
+
awaitTerminal() {
|
|
940
|
+
if (terminal) return Promise.resolve(terminal);
|
|
941
|
+
return new Promise(res => terminalWaiters.push(res));
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function capsForMode(mode, filepath) {
|
|
949
|
+
// Both 'open' and 'feedback' can save; 'feedback' additionally exposes the
|
|
950
|
+
// Done button (canSubmit). The exception is wrapped files (.csv / .mmd):
|
|
951
|
+
// their document is a derived view, so saving is off and the write handler
|
|
952
|
+
// refuses as a backstop.
|
|
953
|
+
return {
|
|
954
|
+
canSave: !isWrappedFile(filepath),
|
|
955
|
+
canWatch: true,
|
|
956
|
+
canSubmit: mode === 'feedback',
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ── Exports ───────────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
module.exports = {
|
|
963
|
+
startBridge,
|
|
964
|
+
// Exposed for tests:
|
|
965
|
+
WsParser,
|
|
966
|
+
encodeFrame,
|
|
967
|
+
hashContent,
|
|
968
|
+
isAllowedOrigin,
|
|
969
|
+
isAllowedHost,
|
|
970
|
+
resolveAllowedPath,
|
|
971
|
+
atomicWrite,
|
|
972
|
+
capsForMode,
|
|
973
|
+
WS_GUID,
|
|
974
|
+
};
|