@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.
- package/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +1 -1
- package/src/agent-task.js +12 -1
- package/src/agent.js +115 -35
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +42 -6
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/context-bar.js +46 -5
- package/src/doctor.js +214 -11
- package/src/git.js +35 -4
- package/src/llm.js +6 -0
- package/src/memory-profile.js +247 -0
- package/src/permissions.js +29 -7
- package/src/registry-browser.js +21 -6
- package/src/row-cache.js +147 -142
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-hub.js +1 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +304 -0
- package/src/shell-safety.js +95 -6
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +139 -0
- package/src/team/runner.js +347 -0
- package/src/team/spawn.js +247 -0
- package/src/team/store.js +145 -0
- package/src/team/tasks.js +164 -0
- package/src/team/team.js +311 -0
- package/src/team-panel.js +226 -0
- package/src/term.js +27 -0
- package/src/tool-result.js +279 -236
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +53 -3
- package/src/tools/edit.js +591 -436
- package/src/tools/git.js +15 -3
- package/src/tools/index.js +2 -0
- package/src/tools/team.js +398 -0
- package/src/tools/web-search.js +88 -75
- package/src/tui.js +6187 -4780
- package/src/update.js +285 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
- 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
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
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
|
-
|
|
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
|