@logicseek/hello-cc 1.0.2 → 1.0.3

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.
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import process from 'node:process';
3
4
  import { DatabaseSync } from 'node:sqlite';
4
5
  import { randomBytes } from 'node:crypto';
5
6
  import { CliError } from '../../shared/errors.mjs';
@@ -11,12 +12,62 @@ function statOrMissing(file) {
11
12
 
12
13
  function directory(file, create) {
13
14
  let stat = statOrMissing(file);
14
- if (!stat && create) { fs.mkdirSync(file, { mode: 0o700 }); stat = fs.lstatSync(file); }
15
+ if (!stat && create) {
16
+ try { fs.mkdirSync(file, { mode: 0o700 }); }
17
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
18
+ stat = fs.lstatSync(file);
19
+ }
15
20
  if (!stat) return false;
16
21
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError('NATIVE_STATE_UNSAFE', `Native state must be a real directory: ${file}`);
22
+ if (create) {
23
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
24
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native state must be owned by the current user: ${file}`);
25
+ }
26
+ const fd = fs.openSync(file, fs.constants.O_RDONLY |
27
+ (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
28
+ try {
29
+ const opened = fs.fstatSync(fd);
30
+ if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
31
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native state directory changed: ${file}`);
32
+ }
33
+ if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) fs.fchmodSync(fd, 0o700);
34
+ const current = fs.lstatSync(file);
35
+ if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
36
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native state directory changed: ${file}`);
37
+ }
38
+ } finally { fs.closeSync(fd); }
39
+ }
17
40
  return true;
18
41
  }
19
42
 
43
+ function privateDatabase(file) {
44
+ let stat = statOrMissing(file);
45
+ if (!stat) {
46
+ try {
47
+ const fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_CREAT |
48
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
49
+ fs.closeSync(fd);
50
+ } catch (error) { if (error?.code !== 'EEXIST') throw error; }
51
+ stat = fs.lstatSync(file);
52
+ }
53
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
54
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
55
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native database must be an owned regular file: ${file}`);
56
+ }
57
+ const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
58
+ try {
59
+ const opened = fs.fstatSync(fd);
60
+ if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
61
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native database changed during validation: ${file}`);
62
+ }
63
+ if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) fs.fchmodSync(fd, 0o600);
64
+ const current = fs.lstatSync(file);
65
+ if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
66
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native database changed during validation: ${file}`);
67
+ }
68
+ } finally { fs.closeSync(fd); }
69
+ }
70
+
20
71
  export function nativePaths(ctx, { create = false } = {}) {
21
72
  const root = fs.realpathSync(ctx.root);
22
73
  const parent = path.join(root, '.hello-cc');
@@ -60,8 +111,8 @@ export function readNativePointer(ctx) {
60
111
 
61
112
  export function createNativeStore(ctx) {
62
113
  const paths = nativePaths(ctx, { create: true });
114
+ privateDatabase(paths.db);
63
115
  const db = new DatabaseSync(paths.db, { timeout: 5000 });
64
- fs.chmodSync(paths.db, 0o600);
65
116
  db.exec(`PRAGMA journal_mode = WAL;
66
117
  PRAGMA busy_timeout = 5000;
67
118
  PRAGMA foreign_keys = ON;
@@ -72,11 +123,16 @@ export function createNativeStore(ctx) {
72
123
  CREATE TABLE IF NOT EXISTS deliveries (
73
124
  id INTEGER PRIMARY KEY, peer TEXT NOT NULL, message_id INTEGER NOT NULL,
74
125
  submission_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL, turn_id TEXT,
75
- detail TEXT, updated_at INTEGER NOT NULL, UNIQUE(peer, message_id)
126
+ detail TEXT, origin TEXT NOT NULL DEFAULT 'peer', updated_at INTEGER NOT NULL, UNIQUE(peer, message_id)
76
127
  );
77
128
  CREATE TABLE IF NOT EXISTS provider_events (
78
129
  id INTEGER PRIMARY KEY, peer TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL
79
130
  );`);
131
+ // Existing deliveries came from the shared message bus. Migration must not
132
+ // promote a stored sender name into user authority.
133
+ if (!db.prepare('PRAGMA table_info(deliveries)').all().some(column => column.name === 'origin')) {
134
+ db.exec("ALTER TABLE deliveries ADD COLUMN origin TEXT NOT NULL DEFAULT 'peer'");
135
+ }
80
136
  const now = () => Date.now();
81
137
  return {
82
138
  db,
@@ -88,9 +144,10 @@ export function createNativeStore(ctx) {
88
144
  cwd=excluded.cwd, status=excluded.status, updated_at=excluded.updated_at`)
89
145
  .run(worker.peer, worker.provider, worker.sessionId || null, worker.cwd, worker.status, now());
90
146
  },
91
- queue(peer, messageId, submissionId) {
92
- db.prepare(`INSERT OR IGNORE INTO deliveries(peer,message_id,submission_id,state,updated_at) VALUES (?,?,?,'queued',?)`)
93
- .run(peer, messageId, submissionId, now());
147
+ queue(peer, messageId, submissionId, origin = 'peer') {
148
+ if (!['peer', 'user'].includes(origin)) throw new CliError('BAD_ARGS', 'Unknown native message origin');
149
+ db.prepare(`INSERT OR IGNORE INTO deliveries(peer,message_id,submission_id,state,origin,updated_at) VALUES (?,?,?,'queued',?,?)`)
150
+ .run(peer, messageId, submissionId, origin, now());
94
151
  return this.delivery(peer, messageId);
95
152
  },
96
153
  delivery(peer, messageId) { return db.prepare('SELECT * FROM deliveries WHERE peer=? AND message_id=?').get(peer, messageId) || null; },
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import process from 'node:process';
3
4
  import { CliError } from '../shared/errors.mjs';
4
5
 
5
6
  function pathIsWithin(parent, candidate) {
@@ -22,7 +23,40 @@ function canonicalProjectRoot(root) {
22
23
  }
23
24
  }
24
25
 
25
- function ensureStateDirectory(root, createStateDir) {
26
+ function privateDirectory(directory, description) {
27
+ let stat;
28
+ try { stat = fs.lstatSync(directory); }
29
+ catch { throw projectPathForbidden(`Cannot inspect ${description}: ${directory}`); }
30
+ if (stat.isSymbolicLink() || !stat.isDirectory() ||
31
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
32
+ throw projectPathForbidden(`${description} must be an owned real directory: ${directory}`);
33
+ }
34
+
35
+ let fd;
36
+ try {
37
+ fd = fs.openSync(directory, fs.constants.O_RDONLY |
38
+ (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
39
+ const opened = fs.fstatSync(fd);
40
+ if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
41
+ throw projectPathForbidden(`${description} changed during validation: ${directory}`);
42
+ }
43
+ if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) {
44
+ fs.fchmodSync(fd, 0o700);
45
+ }
46
+ const current = fs.lstatSync(directory);
47
+ if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
48
+ throw projectPathForbidden(`${description} changed during validation: ${directory}`);
49
+ }
50
+ return fs.realpathSync(directory);
51
+ } catch (error) {
52
+ if (error instanceof CliError) throw error;
53
+ throw projectPathForbidden(`Cannot secure ${description}: ${directory}`);
54
+ } finally {
55
+ if (fd !== undefined) fs.closeSync(fd);
56
+ }
57
+ }
58
+
59
+ function ensureStateDirectory(root, createStateDir, secureExisting = createStateDir) {
26
60
  const stateDir = path.join(root, '.hello-cc');
27
61
  let stateStat;
28
62
  try {
@@ -35,8 +69,9 @@ function ensureStateDirectory(root, createStateDir) {
35
69
  try {
36
70
  fs.mkdirSync(stateDir, { mode: 0o700 });
37
71
  stateStat = fs.lstatSync(stateDir);
38
- } catch {
39
- throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
72
+ } catch (error) {
73
+ if (error?.code === 'EEXIST') stateStat = fs.lstatSync(stateDir);
74
+ else throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
40
75
  }
41
76
  }
42
77
 
@@ -44,7 +79,9 @@ function ensureStateDirectory(root, createStateDir) {
44
79
  throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
45
80
  }
46
81
  try {
47
- const canonical = fs.realpathSync(stateDir);
82
+ const canonical = secureExisting
83
+ ? privateDirectory(stateDir, 'Project state directory')
84
+ : fs.realpathSync(stateDir);
48
85
  if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
49
86
  throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
50
87
  }
@@ -55,13 +92,26 @@ function ensureStateDirectory(root, createStateDir) {
55
92
  }
56
93
  }
57
94
 
58
- function validateDatabaseParent(stateDir, db) {
95
+ export function secureExistingProjectStateDirectory(root) {
96
+ const canonicalRoot = canonicalProjectRoot(root);
97
+ return ensureStateDirectory(canonicalRoot, false, true);
98
+ }
99
+
100
+ function validateDatabaseParent(stateDir, db, createParents = false) {
59
101
  const parent = path.dirname(db);
60
102
  const relative = path.relative(stateDir, parent);
61
103
  const segments = relative === '' ? [] : relative.split(path.sep);
62
104
  let current = stateDir;
63
105
  for (const segment of segments) {
64
106
  current = path.join(current, segment);
107
+ if (createParents) {
108
+ try { fs.mkdirSync(current, { mode: 0o700 }); }
109
+ catch (error) {
110
+ if (error?.code !== 'EEXIST') {
111
+ throw projectPathForbidden(`Cannot create database parent directory: ${current}`);
112
+ }
113
+ }
114
+ }
65
115
  let stat;
66
116
  try {
67
117
  stat = fs.lstatSync(current);
@@ -71,6 +121,9 @@ function validateDatabaseParent(stateDir, db) {
71
121
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
72
122
  throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
73
123
  }
124
+ if (createParents && privateDirectory(current, 'Database parent directory') !== current) {
125
+ throw projectPathForbidden(`Database parent directory changed: ${current}`);
126
+ }
74
127
  }
75
128
 
76
129
  try {
@@ -107,7 +160,7 @@ function validateDatabaseTarget(stateDir, db) {
107
160
  }
108
161
  }
109
162
 
110
- export function resolveProjectDatabase({ root, db, createStateDir = false }) {
163
+ export function resolveProjectDatabase({ root, db, createStateDir = false, createDatabaseParents = false }) {
111
164
  const requestedRoot = path.resolve(String(root || ''));
112
165
  const canonicalRoot = canonicalProjectRoot(requestedRoot);
113
166
  const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
@@ -130,10 +183,40 @@ export function resolveProjectDatabase({ root, db, createStateDir = false }) {
130
183
  return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };
131
184
  }
132
185
 
133
- validateDatabaseParent(state.stateDir, requestedDb);
186
+ validateDatabaseParent(state.stateDir, requestedDb, Boolean(createDatabaseParents));
134
187
  return {
135
188
  root: canonicalRoot,
136
189
  stateDir: state.stateDir,
137
190
  db: validateDatabaseTarget(state.stateDir, requestedDb)
138
191
  };
139
192
  }
193
+
194
+ export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs', { create = true } = {}) {
195
+ if (!directoryName || directoryName !== path.basename(directoryName) ||
196
+ directoryName === '.' || directoryName === '..') {
197
+ throw projectPathForbidden(`Invalid buffer directory name: ${directoryName}`);
198
+ }
199
+ const canonicalRoot = canonicalProjectRoot(root);
200
+ const state = ensureStateDirectory(canonicalRoot, create, true);
201
+ if (!state.exists) return null;
202
+ const { stateDir } = state;
203
+ const directory = path.join(stateDir, directoryName);
204
+ if (create) {
205
+ try { fs.mkdirSync(directory, { mode: 0o700 }); }
206
+ catch (error) {
207
+ if (error?.code !== 'EEXIST') {
208
+ throw projectPathForbidden(`Cannot create buffer directory: ${directory}`);
209
+ }
210
+ }
211
+ } else {
212
+ try { fs.lstatSync(directory); }
213
+ catch (error) {
214
+ if (error?.code === 'ENOENT') return null;
215
+ throw projectPathForbidden(`Cannot inspect buffer directory: ${directory}`);
216
+ }
217
+ }
218
+ if (privateDirectory(directory, 'Buffer directory') !== directory) {
219
+ throw projectPathForbidden(`Buffer directory escapes project state directory: ${directory}`);
220
+ }
221
+ return directory;
222
+ }
@@ -23,6 +23,7 @@ const PORT_CANDIDATE_COUNT = 4;
23
23
  const STARTUP_GRACE_MS = 5000;
24
24
  const RELEASE_GRACE_MS = 5000;
25
25
  const WORKER_URL = new URL('./socket-lock-worker.mjs', import.meta.url);
26
+ const leaseResources = new WeakMap();
26
27
  const WORKER_BOOTSTRAP = String.raw`
27
28
  import('node:worker_threads').then(({ parentPort, workerData }) => {
28
29
  const state = new Int32Array(workerData.stateBuffer);
@@ -224,7 +225,14 @@ function terminateWorker(worker, primary, key) {
224
225
  }
225
226
 
226
227
  function acquisitionError(status, resources) {
227
- if (status === STATE.BUSY) return busyError(resources.location.key);
228
+ if (status === STATE.BUSY) {
229
+ if (Atomics.load(resources.state, DETAIL_INDEX) === 2) {
230
+ const error = new Error(`A listener without an identity handshake may own the legacy lock: ${resources.location.key}`);
231
+ error.code = 'ERR_FILE_LOCK_LEGACY_OWNER';
232
+ return error;
233
+ }
234
+ return busyError(resources.location.key);
235
+ }
228
236
  if (status === STATE.TIMEOUT) return timeoutError(resources.location.key);
229
237
  if (status === STATE.FAILED) {
230
238
  return injectedFailure(resources.worker, resources.state, 'startup', resources.location.key);
@@ -250,7 +258,8 @@ function acquireWorker(location, options, workerFactory) {
250
258
  moduleUrl: WORKER_URL.href,
251
259
  timeoutMs: options.timeoutMs,
252
260
  retryMs: options.retryMs,
253
- nonblocking: options.nonblocking
261
+ nonblocking: options.nonblocking,
262
+ rejectLegacyOwner: options.rejectLegacyOwner
254
263
  },
255
264
  state,
256
265
  states: STATE
@@ -264,7 +273,13 @@ function acquireWorker(location, options, workerFactory) {
264
273
  // A module-load failure happens before the Worker can publish FAILED.
265
274
  // Suppress the otherwise-unhandled event; the bounded startup wait below
266
275
  // converts this path to ERR_FILE_LOCK_WORKER_START_TIMEOUT.
267
- resources.worker.on('error', () => {});
276
+ const lost = () => {
277
+ if (Atomics.compareExchange(state, STATE_INDEX, STATE.ACQUIRED, STATE.FAILED) === STATE.ACQUIRED) {
278
+ Atomics.notify(state, STATE_INDEX);
279
+ }
280
+ };
281
+ resources.worker.on('error', lost);
282
+ resources.worker.on('exit', lost);
268
283
  }
269
284
  resources.worker.unref();
270
285
  const waitMs = STARTUP_GRACE_MS +
@@ -323,20 +338,18 @@ function releaseWorker(resources, primary) {
323
338
  : error;
324
339
  }
325
340
 
326
- export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
341
+ // A lease may span asynchronous work. Its caller must release it only after all
342
+ // work protected by the ownership boundary has conclusively stopped.
343
+ export function createFileLockLease({ workerFactory = defaultWorkerFactory } = {}) {
327
344
  if (typeof workerFactory !== 'function') {
328
345
  throw new TypeError('file lock workerFactory must be a function');
329
346
  }
330
- return function withConfiguredFileLock(
331
- target,
332
- fn,
333
- options = {}
334
- ) {
347
+ return function (target, options = {}) {
335
348
  const timeoutMs = options.timeoutMs === undefined ? 5000 : options.timeoutMs;
336
349
  const retryMs = options.retryMs === undefined ? 25 : options.retryMs;
337
350
  const nonblocking = options.nonblocking === undefined ? false : options.nonblocking;
338
351
  const createParent = options.createParent === undefined ? true : options.createParent;
339
- if (typeof fn !== 'function') throw new TypeError('file lock callback must be a function');
352
+ const rejectLegacyOwner = options.rejectLegacyOwner === undefined ? false : options.rejectLegacyOwner;
340
353
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
341
354
  throw new RangeError('file lock timeoutMs must be a non-negative finite number');
342
355
  }
@@ -349,18 +362,51 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
349
362
  if (typeof createParent !== 'boolean') {
350
363
  throw new TypeError('file lock createParent must be a boolean');
351
364
  }
352
- if (fn.constructor?.name === 'AsyncFunction') {
353
- throw new TypeError('withFileLock requires a synchronous callback');
365
+ if (typeof rejectLegacyOwner !== 'boolean') {
366
+ throw new TypeError('file lock rejectLegacyOwner must be a boolean');
354
367
  }
355
-
356
368
  const location = canonicalTarget(target, { createParent });
357
- const resources = acquireWorker(location, { timeoutMs, retryMs, nonblocking }, workerFactory);
369
+ const resources = acquireWorker(location, { timeoutMs, retryMs, nonblocking, rejectLegacyOwner }, workerFactory);
370
+ const lease = Object.freeze({
371
+ canonicalTarget: location.canonicalTarget,
372
+ assertHeld() {
373
+ if (Atomics.load(resources.state, STATE_INDEX) !== STATE.ACQUIRED || leaseResources.get(lease).finished) {
374
+ const error = new Error(`file lock ownership is no longer confirmed: ${location.key}`);
375
+ error.code = 'ERR_FILE_LOCK_LOST';
376
+ throw error;
377
+ }
378
+ },
379
+ release() { finishLease(lease, NO_ERROR); }
380
+ });
381
+ leaseResources.set(lease, { resources, finished: false, error: NO_ERROR });
382
+ return lease;
383
+ };
384
+ }
385
+
386
+ function finishLease(lease, primary) {
387
+ const entry = leaseResources.get(lease);
388
+ if (!entry.finished) {
389
+ entry.finished = true;
390
+ entry.error = releaseWorker(entry.resources, primary);
391
+ }
392
+ if (entry.error !== NO_ERROR) throw entry.error;
393
+ if (primary !== NO_ERROR) throw primary;
394
+ }
395
+
396
+ export const acquireFileLock = createFileLockLease();
397
+
398
+ export function createFileLock(options = {}) {
399
+ const acquire = createFileLockLease(options);
400
+ return function withConfiguredFileLock(target, fn, lockOptions = {}) {
401
+ if (typeof fn !== 'function') throw new TypeError('file lock callback must be a function');
402
+ if (fn.constructor?.name === 'AsyncFunction') throw new TypeError('withFileLock requires a synchronous callback');
403
+ const lease = acquire(target, lockOptions);
358
404
  let result;
359
405
  let primary = NO_ERROR;
360
406
  try {
361
407
  // Protected I/O must use this fixed path. The supplied parent alias may
362
408
  // be retargeted after acquisition and belongs to a different lock domain.
363
- result = fn(location.canonicalTarget);
409
+ result = fn(lease.canonicalTarget);
364
410
  if (result && typeof result.then === 'function') {
365
411
  Promise.resolve(result).catch(() => {});
366
412
  throw new TypeError('withFileLock requires a synchronous callback');
@@ -368,8 +414,7 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
368
414
  } catch (error) {
369
415
  primary = error;
370
416
  }
371
- const finalError = releaseWorker(resources, primary);
372
- if (finalError !== NO_ERROR) throw finalError;
417
+ finishLease(lease, primary);
373
418
  return result;
374
419
  };
375
420
  }
@@ -139,14 +139,14 @@ export function runSocketLockWorker(workerData) {
139
139
  let received = '';
140
140
  let settled = false;
141
141
  let socket;
142
- const settle = (matching) => {
142
+ const settle = (matching, legacy = false) => {
143
143
  if (settled) return;
144
144
  settled = true;
145
145
  clearTimeout(timer);
146
146
  try { socket?.destroy(); } catch {}
147
- callback(matching);
147
+ callback(matching, legacy);
148
148
  };
149
- const timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
149
+ const timer = setTimeout(() => settle(false, received.length === 0), PROBE_TIMEOUT_MS);
150
150
  try {
151
151
  socket = net.createConnection({ host: '127.0.0.1', port });
152
152
  socket.setEncoding('utf8');
@@ -156,7 +156,10 @@ export function runSocketLockWorker(workerData) {
156
156
  settle(received === expected);
157
157
  }
158
158
  });
159
- socket.once('end', () => settle(received === expected));
159
+ // The previous native runtime owner accepted and immediately ended the
160
+ // connection with no banner. Native leases must not treat it as an
161
+ // unrelated service and start a second runtime on another candidate.
162
+ socket.once('end', () => settle(received === expected, received.length === 0));
160
163
  socket.once('error', () => settle(false));
161
164
  } catch {
162
165
  settle(false);
@@ -184,9 +187,15 @@ export function runSocketLockWorker(workerData) {
184
187
  requestTerminalFailure();
185
188
  return;
186
189
  }
187
- probeOccupant(port, target.identity, (matching) => {
190
+ probeOccupant(port, target.identity, (matching, legacy) => {
188
191
  if (finished || attemptGeneration !== generation) return;
189
- if (matching) retryCleanly(attemptGeneration);
192
+ if (legacy && workerData.rejectLegacyOwner) {
193
+ generation += 1;
194
+ closeServers((error) => {
195
+ if (error) requestTerminalFailure();
196
+ else finish(states.BUSY, 2);
197
+ });
198
+ } else if (matching) retryCleanly(attemptGeneration);
190
199
  else bindCandidate(targetIndex, candidateIndex + 1, boundForTarget, attemptGeneration);
191
200
  });
192
201
  };
@@ -10,6 +10,7 @@ const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
10
10
  const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
11
11
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
12
12
  const MAX_TEXT = 65536;
13
+ const STREAM_UPDATE_MS = 50;
13
14
 
14
15
  export class CodexAppServerError extends Error {
15
16
  constructor(code, message, extra = {}) {
@@ -70,6 +71,8 @@ export function createCodexAppServer({
70
71
  let buffer = '';
71
72
  let truncated = false;
72
73
  let closePromise = null;
74
+ let streamTimer = null;
75
+ let streamEvent = null;
73
76
  const threads = new Map();
74
77
  const pending = new Map();
75
78
  const approvals = new Map();
@@ -111,11 +114,26 @@ export function createCodexAppServer({
111
114
  });
112
115
  }
113
116
 
117
+ function publishChange(event) {
118
+ clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
119
+ try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
120
+ }
121
+
114
122
  function changed(method, params = {}) {
115
123
  const event = { method, params: bounded(params, 8192), at: Date.now() };
116
124
  events.push(event);
117
125
  if (events.length > 100) events.shift();
118
- try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
126
+ // Keep protocol state current on every token, but copy/broadcast its complete
127
+ // retained history at most once per interval during a text-only burst.
128
+ // Approval, completion, error and connection changes publish immediately;
129
+ // their snapshot includes all pending text and cancels the trailing timer.
130
+ if (method === 'item/agentMessage/delta') {
131
+ streamEvent = event;
132
+ if (!streamTimer) {
133
+ streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
134
+ streamTimer.unref?.();
135
+ }
136
+ } else publishChange(event);
119
137
  }
120
138
 
121
139
  function ensureThread(id) {
@@ -81,6 +81,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
81
81
  binding: { transport: 'app-server', runtimeSessionId: id } });
82
82
  return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
83
83
  }, onChange(state) {
84
+ session.executorId ||= state.executorId;
84
85
  broadcast(session, { type: 'codex_state', state });
85
86
  if (state.status === 'disconnected' && !closeScheduled) {
86
87
  closeScheduled = true;
@@ -114,6 +115,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
114
115
  throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
115
116
  }
116
117
  session.pid = executorState.pid || null;
118
+ session.executorId = executorState.executorId;
117
119
  const save = connectWebProject(pctx);
118
120
  try {
119
121
  tx(save, () => {
@@ -99,7 +99,7 @@ export function createCookieAuth({
99
99
  }
100
100
 
101
101
  function webAuthMode(url, req) {
102
- if (authOk(url, req, token)) return 'token';
102
+ if (authOk(url, req, token, { trustProxy, proxyOrigin })) return 'token';
103
103
  return cookieSessionOk(req) ? 'cookie' : null;
104
104
  }
105
105
 
@@ -7,12 +7,12 @@ import process from 'node:process';
7
7
  import { URL } from 'node:url';
8
8
  import { publicCliFailure, CliError } from '../shared/errors.mjs';
9
9
  import { required } from '../cli-args.mjs';
10
- import { packageRoot } from '../cli-runtime.mjs';
10
+ import { terminalAssetPath } from './terminal-assets.mjs';
11
11
  import { projectRecord } from '../runtime/projects.mjs';
12
12
  import { writeRuntime } from '../runtime/state.mjs';
13
13
  import { normalizeStateResources } from '../ui/state-render.mjs';
14
14
  import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
15
- import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
15
+ import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches, tokenlessRequestAllowed } from '../web/http.mjs';
16
16
  import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
17
17
  import { isDshHookBinding } from '../core/peers/bindings.mjs';
18
18
  import { writeAdoptionState } from './adoption-state.mjs';
@@ -48,8 +48,12 @@ export function createHttpRoutes(deps) {
48
48
  }
49
49
 
50
50
  const handleWebRequest = async (req, res) => {
51
- const url = requestUrl(req);
52
51
  try {
52
+ const url = requestUrl(req);
53
+ if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
54
+ sendJson(res, 403, { ok: false, error: { code: 'UNTRUSTED_HOST', message: 'Tokenless access requires a trusted local Host' } });
55
+ return;
56
+ }
53
57
  if (req.method === 'GET' && url.pathname === '/') {
54
58
  const accept = req.headers.accept || '';
55
59
  const isBrowserNav = req.headers['sec-fetch-mode'] === 'navigate' || accept.includes('text/html');
@@ -85,19 +89,19 @@ export function createHttpRoutes(deps) {
85
89
  return;
86
90
  }
87
91
  if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
88
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js'), 'application/javascript; charset=utf-8');
92
+ sendFile(res, terminalAssetPath('xterm.js'), 'application/javascript; charset=utf-8');
89
93
  return;
90
94
  }
91
95
  if (req.method === 'GET' && url.pathname === '/assets/xterm.css') {
92
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), 'text/css; charset=utf-8');
96
+ sendFile(res, terminalAssetPath('xterm.css'), 'text/css; charset=utf-8');
93
97
  return;
94
98
  }
95
99
  if (req.method === 'GET' && url.pathname === '/assets/addon-fit.js') {
96
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-fit', 'lib', 'addon-fit.js'), 'application/javascript; charset=utf-8');
100
+ sendFile(res, terminalAssetPath('addon-fit.js'), 'application/javascript; charset=utf-8');
97
101
  return;
98
102
  }
99
103
  if (req.method === 'GET' && url.pathname === '/assets/addon-search.js') {
100
- sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-search', 'lib', 'addon-search.js'), 'application/javascript; charset=utf-8');
104
+ sendFile(res, terminalAssetPath('addon-search.js'), 'application/javascript; charset=utf-8');
101
105
  return;
102
106
  }
103
107
  if (req.method === 'POST' && url.pathname === '/login') {
package/lib/web/http.mjs CHANGED
@@ -84,6 +84,24 @@ export function isLoopbackRemote(req) {
84
84
  return addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.');
85
85
  }
86
86
 
87
+ function localHostAuthority(req) {
88
+ const host = req?.headers?.host;
89
+ if (typeof host !== 'string') return false;
90
+ const match = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/i.exec(host);
91
+ if (!match) return false;
92
+ return !match[1] || (Number(match[1]) > 0 && Number(match[1]) <= 65535);
93
+ }
94
+
95
+ export function tokenlessRequestAllowed(req, options = {}) {
96
+ if (!isLoopbackRemote(req)) return false;
97
+ if (localHostAuthority(req)) return true;
98
+ if (!options.trustProxy || !requestMatchesProxyOrigin(req, options)) return false;
99
+ const expected = normalizedProxyOrigin(options.proxyOrigin);
100
+ const actual = normalizedOriginAuthority(req?.headers?.host, expected?.protocol);
101
+ return Boolean(expected && actual && actual.hostname === expected.authority.hostname &&
102
+ actual.port === expected.authority.port);
103
+ }
104
+
87
105
  export function tokenMatches(provided, expected) {
88
106
  if (!provided || !expected || provided.length !== expected.length) return false;
89
107
  try {
@@ -174,10 +192,10 @@ export function requestOriginMatches(req, options = {}) {
174
192
  }
175
193
  }
176
194
 
177
- export function authOk(url, req, token) {
195
+ export function authOk(url, req, token, options = {}) {
178
196
  // No token configured: only trust loopback clients. A tokenless runtime is
179
197
  // refused on non-loopback binds at startup, so this is defense-in-depth.
180
- if (!token) return isLoopbackRemote(req);
198
+ if (!token) return tokenlessRequestAllowed(req, options);
181
199
  // v1-token-query-csrf-bypass: the query token is accepted ONLY on the cookie
182
200
  // exchange paths (the console page and the login endpoint). Everywhere else
183
201
  // — including all API routes and the WS upgrade — a leaked ?token= URL must