@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.
- package/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +13 -2
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +153 -50
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +113 -7
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
210
|
+
function sameProcessIdentity(left, right) {
|
|
211
|
+
return left?.pid === right?.pid && left?.startToken === right?.startToken &&
|
|
212
|
+
left?.commandHash === right?.commandHash;
|
|
213
|
+
}
|
|
338
214
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
//
|
|
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
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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 (
|
|
285
|
+
if (platform !== 'linux') return results;
|
|
417
286
|
|
|
418
287
|
let pids;
|
|
419
|
-
try { pids = fs.readdirSync(
|
|
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
|
|
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(
|
|
432
|
-
const env = readEnvFile(
|
|
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
|
|
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
|
|
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
|
-
|
|
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: () => {
|
|
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,
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
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
|
|
143
|
+
executorId: original.executorId, rootIdentity,
|
|
144
|
+
nativeOwnerGeneration: original.binding.transport === 'native' ? nativeOwnerGeneration(original.executorId) : null }),
|
|
145
|
+
assertValid, assertOwnership };
|
|
104
146
|
}
|