@logicseek/hello-cc 1.1.0-rc.8 → 1.1.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/docs/README.md +2 -0
  5. package/docs/README.zh-CN.md +2 -0
  6. package/docs/commands.md +16 -0
  7. package/docs/commands.zh-CN.md +14 -0
  8. package/docs/dsh.md +6 -0
  9. package/docs/dsh.zh-CN.md +5 -0
  10. package/docs/native.md +21 -1
  11. package/docs/native.zh-CN.md +19 -1
  12. package/docs/private-state.md +115 -0
  13. package/docs/private-state.zh-CN.md +92 -0
  14. package/lib/cli/app.mjs +46 -5
  15. package/lib/cli/commands/hook.mjs +31 -4
  16. package/lib/cli/commands/mcp.mjs +6 -0
  17. package/lib/cli/commands/native.mjs +17 -4
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +196 -62
  20. package/lib/cli/commands/tmux.mjs +28 -19
  21. package/lib/cli/commands/up.mjs +25 -17
  22. package/lib/cli-runtime.mjs +16 -1
  23. package/lib/core/coordination/event-retention.mjs +2 -0
  24. package/lib/core/sessions/codex-thread-root.mjs +144 -0
  25. package/lib/core/sessions/launch.mjs +4 -0
  26. package/lib/discover.mjs +95 -231
  27. package/lib/integrations/dsh-collaboration.mjs +9 -2
  28. package/lib/integrations/native/claude.mjs +6 -0
  29. package/lib/integrations/native/codex.mjs +4 -2
  30. package/lib/integrations/native/dsh-acp.mjs +1 -1
  31. package/lib/integrations/native/jsonrpc.mjs +2 -2
  32. package/lib/mcp/scope.mjs +50 -8
  33. package/lib/mcp/tools.mjs +15 -2
  34. package/lib/process/pinned-cwd.mjs +64 -0
  35. package/lib/process/pinned-guidance.mjs +1 -0
  36. package/lib/process/pinned-project-state.mjs +1 -0
  37. package/lib/process/tmux-cwd-handoff.mjs +277 -0
  38. package/lib/runtime/native/cli-submissions.mjs +220 -0
  39. package/lib/runtime/native/client.mjs +7 -1
  40. package/lib/runtime/native/launcher.mjs +20 -4
  41. package/lib/runtime/native/service.mjs +143 -50
  42. package/lib/runtime/native/store.mjs +303 -14
  43. package/lib/runtime/private-state.mjs +435 -24
  44. package/lib/runtime/project-path.mjs +53 -1
  45. package/lib/runtime/project-trust.mjs +6 -2
  46. package/lib/runtime/state-migration.mjs +66 -13
  47. package/lib/shared/redact.mjs +4 -1
  48. package/lib/terminal/tmux.mjs +13 -2
  49. package/lib/ui/help.mjs +1 -0
  50. package/lib/web/codex-app-server.mjs +3 -1
  51. package/lib/web/codex-sessions.mjs +68 -8
  52. package/lib/web/context-files.mjs +194 -41
  53. package/lib/web/http-routes.mjs +43 -12
  54. package/lib/web/native-sessions.mjs +3 -0
  55. package/lib/web/project-contexts.mjs +27 -5
  56. package/lib/web/project-files.mjs +108 -26
  57. package/lib/web/pty-sessions.mjs +15 -2
  58. package/lib/web/session-serialize.mjs +6 -1
  59. package/lib/web/startup.mjs +3 -1
  60. package/lib/web/tmux-sessions.mjs +153 -50
  61. package/lib/web/ui-interactions.mjs +7 -4
  62. package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
  63. package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
  64. package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
  65. package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
  66. package/native/cwd-handoff/README.md +45 -0
  67. package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
  68. package/package.json +8 -2
  69. package/scripts/build-cwd-handoff.mjs +47 -0
  70. package/scripts/pack-cwd-handoff.mjs +27 -0
  71. package/scripts/regression.mjs +113 -7
  72. package/scripts/verify-cwd-handoff-package.mjs +60 -0
  73. package/scripts/web-workbench-acceptance.mjs +79 -1
  74. package/scripts/web-workbench-installed-acceptance.mjs +11 -1
package/lib/discover.mjs CHANGED
@@ -9,9 +9,11 @@
9
9
 
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
- import os from 'node:os';
13
12
  import { spawnSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
14
  import { providerSessionPeerId } from './core/peers/session.mjs';
15
+ import { inspectProcessIdentity } from './process/identity.mjs';
16
+ import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from './process/selected-cwd-identity.mjs';
15
17
 
16
18
  // ─── Root detection ──────────────────────────────────────────────────────────
17
19
 
@@ -177,259 +179,131 @@ function readEnvFile(envPath) {
177
179
 
178
180
  // ─── Claude Code session discovery ───────────────────────────────────────────
179
181
 
180
- const CLAUDE_SESSIONS_DIR = path.join(os.homedir(), '.claude', 'sessions');
181
-
182
- /**
183
- * Read existing Claude Code session files and return info about live sessions
184
- * that belong to a hello-cc project.
185
- */
182
+ // Session files contain a historical cwd and PID, not a root-bound proof of
183
+ // which directory inode owned the provider session. A reused PID or pathname
184
+ // can make an old session appear to belong to a replacement project.
186
185
  export function scanClaudeSessions() {
187
- const results = [];
188
- let files;
189
- try { files = fs.readdirSync(CLAUDE_SESSIONS_DIR); }
190
- catch { return results; }
191
-
192
- for (const file of files) {
193
- if (!file.endsWith('.json')) continue;
194
- try {
195
- const content = JSON.parse(
196
- fs.readFileSync(path.join(CLAUDE_SESSIONS_DIR, file), 'utf8')
197
- );
198
- const { pid, sessionId, cwd, status } = content;
199
- if (!pid || !sessionId || !cwd) continue;
200
- if (!isAlive(pid)) continue;
201
- const env = readEnvFile(`/proc/${pid}/environ`);
202
-
203
- const hccRoot = findHccRoot(cwd);
204
- if (!hccRoot) continue;
205
-
206
- results.push({
207
- kind: 'claude',
208
- sessionId,
209
- peerId: derivePeerId('claude', sessionId, null, env.HCC_PEER),
210
- pid,
211
- cwd,
212
- hccRoot,
213
- status: status || 'unknown',
214
- transport: 'detected',
215
- });
216
- } catch { /* ignore malformed files */ }
217
- }
218
- return results;
186
+ return [];
219
187
  }
220
188
 
221
- /**
222
- * Watch ~/.claude/sessions/ for new session files.
223
- * Calls `onNew(sessionInfo)` for each new live session in an HCC project.
224
- * Returns the fs.FSWatcher or null.
225
- */
226
- export function watchClaudeSessions(onNew) {
227
- try {
228
- fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
229
- } catch {}
230
-
231
- try {
232
- return fs.watch(CLAUDE_SESSIONS_DIR, { persistent: false }, (event, filename) => {
233
- if (event !== 'rename' || !filename?.endsWith('.json')) return;
234
- // Small delay so Claude Code finishes writing the file
235
- setTimeout(() => {
236
- try {
237
- const content = JSON.parse(
238
- fs.readFileSync(path.join(CLAUDE_SESSIONS_DIR, filename), 'utf8')
239
- );
240
- const { pid, sessionId, cwd, status } = content;
241
- if (!pid || !sessionId || !cwd) return;
242
- if (!isAlive(pid)) return;
243
- const env = readEnvFile(`/proc/${pid}/environ`);
244
- const hccRoot = findHccRoot(cwd);
245
- if (!hccRoot) return;
246
- onNew({
247
- kind: 'claude',
248
- sessionId,
249
- peerId: derivePeerId('claude', sessionId, null, env.HCC_PEER),
250
- pid,
251
- cwd,
252
- hccRoot,
253
- status: status || 'unknown',
254
- transport: 'detected',
255
- });
256
- } catch {}
257
- }, 300);
258
- });
259
- } catch {
260
- return null;
261
- }
189
+ // Keep the exported watcher interface inert until session files carry a
190
+ // verifiable root/session receipt. Watching must not reintroduce attribution.
191
+ export function watchClaudeSessions(_onNew) {
192
+ return null;
262
193
  }
263
194
 
264
195
  // ─── Codex session discovery ──────────────────────────────────────────────────
265
196
 
266
- function codexSessionsDir() {
267
- const d = new Date();
268
- return path.join(
269
- os.homedir(), '.codex', 'sessions',
270
- String(d.getFullYear()),
271
- String(d.getMonth() + 1).padStart(2, '0'),
272
- String(d.getDate()).padStart(2, '0')
273
- );
274
- }
275
-
276
- function parseCodexSessionFile(filePath) {
277
- try {
278
- const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0];
279
- if (!firstLine) return null;
280
- const parsed = JSON.parse(firstLine);
281
- if (parsed.type !== 'session_meta') return null;
282
- const { id, cwd } = parsed.payload || {};
283
- if (!id || !cwd) return null;
284
- return { sessionId: id, cwd };
285
- } catch { return null; }
197
+ // Provider history may only be attributed by a separately persisted,
198
+ // root-bound receipt; a live process with the same cwd or resume ID is not one.
199
+ export function scanCodexSessions() {
200
+ return [];
286
201
  }
287
202
 
288
- /**
289
- * Scan today's Codex session files. Match to running processes via cmdline.
290
- */
291
- export function scanCodexSessions() {
292
- const results = [];
293
- const dir = codexSessionsDir();
294
- let files;
295
- try { files = fs.readdirSync(dir); } catch { return results; }
296
-
297
- // Build maps from /proc. Fresh sessions and --last/fork launches usually do
298
- // not expose the new session id in argv, so cwd is the fallback.
299
- const runningCodex = getRunningCodexSessions();
300
-
301
- for (const file of files.sort().reverse().slice(0, 30)) {
302
- if (!file.endsWith('.jsonl')) continue;
303
- const info = parseCodexSessionFile(path.join(dir, file));
304
- if (!info) continue;
305
-
306
- const proc = runningCodex.bySessionId.get(info.sessionId) || firstProcessForCwd(runningCodex.byCwd, info.cwd);
307
- if (!proc) continue;
308
-
309
- const hccRoot = findHccRoot(info.cwd);
310
- if (!hccRoot) continue;
311
-
312
- results.push({
313
- kind: 'codex',
314
- sessionId: info.sessionId,
315
- peerId: derivePeerId('codex', info.sessionId, null, proc.peerId),
316
- pid: proc.pid,
317
- cwd: info.cwd,
318
- hccRoot,
319
- status: 'running',
320
- transport: 'detected',
321
- });
322
- }
323
- return results;
203
+ // The same boundary applies to newly created session files.
204
+ export function watchCodexSessions(_onNew) {
205
+ return null;
324
206
  }
325
207
 
326
- /**
327
- * Walk /proc to find all running `codex` Node processes and map session ID → PID.
328
- * Codex launches as: `node /path/to/codex [resume] <session-id>` or similar.
329
- */
330
- function getRunningCodexSessions() {
331
- const bySessionId = new Map();
332
- const byCwd = new Map();
333
- if (process.platform !== 'linux') return { bySessionId, byCwd };
208
+ // ─── Live process-only discovery ───────────────────────────────────────────────
334
209
 
335
- let pids;
336
- try { pids = fs.readdirSync('/proc').filter(e => /^\d+$/.test(e)); }
337
- catch { return { bySessionId, byCwd }; }
210
+ function sameProcessIdentity(left, right) {
211
+ return left?.pid === right?.pid && left?.startToken === right?.startToken &&
212
+ left?.commandHash === right?.commandHash;
213
+ }
338
214
 
339
- for (const pid of pids) {
340
- try {
341
- const cmdline = readFileSafe(`/proc/${pid}/cmdline`).split('\0');
342
- if (!cmdline.some(a => a.includes('codex'))) continue;
343
- const cwd = fs.readlinkSync(`/proc/${pid}/cwd`);
344
- const env = readEnvFile(`/proc/${pid}/environ`);
345
- const procInfo = { pid: parseInt(pid), peerId: env.HCC_PEER || null };
346
- if (!byCwd.has(cwd)) byCwd.set(cwd, []);
347
- byCwd.get(cwd).push(procInfo);
348
-
349
- // codex resume <uuid>
350
- const resumeId = parseCodexResumeId(cmdline);
351
- if (resumeId) {
352
- bySessionId.set(resumeId, procInfo);
353
- }
354
- } catch {}
355
- }
356
- return { bySessionId, byCwd };
215
+ function sameDirectoryStat(left, right) {
216
+ return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
217
+ left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
357
218
  }
358
219
 
359
- function firstProcessForCwd(byCwd, cwd) {
360
- const direct = byCwd.get(cwd);
361
- if (direct?.length) return direct[0];
362
- for (const [procCwd, pids] of byCwd.entries()) {
363
- if ((procCwd === cwd || procCwd.startsWith(`${cwd}${path.sep}`) || cwd.startsWith(`${procCwd}${path.sep}`)) && pids.length) {
364
- return pids[0];
365
- }
366
- }
367
- return null;
220
+ function matchesSnapshot(stat, snapshot) {
221
+ return stat.isDirectory() && stat.dev.toString() === snapshot.identity.dev &&
222
+ stat.ino.toString() === snapshot.identity.ino &&
223
+ (stat.birthtimeNs?.toString() || null) === snapshot.identity.birthtimeNs;
368
224
  }
369
225
 
370
- /**
371
- * Watch today's Codex sessions directory for new session files.
372
- */
373
- export function watchCodexSessions(onNew) {
374
- const dir = codexSessionsDir();
375
- try { fs.mkdirSync(dir, { recursive: true }); } catch {}
226
+ function processPeerId(kind, pid, identity) {
227
+ const suffix = createHash('sha256').update(`${pid}\0${identity.startToken}`).digest('hex').slice(0, 20);
228
+ return `${kind}-process-${suffix}`;
229
+ }
376
230
 
231
+ // Recheck immediately before registration. Historical paths and provider IDs
232
+ // are not authority: the same pathname can now name a different directory.
233
+ // The proc cwd itself, its current canonical name, and its parent chain must
234
+ // all lead to the selected root inode. A changed/reused PID is rejected too.
235
+ export function verifiedLiveProcessForRoot(record, ctx, { procRoot = '/proc', observeProcess = inspectProcessIdentity } = {}) {
236
+ if (record?.source !== 'process' || !Number.isSafeInteger(record.pid) || record.pid < 1 ||
237
+ !record.processIdentity || record.sessionId || record.resumeId) return null;
238
+ const selected = ctx.rootIdentity || ctx.initialRootIdentity || captureSelectedCwdSnapshot(ctx.root);
239
+ assertSelectedCwdSnapshot(selected);
377
240
  try {
378
- return fs.watch(dir, { persistent: false }, (event, filename) => {
379
- if (event !== 'rename' || !filename?.endsWith('.jsonl')) return;
380
- setTimeout(() => {
381
- const info = parseCodexSessionFile(path.join(dir, filename));
382
- if (!info) return;
383
-
384
- const running = getRunningCodexSessions();
385
- const proc = running.bySessionId.get(info.sessionId) || firstProcessForCwd(running.byCwd, info.cwd);
386
-
387
- const hccRoot = findHccRoot(info.cwd);
388
- if (!hccRoot) return;
389
-
390
- onNew({
391
- kind: 'codex',
392
- sessionId: info.sessionId,
393
- peerId: derivePeerId('codex', info.sessionId, null, proc?.peerId),
394
- pid: proc?.pid || null,
395
- cwd: info.cwd,
396
- hccRoot,
397
- status: 'running',
398
- transport: 'detected',
399
- });
400
- }, 500);
401
- });
402
- } catch {
241
+ const before = observeProcess(record.pid);
242
+ if (before.state !== 'live' || !sameProcessIdentity(record.processIdentity, before.identity)) return null;
243
+ const procCwd = `${procRoot}/${record.pid}/cwd`;
244
+ const liveCwd = fs.realpathSync.native(procCwd);
245
+ const relative = path.relative(selected.canonical, liveCwd);
246
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
247
+ const procStat = fs.statSync(procCwd, { bigint: true });
248
+ if (!sameDirectoryStat(procStat, fs.statSync(liveCwd, { bigint: true }))) return null;
249
+ // Keep the /proc magic link in the path while walking upwards: path.join
250
+ // would erase "cwd/.." before the kernel could resolve the live inode.
251
+ let ancestor = procCwd;
252
+ for (let level = 0, depth = relative ? relative.split(path.sep).length : 0; level < depth; level++) {
253
+ ancestor += '/..';
254
+ }
255
+ if (!matchesSnapshot(fs.statSync(ancestor, { bigint: true }), selected)) return null;
256
+ const after = observeProcess(record.pid);
257
+ if (after.state !== 'live' || !sameProcessIdentity(before.identity, after.identity)) return null;
258
+ assertSelectedCwdSnapshot(selected);
259
+ return { ...record, cwd: liveCwd, hccRoot: selected.canonical, processIdentity: after.identity };
260
+ } catch (error) {
261
+ if (error?.code === 'PROJECT_PATH_CHANGED') throw error;
403
262
  return null;
404
263
  }
405
264
  }
406
265
 
407
- // ─── Generic process scan (fallback) ─────────────────────────────────────────
266
+ // Binding updates merge an existing provider session ID when the incoming
267
+ // record omits one. Process-only discovery must never inherit that history.
268
+ export function canRegisterProcessOnly(db, record) {
269
+ const binding = db.prepare('SELECT transport, provider_session_id, provider_session_name, resume_mode, resume_arg, runtime_target FROM peer_bindings WHERE peer=?')
270
+ .get(record.peerId);
271
+ if (binding) return binding.transport === 'detected' && !binding.provider_session_id &&
272
+ !binding.provider_session_name && !binding.resume_arg && !binding.runtime_target &&
273
+ binding.resume_mode === 'unknown';
274
+ return !db.prepare('SELECT id FROM peers WHERE id=?').get(record.peerId);
275
+ }
408
276
 
409
277
  /**
410
- * Scan /proc for any running claude/codex processes that belong to an HCC
411
- * project. Used as a fallback when session files are not available.
412
- * Returns an array of discovered session info objects.
278
+ * Scan /proc for live claude/codex processes. This does not infer ownership of
279
+ * provider history from argv, stale session files, or pathname equality.
280
+ * Call verifiedLiveProcessForRoot before registering a result to a project.
413
281
  */
414
- export function scanProcesses() {
282
+ export function scanProcesses({ procRoot = '/proc', platform = process.platform,
283
+ observeProcess = inspectProcessIdentity } = {}) {
415
284
  const results = [];
416
- if (process.platform !== 'linux') return results;
285
+ if (platform !== 'linux') return results;
417
286
 
418
287
  let pids;
419
- try { pids = fs.readdirSync('/proc').filter(e => /^\d+$/.test(e)); }
288
+ try { pids = fs.readdirSync(procRoot).filter(e => /^\d+$/.test(e)); }
420
289
  catch { return results; }
421
290
 
422
291
  const seen = new Set();
423
292
 
424
293
  for (const pidStr of pids) {
425
294
  try {
426
- const cmdline = readFileSafe(`/proc/${pidStr}/cmdline`).split('\0');
295
+ const pid = Number(pidStr);
296
+ const cmdline = readFileSafe(`${procRoot}/${pidStr}/cmdline`).split('\0');
427
297
  const hasClaude = cmdline.some(a => /claude/.test(a) && !a.includes('hcc'));
428
298
  const hasCodex = cmdline.some(a => /\bcodex\b/.test(a));
429
299
  if (!hasClaude && !hasCodex) continue;
430
300
 
431
- const cwd = fs.readlinkSync(`/proc/${pidStr}/cwd`);
432
- const env = readEnvFile(`/proc/${pidStr}/environ`);
301
+ const cwd = fs.readlinkSync(`${procRoot}/${pidStr}/cwd`);
302
+ const env = readEnvFile(`${procRoot}/${pidStr}/environ`);
303
+ const observed = observeProcess(pid);
304
+ if (observed.state !== 'live' || !observed.identity) continue;
305
+ const commandHash = createHash('sha256').update(cmdline.filter(Boolean).join(' ').trim()).digest('hex');
306
+ if (observed.identity.commandHash !== commandHash) continue;
433
307
 
434
308
  const kind = hasClaude ? 'claude' : 'codex';
435
309
 
@@ -440,19 +314,7 @@ export function scanProcesses() {
440
314
  if (entrypoint && entrypoint !== 'cli') continue;
441
315
  }
442
316
 
443
- const explicitClaudeSession = kind === 'claude' ? parseClaudeSessionId(cmdline) : '';
444
- const sessionId = kind === 'claude'
445
- ? (env.CLAUDE_CODE_SESSION_ID || explicitClaudeSession || '')
446
- : parseCodexResumeId(cmdline) || '';
447
-
448
- let resumeId = null;
449
- if (kind === 'claude') {
450
- resumeId = parseClaudeFork(cmdline) ? null : parseClaudeResumeId(cmdline);
451
- } else {
452
- resumeId = parseCodexResumeId(cmdline);
453
- }
454
-
455
- const peerId = derivePeerId(kind, sessionId, resumeId, env.HCC_PEER);
317
+ const peerId = processPeerId(kind, pid, observed.identity);
456
318
  if (seen.has(peerId)) continue;
457
319
  seen.add(peerId);
458
320
 
@@ -461,14 +323,16 @@ export function scanProcesses() {
461
323
 
462
324
  results.push({
463
325
  kind,
464
- sessionId,
465
- resumeId,
326
+ sessionId: '',
327
+ resumeId: null,
466
328
  peerId,
467
- pid: parseInt(pidStr),
329
+ pid,
468
330
  cwd,
469
331
  hccRoot,
470
332
  status: 'running',
471
333
  transport: 'detected',
334
+ source: 'process',
335
+ processIdentity: observed.identity,
472
336
  });
473
337
  } catch {}
474
338
  }
@@ -17,6 +17,7 @@ import { createScopedMcpTools } from '../mcp/tools.mjs';
17
17
  import { resolveProjectDatabase } from '../runtime/project-path.mjs';
18
18
  import { registerProjectActivity } from '../runtime/projects.mjs';
19
19
  import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
20
+ import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
20
21
 
21
22
  const fail = (message) => { throw new CliError('DSH_COLLABORATION_CONFLICT', message); };
22
23
 
@@ -39,8 +40,11 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
39
40
  if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 1024 ||
40
41
  sessionId.trim() !== sessionId || /[\u0000-\u001f\u007f]/.test(sessionId) ||
41
42
  typeof cwd !== 'string' || !path.isAbsolute(cwd)) fail('Agent requires its real session ID and absolute cwd');
43
+ const rootIdentity = captureSelectedCwdSnapshot(cwd);
42
44
  const project = resolveProjectDatabase({ root: cwd, createStateDir: true });
43
- const ctx = { root: project.root, cwd: project.root, dbPath: project.db, json: true };
45
+ assertSelectedCwdSnapshot(rootIdentity);
46
+ const ctx = { root: project.root, cwd: project.root, dbPath: project.db, json: true,
47
+ initialRootIdentity: rootIdentity };
44
48
  const peer = providerSessionPeerId('dsh', sessionId);
45
49
  const owner = `cordis:${randomUUID()}`;
46
50
  const now = () => Math.floor(Date.now() / 1000);
@@ -127,7 +131,10 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
127
131
  finally { db.close(); }
128
132
  }
129
133
  const authority = { scope: Object.freeze({ root: ctx.root, dbPath: ctx.dbPath, peer, executorId: owner }),
130
- assertValid: () => { if (disposed || retiring) fail('The Cordis Agent authority has been disposed'); }, assertOwnership };
134
+ assertValid: () => {
135
+ if (disposed || retiring) fail('The Cordis Agent authority has been disposed');
136
+ assertSelectedCwdSnapshot(rootIdentity);
137
+ }, assertOwnership };
131
138
  const service = createScopedMcpTools({ ctx, authority, connect, now, addEvent,
132
139
  touchPeer: (db, id, status) => { assertOwnership(db); if (id !== peer) fail('Peer identity changed'); peers.touchPeer(db, id, status); } });
133
140
  let queue = Promise.resolve();
@@ -6,6 +6,7 @@ import { createNativeInteractions } from './interactions.mjs';
6
6
  import { CliError } from '../../shared/errors.mjs';
7
7
  import { claudeUsage } from './telemetry.mjs';
8
8
  import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
9
+ import { HOOK_ROOT_IDENTITY_ENV } from '../../core/sessions/hook-root-identity.mjs';
9
10
 
10
11
  const SESSION_MARKERS = new Set([
11
12
  'CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_PARENT_SESSION_ID',
@@ -24,6 +25,11 @@ function isolatedEnv(source, coordinationEnv = source) {
24
25
  // An inherited terminal's partial HCC markers must not identify the worker.
25
26
  if (COORDINATION_KEYS.every((key) => typeof coordinationEnv[key] === 'string' && coordinationEnv[key])) {
26
27
  for (const key of COORDINATION_KEYS) clean[key] = coordinationEnv[key];
28
+ // The hook must receive the same launch-time directory receipt as the
29
+ // worker; otherwise a rebound project path would look like a legacy hook.
30
+ if (typeof coordinationEnv[HOOK_ROOT_IDENTITY_ENV] === 'string' && coordinationEnv[HOOK_ROOT_IDENTITY_ENV]) {
31
+ clean[HOOK_ROOT_IDENTITY_ENV] = coordinationEnv[HOOK_ROOT_IDENTITY_ENV];
32
+ }
27
33
  }
28
34
  return clean;
29
35
  }
@@ -102,7 +102,8 @@ export function createCodexAdapter(options = {}) {
102
102
  } else if (method === 'turn/diff/updated') {
103
103
  emit({ type: 'diff', diff: params.diff, turnId, submissionId });
104
104
  } else if (method === 'error') {
105
- emit({ type: 'error', turnId, error: params.error ?? params });
105
+ emit({ type: 'error', turnId, submissionId, error: params.error ?? params,
106
+ ...(typeof params.willRetry === 'boolean' ? { willRetry: params.willRetry } : {}) });
106
107
  } else if (method === 'serverRequest/resolved') {
107
108
  interactions.resolved(params.requestId);
108
109
  } else if (method === 'thread/status/changed' && params.status?.type === 'systemError') {
@@ -169,7 +170,8 @@ export function createCodexAdapter(options = {}) {
169
170
  status('opening');
170
171
  try {
171
172
  cwdBinding?.assertUnchanged();
172
- rpc = rpcFactory({ binary, args: ['app-server', '--stdio'], cwd, env, timeoutMs,
173
+ rpc = rpcFactory({ binary, args: ['app-server', '--stdio'], cwd, env,
174
+ expectedIdentity: cwdBinding || options.expectedCwdIdentity, timeoutMs,
173
175
  onNotification: notification, onRequest: serverRequest, onExit: exited, onError: transportError });
174
176
  await rpc.start();
175
177
  if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
@@ -205,7 +205,7 @@ export function createDshAcpAdapter(options = {}) {
205
205
  cwdBinding.assertUnchanged();
206
206
  const config = {
207
207
  binary: options.binary || 'dsh', args: ['--profile', 'acp'],
208
- cwd, env: options.env, timeoutMs: options.timeoutMs,
208
+ cwd, env: options.env, expectedIdentity: cwdBinding, timeoutMs: options.timeoutMs,
209
209
  onNotification, onRequest, onExit, onError: onTransportError
210
210
  };
211
211
  rpc = options.rpcFactory ? options.rpcFactory(config) : new JsonRpcProcess(config);
@@ -40,10 +40,10 @@ export class JsonRpcProcess {
40
40
 
41
41
  async start() {
42
42
  if (this.child || this.closed) throw new CliError('NATIVE_TRANSPORT_STATE', 'Transport cannot be started twice');
43
- const { binary, args = [], cwd, env } = this.options;
43
+ const { binary, args = [], cwd, env, expectedIdentity } = this.options;
44
44
  assertExecutableAvailable(binary, cwd || process.cwd(), env);
45
45
  this.child = spawnPinnedCwdProcess(binary, args, {
46
- cwd: cwd || process.cwd(), env, shell: false, stdio: ['pipe', 'pipe', 'pipe']
46
+ cwd: cwd || process.cwd(), env, expectedIdentity, shell: false, stdio: ['pipe', 'pipe', 'pipe']
47
47
  });
48
48
  this.child.stdout.setEncoding('utf8');
49
49
  this.child.stdout.on('data', (chunk) => this.read(chunk));
package/lib/mcp/scope.mjs CHANGED
@@ -6,6 +6,9 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { CliError } from '../shared/errors.mjs';
8
8
  import { inspectProcessIdentity } from '../process/identity.mjs';
9
+ import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot,
10
+ sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
11
+ import { nativeOwnerGeneration } from '../runtime/native/store.mjs';
9
12
 
10
13
  const PATH_KEY = 'HCC_MCP_BOOTSTRAP_PATH';
11
14
  const TOKEN_KEY = 'HCC_MCP_BOOTSTRAP_TOKEN';
@@ -20,23 +23,54 @@ function validIdentity(value) {
20
23
  function sameIdentity(a, b) {
21
24
  return a?.pid === b?.pid && a?.startToken === b?.startToken && a?.commandHash === b?.commandHash;
22
25
  }
26
+ function validRootIdentity(value) {
27
+ const identity = value?.identity;
28
+ return typeof value?.requested === 'string' && path.isAbsolute(value.requested) &&
29
+ typeof value.canonical === 'string' && path.isAbsolute(value.canonical) &&
30
+ typeof identity?.dev === 'string' && /^\d+$/.test(identity.dev) &&
31
+ typeof identity.ino === 'string' && /^\d+$/.test(identity.ino) &&
32
+ (identity.birthtimeNs === null ||
33
+ (typeof identity.birthtimeNs === 'string' && /^\d+$/.test(identity.birthtimeNs)));
34
+ }
35
+ function assertLaunchRoot(root, rootIdentity) {
36
+ if (!validRootIdentity(rootIdentity)) fail();
37
+ if (typeof rootIdentity.assertUnchanged === 'function') rootIdentity.assertUnchanged();
38
+ else assertSelectedCwdSnapshot(rootIdentity);
39
+ const selected = captureSelectedCwdSnapshot(root);
40
+ if (!sameSelectedCwdIdentity(rootIdentity, selected)) {
41
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed');
42
+ }
43
+ return { requested: selected.canonical, canonical: selected.canonical,
44
+ identity: { ...rootIdentity.identity } };
45
+ }
46
+ function assertCapabilityRoot(receipt, root) {
47
+ if (!validRootIdentity(receipt) || receipt.requested !== root || receipt.canonical !== root) fail();
48
+ try { assertSelectedCwdSnapshot(receipt); }
49
+ catch { fail(); }
50
+ }
23
51
 
24
52
  // Per executor only: callers pass this entry to its thread configuration. No
25
53
  // user/global Codex configuration is read or written.
26
- export function createScopedMcpConfig({ root, dbPath, peer, executorId, ownerIdentity,
54
+ export function createScopedMcpConfig({ root, dbPath, peer, executorId, ownerIdentity, rootIdentity,
27
55
  binding = { transport: 'app-server', runtimeSessionId: peer },
28
56
  cliPath = fileURLToPath(new URL('../../bin/hcc.mjs', import.meta.url)) }) {
29
57
  if (typeof peer !== 'string' || !peer.trim() || peer.length > 256 || /[\r\n\0]/.test(peer) ||
30
58
  typeof executorId !== 'string' || !executorId || executorId.length > 256 || !validIdentity(ownerIdentity) ||
31
- (binding.transport === 'native' && (binding.runtimeTarget !== executorId || binding.runtimeSessionId !== peer))) fail();
32
- const scope = { root: canonical(root), dbPath: canonical(dbPath), peer, executorId,
59
+ (binding.transport === 'native' && (binding.runtimeTarget !== executorId || binding.runtimeSessionId !== peer ||
60
+ !nativeOwnerGeneration(executorId)))) fail();
61
+ const launchRoot = assertLaunchRoot(root, rootIdentity);
62
+ const scope = { root: launchRoot.canonical, dbPath: canonical(dbPath), peer, executorId,
63
+ rootIdentity: launchRoot,
33
64
  ownerIdentity: { ...ownerIdentity }, binding: { ...binding } };
65
+ if (typeof rootIdentity.assertUnchanged === 'function') rootIdentity.assertUnchanged();
66
+ assertCapabilityRoot(scope.rootIdentity, scope.root);
34
67
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-mcp-'));
35
68
  fs.chmodSync(directory, 0o700);
36
69
  const bootstrapPath = path.join(directory, 'scope.json');
37
70
  const token = randomBytes(32).toString('base64url');
38
71
  try {
39
- fs.writeFileSync(bootstrapPath, JSON.stringify({ version: 1, ...scope, tokenHash: digest(token) }),
72
+ assertCapabilityRoot(scope.rootIdentity, scope.root);
73
+ fs.writeFileSync(bootstrapPath, JSON.stringify({ version: 2, ...scope, tokenHash: digest(token) }),
40
74
  { flag: 'wx', mode: 0o600 });
41
75
  } catch (error) { fs.rmSync(directory, { recursive: true, force: true }); throw error; }
42
76
  let disposed = false;
@@ -64,13 +98,16 @@ export function loadScopedMcpBootstrap(ctx, peer, env = process.env) {
64
98
  (process.platform !== 'win32' && (stat.mode & 0o077)) ||
65
99
  (process.getuid && stat.uid !== process.getuid())) fail();
66
100
  const value = JSON.parse(fs.readFileSync(fd, 'utf8'));
67
- if (value.version !== 1 || typeof value.tokenHash !== 'string' || !/^[a-f0-9]{64}$/.test(value.tokenHash) ||
68
- !timingSafeEqual(Buffer.from(value.tokenHash, 'hex'), Buffer.from(digest(token), 'hex')) ||
101
+ if (value.version !== 2 || typeof value.tokenHash !== 'string' || !/^[a-f0-9]{64}$/.test(value.tokenHash) ||
102
+ !timingSafeEqual(Buffer.from(value.tokenHash, 'hex'), Buffer.from(digest(token), 'hex'))) fail();
103
+ assertCapabilityRoot(value.rootIdentity, value.root);
104
+ if (
69
105
  value.root !== canonical(ctx.root) || value.dbPath !== canonical(ctx.dbPath) ||
70
106
  value.peer !== peer || !validIdentity(value.ownerIdentity) ||
71
107
  typeof value.executorId !== 'string' || !value.executorId || !value.binding ||
72
108
  typeof value.binding.transport !== 'string' ||
73
- (value.binding.transport === 'native' && (value.binding.runtimeTarget !== value.executorId || value.binding.runtimeSessionId !== peer))) fail();
109
+ (value.binding.transport === 'native' && (value.binding.runtimeTarget !== value.executorId || value.binding.runtimeSessionId !== peer ||
110
+ !nativeOwnerGeneration(value.executorId)))) fail();
74
111
  return value;
75
112
  } catch { fail(); } finally { if (fd !== undefined) fs.closeSync(fd); }
76
113
  }
@@ -99,6 +136,11 @@ export function loadScopedMcpBootstrap(ctx, peer, env = process.env) {
99
136
  }
100
137
  }
101
138
  assertValid();
139
+ const rootIdentity = Object.freeze({ requested: original.rootIdentity.requested,
140
+ canonical: original.rootIdentity.canonical,
141
+ identity: Object.freeze({ ...original.rootIdentity.identity }) });
102
142
  return { scope: Object.freeze({ root: original.root, dbPath: original.dbPath, peer,
103
- executorId: original.executorId }), assertValid, assertOwnership };
143
+ executorId: original.executorId, rootIdentity,
144
+ nativeOwnerGeneration: original.binding.transport === 'native' ? nativeOwnerGeneration(original.executorId) : null }),
145
+ assertValid, assertOwnership };
104
146
  }