@hncode/hncode 0.5.0 → 0.5.2

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.
Files changed (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
package/src/update.js CHANGED
@@ -1,116 +1,286 @@
1
- // Auto-update support for hncode.
2
- //
3
- // hncode is installed as a global npm package (@hncode/hncode). This module
4
- // checks npm for a newer version and, when one exists, installs it in the
5
- // background without ever blocking the TUI. The running process is unaffected —
6
- // the new files land on disk and take effect on next launch, which is the
7
- // "seamless / no-sense" behaviour we want.
8
- //
9
- // Everything here is fire-and-forget: check() and update() return Promises that
10
- // resolve to a status string, never throw to the caller. The TUI calls them from
11
- // timers / startup without awaiting.
12
-
13
- import { spawn } from 'node:child_process';
14
- import { localVersion } from './version.js';
15
-
16
- const PKG_NAME = '@hncode/hncode';
17
- // Spawn npm portably. On Windows npm is a `.cmd` shim, and Node refuses to spawn
18
- // a `.cmd` directly (EINVAL) — it must go through a shell. Using
19
- // `cmd.exe /c npm ...` keeps that behaviour while avoiding `shell: true`, which
20
- // Node warns about (with a shell, args are concatenated rather than escaped).
21
- function spawnNpm(args, opts) {
22
- if (process.platform === 'win32') return spawn('cmd.exe', ['/c', 'npm', ...args], opts);
23
- return spawn('npm', args, opts);
24
- }
25
- // How often the auto-check runs once we are past the startup check.
26
- export const AUTO_UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
27
- export { localVersion };
28
-
29
- // Latest version published to npm, or '' if the registry is unreachable /
30
- // not installed / not a package.
31
- //
32
- // ASYNC on purpose: `execSync` blocks the whole event loop, so a cold `npm view`
33
- // (measured at ~15s on a slow registry, vs ~2s warm) froze the TUI — no key
34
- // handling, no repaint — for the entire duration. `spawn` runs the same command
35
- // off the main thread, and the timeout kills it rather than waiting it out.
36
- export function remoteVersion(timeoutMs = 10000) {
37
- return new Promise((resolve) => {
38
- let child;
39
- try {
40
- child = spawnNpm(['view', PKG_NAME, 'version', '--silent'], {
41
- stdio: ['ignore', 'pipe', 'ignore'],
42
- });
43
- } catch {
44
- resolve('');
45
- return;
46
- }
47
- let out = '';
48
- let done = false;
49
- const finish = (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
50
- const timer = setTimeout(() => { try { child.kill(); } catch {} finish(''); }, timeoutMs);
51
- if (child.stdout) child.stdout.on('data', (d) => { out += d.toString(); });
52
- child.on('error', () => finish(''));
53
- child.on('close', () => finish((out || '').trim()));
54
- });
55
- }
56
-
57
- // Parse a semver-ish "x.y.z" (ignore prerelease tags) into comparable numbers.
58
- function parseSemver(v) {
59
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim());
60
- if (!m) return null;
61
- return { major: +m[1], minor: +m[2], patch: +m[3] };
62
- }
63
-
64
- // True when `remote` is strictly newer than `local`.
65
- export function isNewer(remote, local) {
66
- const r = parseSemver(remote), l = parseSemver(local);
67
- if (!r || !l) return false;
68
- if (r.major !== l.major) return r.major > l.major;
69
- if (r.minor !== l.minor) return r.minor > l.minor;
70
- return r.patch > l.patch;
71
- }
72
-
73
- // Spin up the actual install in the BACKGROUND. Detached + unref'd so it keeps
74
- // running after this process exits; stdout/err are swallowed (seamless).
75
- // Returns: { ok: true } or { ok: false, error }.
76
- export function updateBackground() {
77
- return new Promise((resolve) => {
78
- try {
79
- // `npm install -g` needs to run detached; no need to wait here.
80
- const child = spawnNpm(['install', '-g', `${PKG_NAME}@latest`], {
81
- detached: true,
82
- stdio: 'ignore',
83
- });
84
- child.unref();
85
- child.on('error', (e) => resolve({ ok: false, error: e.message }));
86
- // We consider it "started" — the actual completion is unknown and that is
87
- // fine; the next process will be the new version.
88
- resolve({ ok: true });
89
- } catch (e) {
90
- resolve({ ok: false, error: (e && e.message) || String(e) });
91
- }
92
- });
93
- }
94
-
95
- // Full auto-check: read remote, if newer start a background update.
96
- // Returns a status object. The TUI's auto-check IGNORES it (fully silent); the
97
- // /update command reports it.
98
- export async function checkAndUpdate() {
99
- const local = localVersion();
100
- if (!local) return { status: 'none', message: "can't read local version" };
101
- const remote = await remoteVersion();
102
- if (!remote) return { status: 'none', message: 'npm registry unreachable' };
103
-
104
- if (isNewer(remote, local)) {
105
- const r = await updateBackground();
106
- return {
107
- status: r.ok ? 'updated' : 'error',
108
- message: r.ok
109
- ? `New version ${remote} — installing in background (restart to use it)`
110
- : `Update failed: ${r.error}`,
111
- current: local,
112
- latest: remote,
113
- };
114
- }
115
- return { status: 'uptodate', message: `hncode is up to date (${local})`, current: local, latest: remote };
1
+ // Auto-update support for hncode.
2
+ //
3
+ // hncode is installed as a global npm package (@hncode/hncode). This module
4
+ // checks npm for a newer version and, when one exists, installs it in the
5
+ // background without ever blocking the TUI. The running process is unaffected —
6
+ // the new files land on disk and take effect on next launch, which is the
7
+ // "seamless / no-sense" behaviour we want.
8
+ //
9
+ // Everything here is fire-and-forget: check() and update() return Promises that
10
+ // resolve to a status string, never throw to the caller. The TUI calls them from
11
+ // timers / startup without awaiting.
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+ import { localVersion } from './version.js';
18
+
19
+ const PKG_NAME = '@hncode/hncode';
20
+ // Spawn npm portably. On Windows npm is a `.cmd` shim, and Node refuses to spawn
21
+ // a `.cmd` directly (EINVAL) — it must go through a shell. Using
22
+ // `cmd.exe /c npm ...` keeps that behaviour while avoiding `shell: true`, which
23
+ // Node warns about (with a shell, args are concatenated rather than escaped).
24
+ function spawnNpm(args, opts) {
25
+ if (process.platform === 'win32') return spawn('cmd.exe', ['/c', 'npm', ...args], opts);
26
+ return spawn('npm', args, opts);
27
+ }
28
+ // How often the auto-check runs once we are past the startup check.
29
+ export const AUTO_UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
30
+ export { localVersion };
31
+
32
+ // Latest version published to npm, or '' if the registry is unreachable /
33
+ // not installed / not a package.
34
+ //
35
+ // ASYNC on purpose: `execSync` blocks the whole event loop, so a cold `npm view`
36
+ // (measured at ~15s on a slow registry, vs ~2s warm) froze the TUI — no key
37
+ // handling, no repaint — for the entire duration. `spawn` runs the same command
38
+ // off the main thread, and the timeout kills it rather than waiting it out.
39
+ export function remoteVersion(timeoutMs = 10000) {
40
+ return new Promise((resolve) => {
41
+ let child;
42
+ try {
43
+ child = spawnNpm(['view', PKG_NAME, 'version', '--silent'], {
44
+ stdio: ['ignore', 'pipe', 'ignore'],
45
+ });
46
+ } catch {
47
+ resolve('');
48
+ return;
49
+ }
50
+ let out = '';
51
+ let done = false;
52
+ const finish = (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
53
+ const timer = setTimeout(() => { try { child.kill(); } catch {} finish(''); }, timeoutMs);
54
+ if (child.stdout) child.stdout.on('data', (d) => { out += d.toString(); });
55
+ child.on('error', () => finish(''));
56
+ child.on('close', () => finish((out || '').trim()));
57
+ });
58
+ }
59
+
60
+ // Parse a semver-ish `x.y.z` into comparable numbers, or null when it is not one.
61
+ //
62
+ // The leading `v` is stripped because npm accepts `v1.2.3` as a version and some mirrors
63
+ // echo it back; without this the string failed to parse and every update was invisible.
64
+ // The prerelease suffix is KEPT as a flag rather than discarded: `1.2.3-beta` has to rank
65
+ // BELOW `1.2.3`, and dropping the suffix made the two identical.
66
+ function parseSemver(v) {
67
+ const raw = String(v == null ? '' : v).trim();
68
+ const m = /^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(raw);
69
+ if (!m) return null;
70
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
71
+ }
72
+
73
+ // True when `remote` is strictly newer than `local`.
74
+ export function isNewer(remote, local) {
75
+ const r = parseSemver(remote), l = parseSemver(local);
76
+ if (!r || !l) return false;
77
+ if (r.major !== l.major) return r.major > l.major;
78
+ if (r.minor !== l.minor) return r.minor > l.minor;
79
+ if (r.patch !== l.patch) return r.patch > l.patch;
80
+ // Same release. A plain version outranks its own prerelease, so 1.2.3 is newer than
81
+ // 1.2.3-beta — but 1.2.3-beta is NOT newer than 1.2.3, which is what dropping the
82
+ // suffix got wrong.
83
+ if (r.pre === l.pre) return false;
84
+ if (!l.pre) return false; // local is the release, remote is a prerelease of it
85
+ if (!r.pre) return true; // remote is the release, local is the prerelease
86
+ return r.pre > l.pre; // both prereleases: lexical order is semver's rule here
87
+ }
88
+
89
+
90
+ // ---- install lock ------------------------------------------------------------
91
+ //
92
+ // Created with O_EXCL, which is atomic across processes: whoever wins the create owns the
93
+ // install, and everyone else reads the owner and stands down. A plain `existsSync` check
94
+ // followed by a write is a race — two sessions can pass the check before either writes.
95
+ export function updateLockFile() {
96
+ const dir = process.env.HNCODE_HOME || path.join(os.homedir(), '.hncode');
97
+ return path.join(dir, 'update.lock');
98
+ }
99
+
100
+ // Older than this and the lock is treated as abandoned. An install that outlives it is
101
+ // still running, but a lock that can never expire would disable updates for good — which is
102
+ // the worse failure, so the age wins.
103
+ const LOCK_STALE_MS = 15 * 60 * 1000;
104
+
105
+ /**
106
+ * Try to claim the install lock.
107
+ *
108
+ * Returns `{ ok: true }` when this process now owns it, or `{ ok: false, heldBy }` with the
109
+ * owner record (pid, version, when) so the caller can say who is installing instead of
110
+ * silently doing nothing. Never throws.
111
+ */
112
+ export function claimInstallLock(version = '') {
113
+ const file = updateLockFile();
114
+ const payload = JSON.stringify({ pid: process.pid, version, at: new Date().toISOString() });
115
+ try {
116
+ fs.mkdirSync(path.dirname(file), { recursive: true });
117
+ } catch { /* the open below reports the real problem */ }
118
+
119
+ for (let attempt = 0; attempt < 2; attempt++) {
120
+ try {
121
+ // 'wx' is O_CREAT|O_EXCL|O_WRONLY: it fails when the file exists, atomically.
122
+ fs.writeFileSync(file, payload, { flag: 'wx' });
123
+ return { ok: true };
124
+ } catch (e) {
125
+ if (e && e.code !== 'EEXIST') return { ok: false, heldBy: null };
126
+ // Someone holds it. Only break it if it is old enough to be abandoned.
127
+ let owner = null;
128
+ let age = 0;
129
+ try {
130
+ owner = JSON.parse(fs.readFileSync(file, 'utf8'));
131
+ age = Date.now() - new Date(owner.at || 0).getTime();
132
+ } catch {
133
+ // Unreadable: treat it as stale rather than as a permanent block.
134
+ age = LOCK_STALE_MS + 1;
135
+ }
136
+ if (age < LOCK_STALE_MS) return { ok: false, heldBy: owner };
137
+ try { fs.unlinkSync(file); } catch { return { ok: false, heldBy: owner }; }
138
+ // Loop once more: the next create either wins or reports the new owner.
139
+ }
140
+ }
141
+ return { ok: false, heldBy: null };
142
+ }
143
+
144
+ /** Release the install lock. Never throws. */
145
+ export function releaseInstallLock() {
146
+ try { fs.unlinkSync(updateLockFile()); return true; } catch { return false; }
147
+ }
148
+
149
+ /** Who holds the lock right now, or null. Never throws. */
150
+ export function installLockHolder() {
151
+ try { return JSON.parse(fs.readFileSync(updateLockFile(), 'utf8')); } catch { return null; }
152
+ }
153
+
154
+ // ---- recording what a background install did ---------------------------------
155
+ //
156
+ // An install cannot change the RUNNING process, so the only moment the user can learn
157
+ // about it is the next launch. That makes a file the natural channel: the detached npm
158
+ // writes its outcome here, and the next `startTUI` reads it.
159
+ export function updateStampFile() {
160
+ const dir = process.env.HNCODE_HOME || path.join(os.homedir(), '.hncode');
161
+ return path.join(dir, 'update.json');
162
+ }
163
+
164
+ /** The last install's outcome, or null. Never throws. */
165
+ export function readUpdateStamp() {
166
+ try { return JSON.parse(fs.readFileSync(updateStampFile(), 'utf8')); } catch { return null; }
167
+ }
168
+
169
+ /** Forget it, once it has been reported. Never throws. */
170
+ export function clearUpdateStamp() {
171
+ try { fs.unlinkSync(updateStampFile()); return true; } catch { return false; }
172
+ }
173
+
174
+ function writeUpdateStamp(obj) {
175
+ try {
176
+ fs.mkdirSync(path.dirname(updateStampFile()), { recursive: true });
177
+ fs.writeFileSync(updateStampFile(), JSON.stringify({ at: new Date().toISOString(), ...obj }));
178
+ } catch { /* the install is what matters; a lost note must not mask it */ }
179
+ }
180
+
181
+ // Run the install DETACHED and record how it went. Returns as soon as npm is started —
182
+ // `{ ok: true, pending: true }` — because waiting here would stall the caller for the length
183
+ // of an install. The outcome travels through the stamp file instead.
184
+ //
185
+ // stderr is CAPTURED rather than ignored. `stdio: 'ignore'` is what made every failure
186
+ // silent: an npm that could not write the global prefix said so to nobody.
187
+ export function updateBackground(version = '') {
188
+ return new Promise((resolve) => {
189
+ // ONE install at a time, across processes. The lock is the only thing that stops the
190
+ // startup check, the interval and /update from each spawning their own npm — the
191
+ // in-process guard spans only the version query, and another session shares nothing
192
+ // but this file.
193
+ const lock = claimInstallLock(version);
194
+ if (!lock.ok) {
195
+ const who = lock.heldBy;
196
+ const msg = who && who.pid
197
+ ? `an update to ${who.version || 'a newer version'} is already installing (pid ${who.pid})`
198
+ : 'an update is already installing';
199
+ // Not an error: the other install will finish and write the stamp. Saying so lets
200
+ // /update answer honestly instead of reporting a failure the user cannot act on.
201
+ resolve({ ok: false, busy: true, error: msg });
202
+ return;
203
+ }
204
+
205
+ let child;
206
+ try {
207
+ child = spawnNpm(['install', '-g', `${PKG_NAME}@latest`], {
208
+ detached: true,
209
+ stdio: ['ignore', 'ignore', 'pipe'],
210
+ });
211
+ } catch (e) {
212
+ releaseInstallLock();
213
+ const error = (e && e.message) || String(e);
214
+ writeUpdateStamp({ status: 'error', version, error });
215
+ resolve({ ok: false, error });
216
+ return;
217
+ }
218
+
219
+ let stderr = '';
220
+ if (child.stderr) {
221
+ child.stderr.setEncoding('utf8');
222
+ child.stderr.on('data', (d) => {
223
+ stderr += d;
224
+ if (stderr.length > 4000) stderr = stderr.slice(-4000);
225
+ });
226
+ }
227
+
228
+ // The lock is released on BOTH paths below. Leaving it behind on a failure would
229
+ // block every later update until it aged out, which is worse than the double install
230
+ // it prevents.
231
+ child.on('error', (e) => {
232
+ releaseInstallLock();
233
+ writeUpdateStamp({ status: 'error', version, error: (e && e.message) || String(e) });
234
+ });
235
+ child.on('close', (code) => {
236
+ releaseInstallLock();
237
+ // Exit code 0 is the only thing that means the files were replaced. Anything else —
238
+ // no permission, an unreachable registry, a killed process — is recorded with npm's
239
+ // own last words, which is what makes the next launch's message actionable.
240
+ if (code === 0) {
241
+ writeUpdateStamp({ status: 'installed', version });
242
+ } else {
243
+ const tail = stderr.trim().split(/\r?\n/).slice(-6).join('\n');
244
+ writeUpdateStamp({ status: 'error', version, code, error: tail || `npm exited ${code}` });
245
+ }
246
+ });
247
+
248
+ // Detached so the install outlives this process; the stamp file is how its result
249
+ // travels back. `unref` is what lets hncode exit while npm is still running.
250
+ child.unref();
251
+ resolve({ ok: true, pending: true });
252
+ });
253
+ }
254
+
255
+ // Full auto-check: read remote, if newer start a background update.
256
+ // Returns a status object. The TUI's auto-check IGNORES it (fully silent); the
257
+ // /update command reports it.
258
+ // Full auto-check: read the remote version and, when it is newer, START a background
259
+ // install. Returns a status object.
260
+ //
261
+ // The status says `installing`, not `updated`: this returns as soon as npm is spawned, and
262
+ // whether the files were actually replaced is only knowable from the stamp file afterwards.
263
+ // The old `updated` claimed a result nothing here could observe, which is how a failed
264
+ // install was reported to the user as a success.
265
+ export async function checkAndUpdate() {
266
+ const local = localVersion();
267
+ if (!local) return { status: 'none', message: "can't read local version" };
268
+ const remote = await remoteVersion();
269
+ if (!remote) return { status: 'none', message: 'npm registry unreachable' };
270
+
271
+ if (isNewer(remote, local)) {
272
+ const r = await updateBackground(remote);
273
+ return {
274
+ // `busy` is not `error`: another install is already running and will write the
275
+ // stamp itself. Reporting a failure would send the user looking for a problem
276
+ // that does not exist.
277
+ status: r.ok ? 'installing' : (r.busy ? 'busy' : 'error'),
278
+ message: r.ok
279
+ ? `New version ${remote} — installing in background; restart when it finishes`
280
+ : (r.busy ? `Already updating: ${r.error}` : `Update failed: ${r.error}`),
281
+ current: local,
282
+ latest: remote,
283
+ };
284
+ }
285
+ return { status: 'uptodate', message: `hncode is up to date (${local})`, current: local, latest: remote };
116
286
  }
package/src/web-daemon.js CHANGED
@@ -273,6 +273,20 @@ const MIME = {
273
273
  '.ttf': 'font/ttf',
274
274
  };
275
275
 
276
+ /**
277
+ * Whether a session id from a URL may be turned into a filename.
278
+ *
279
+ * Session ids are generated by the store as plain hex, so any character outside
280
+ * that set is already malformed — and the SEPARATORS are the dangerous ones,
281
+ * because `path.join(dir, id + '.json')` would walk out of the sessions
282
+ * directory. Rejecting the dot as well as both separators is deliberate: a bare
283
+ * `..` needs no separator to escape.
284
+ */
285
+ function isSafeSessionId(id) {
286
+ if (typeof id !== 'string' || !id) return false;
287
+ return /^[A-Za-z0-9_-]+$/.test(id);
288
+ }
289
+
276
290
  export function readAsset(urlPath) {
277
291
  const rel = String(urlPath || '/').split('?')[0];
278
292
  // NOTE: '/' is NOT mapped to index.html here. The daemon serves TWO shells —
@@ -502,7 +516,13 @@ export async function startWebDaemon(opts = {}) {
502
516
  const registry = opts.registry || new Registry();
503
517
  const idleExit = opts.idleExit !== false;
504
518
  const loopback = isLoopback(host);
505
- const expectedHosts = [];
519
+ // Fixed at startup, never appended to. The same-origin check reads this list, so
520
+ // seeding it from request headers would let an attacker widen it: one cross-origin
521
+ // GET plants their own Host, and every later cross-origin POST then passes Origin.
522
+ const addr = host.includes(':') ? `[${host}]` : host;
523
+ const expectedHosts = [...new Set([
524
+ `${addr}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`,
525
+ ])];
506
526
  // The bind address decides whether a token is required, matching the HTTP rule:
507
527
  // on loopback the daemon is reachable only from this machine (reading config.toml
508
528
  // grants the same access), but bound beyond loopback it is on the network and the
@@ -567,11 +587,8 @@ export async function startWebDaemon(opts = {}) {
567
587
  });
568
588
  }
569
589
 
570
- // The Origin check accepts the host the browser actually used, plus localhost
571
- // on the same port (a user may reach the daemon either way).
572
590
  function noteHost(req) {
573
591
  const h = String(req.headers.host || '');
574
- if (h && !expectedHosts.includes(h)) expectedHosts.push(h);
575
592
  const bare = h.replace(/:\d+$/, '');
576
593
  for (const alt of [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]) {
577
594
  if (!expectedHosts.includes(alt) && (bare === 'localhost' || bare === '127.0.0.1' || bare === '::1')) {
@@ -683,6 +700,12 @@ export async function startWebDaemon(opts = {}) {
683
700
  const m = /^\/s\/([^/]+)(\/.*)?$/.exec(p);
684
701
  if (m) {
685
702
  const id = decodeURIComponent(m[1]);
703
+ // The id reaches `path.join(sessionsDir, id + '.json')` in the session store,
704
+ // so `%2e%2e%2f%2e%2e%2fpackage` would read any JSON on disk. `readAsset` has
705
+ // the same check for assets; the session id needed its own. Refusing here also
706
+ // rejects the empty id, which would otherwise address `<sessionsDir>.json`.
707
+ if (!isSafeSessionId(id)) return send(res, 404, 'not found');
708
+
686
709
  // `/s/<id>` must become `/s/<id>/` before anything is served. The session
687
710
  // page loads its assets and calls its API through RELATIVE paths (so that
688
711
  // the same files work both standalone and behind this prefix); without the