@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
|
@@ -13,7 +13,11 @@ export function unsafeDirectoryAcl(directory, stat = null) {
|
|
|
13
13
|
try { identity ||= fs.lstatSync(directory); }
|
|
14
14
|
catch { return true; }
|
|
15
15
|
const signature = `${identity.dev}:${identity.ino}:${identity.ctimeNs ?? identity.ctimeMs}`;
|
|
16
|
-
|
|
16
|
+
// Number Stats carry milliseconds; BigInt Stats carry nanoseconds. Keep
|
|
17
|
+
// both exact signatures instead of replacing one with the other on every
|
|
18
|
+
// read through a different state-validation path.
|
|
19
|
+
const cacheKey = `${typeof identity.dev === 'bigint' ? 'bigint' : 'number'}\0${directory}`;
|
|
20
|
+
const cached = aclCache.get(cacheKey);
|
|
17
21
|
if (cached?.signature === signature) return cached.unsafe;
|
|
18
22
|
const ls = fs.existsSync('/bin/ls') ? '/bin/ls'
|
|
19
23
|
: fs.existsSync('/usr/bin/ls') ? '/usr/bin/ls' : null;
|
|
@@ -36,7 +40,7 @@ export function unsafeDirectoryAcl(directory, stat = null) {
|
|
|
36
40
|
: permissions[10] === '+')
|
|
37
41
|
: permissions[10] === '+';
|
|
38
42
|
if (aclCache.size >= 1024) aclCache.clear();
|
|
39
|
-
aclCache.set(
|
|
43
|
+
aclCache.set(cacheKey, { signature, unsafe });
|
|
40
44
|
return unsafe;
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
import { DatabaseSync } from 'node:sqlite';
|
|
26
26
|
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
27
27
|
import { privateProjectStateDir, withPrivateProjectStateTransition,
|
|
28
|
-
privateStateBaseDir } from './private-state.mjs';
|
|
28
|
+
privateProjectStateManifest, privateStateBaseDir } from './private-state.mjs';
|
|
29
29
|
import { unsafeDirectoryAcl } from './project-trust.mjs';
|
|
30
30
|
|
|
31
31
|
const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
|
|
@@ -264,6 +264,55 @@ function checkMeshPeers(db) {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// A binding-only v1 -> v2 upgrade never copies or opens the store for write.
|
|
268
|
+
// These checks are additional evidence, not proof that an unregistered writer
|
|
269
|
+
// (including a pre-pointer old Web process) is absent; the operator must also
|
|
270
|
+
// explicitly assert a cold drain.
|
|
271
|
+
export function assertPrivateStateOffline(directory) {
|
|
272
|
+
checkedDirectory(directory, { exactMode: 0o700 });
|
|
273
|
+
const globalPointer = path.join(privateStateBaseDir(), 'runtime.json');
|
|
274
|
+
for (const file of [globalPointer, path.join(directory, 'runtime.json'),
|
|
275
|
+
path.join(directory, 'native', 'runtime.json')]) {
|
|
276
|
+
if (existing(file)) checkPointer(file);
|
|
277
|
+
}
|
|
278
|
+
const bufs = path.join(directory, 'bufs');
|
|
279
|
+
if (existing(bufs)) {
|
|
280
|
+
checkedDirectory(bufs, { exactMode: 0o700 });
|
|
281
|
+
for (const name of fs.readdirSync(bufs)) {
|
|
282
|
+
if (name.endsWith('.meta')) checkBufferMetadata(path.join(bufs, name));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const mesh = path.join(directory, 'mesh.db');
|
|
286
|
+
if (existing(mesh)) {
|
|
287
|
+
checkedRegular(mesh);
|
|
288
|
+
let db;
|
|
289
|
+
try {
|
|
290
|
+
db = new DatabaseSync(mesh, { readOnly: true, timeout: 1000 });
|
|
291
|
+
checkMeshPeers(db);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
throw unsafe(`Cannot establish offline mesh peer state: ${mesh}`, error);
|
|
294
|
+
} finally { db?.close(); }
|
|
295
|
+
}
|
|
296
|
+
const nativeDb = path.join(directory, 'native', 'state.db');
|
|
297
|
+
if (existing(nativeDb)) {
|
|
298
|
+
checkedRegular(nativeDb);
|
|
299
|
+
let db;
|
|
300
|
+
try {
|
|
301
|
+
db = new DatabaseSync(nativeDb, { readOnly: true, timeout: 1000 });
|
|
302
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_owner'").get()) {
|
|
303
|
+
const row = db.prepare('SELECT pid,start_token,command_hash,state FROM native_owner WHERE singleton=1').get();
|
|
304
|
+
if (row && row.state !== 'released') {
|
|
305
|
+
const stored = { pid: Number(row.pid), startToken: row.start_token,
|
|
306
|
+
commandHash: row.command_hash };
|
|
307
|
+
assertRecordedProcessDead(Number(row.pid), stored, 'native owner');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw unsafe(`Cannot establish offline native owner state: ${nativeDb}`, error);
|
|
312
|
+
} finally { db?.close(); }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
267
316
|
function snapshotDatabase(scratchDb, destination, { mesh = false } = {}) {
|
|
268
317
|
let source;
|
|
269
318
|
let target;
|
|
@@ -553,18 +602,17 @@ function sourceIdentity(root) {
|
|
|
553
602
|
return { canonicalRoot, rootStat, source, sourceStat };
|
|
554
603
|
}
|
|
555
604
|
|
|
556
|
-
function markerBody(project) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}) + '\n';
|
|
605
|
+
function markerBody(project, binding) {
|
|
606
|
+
if (binding.dev !== String(project.rootStat.dev) || binding.ino !== String(project.rootStat.ino) ||
|
|
607
|
+
(binding.version === 2 && binding.birthtimeNs !== String(project.rootStat.birthtimeNs))) {
|
|
608
|
+
throw unsafe('Project root identity changed before migration marker publication');
|
|
609
|
+
}
|
|
610
|
+
return `${JSON.stringify(binding)}\n`;
|
|
563
611
|
}
|
|
564
612
|
|
|
565
|
-
function writeMarker(stage, project) {
|
|
613
|
+
function writeMarker(stage, project, binding) {
|
|
566
614
|
const marker = path.join(stage, MARKER);
|
|
567
|
-
const body = markerBody(project);
|
|
615
|
+
const body = markerBody(project, binding);
|
|
568
616
|
const fd = fs.openSync(marker, CREATE_FLAGS, 0o600);
|
|
569
617
|
try {
|
|
570
618
|
fs.writeFileSync(fd, body);
|
|
@@ -600,19 +648,22 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
|
|
|
600
648
|
const project = sourceIdentity(root);
|
|
601
649
|
const base = privateStateBaseDir();
|
|
602
650
|
const destination = privateProjectStateDir(project.canonicalRoot);
|
|
651
|
+
const destinationManifest = privateProjectStateManifest(project.canonicalRoot);
|
|
603
652
|
const projects = path.dirname(destination);
|
|
653
|
+
const stageParent = destinationManifest.version === 2 ? path.dirname(projects) : projects;
|
|
604
654
|
if (project.source === base || projects.startsWith(`${project.source}${path.sep}`)) {
|
|
605
655
|
throw unsafe('Legacy source overlaps private destination');
|
|
606
656
|
}
|
|
607
657
|
checkHomeAncestry(path.dirname(base));
|
|
608
658
|
ensurePrivateDirectory(base);
|
|
609
659
|
ensurePrivateDirectory(projects);
|
|
660
|
+
if (stageParent !== projects) ensurePrivateDirectory(stageParent);
|
|
610
661
|
return withPrivateProjectStateTransition(project.canonicalRoot, ({ setAuthorityKind }) => {
|
|
611
662
|
const projectsStat = checkedDirectory(projects, { exactMode: 0o700 });
|
|
612
663
|
if (existing(destination)) {
|
|
613
664
|
throw failure('STATE_MIGRATION_EXISTS', `Private project state already exists: ${destination}`);
|
|
614
665
|
}
|
|
615
|
-
const stage = path.join(
|
|
666
|
+
const stage = path.join(stageParent, `.${path.basename(destination)}.migration-${randomBytes(12).toString('hex')}`);
|
|
616
667
|
const stageStat = privateMkdir(stage);
|
|
617
668
|
let committed = false;
|
|
618
669
|
try {
|
|
@@ -644,7 +695,7 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
|
|
|
644
695
|
if (!sameFile(checkedDirectory(projects, { exactMode: 0o700 }), projectsStat)) {
|
|
645
696
|
throw unsafe('Private projects directory changed before commit');
|
|
646
697
|
}
|
|
647
|
-
writeMarker(stage, project);
|
|
698
|
+
writeMarker(stage, project, destinationManifest);
|
|
648
699
|
// Persist authority before exposing the destination. If the rename fails,
|
|
649
700
|
// the marker makes future access fail closed until an explicit retry.
|
|
650
701
|
writeAuthorityMarker(projects, setAuthorityKind);
|
|
@@ -655,10 +706,12 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
|
|
|
655
706
|
fs.renameSync(stage, destination);
|
|
656
707
|
try {
|
|
657
708
|
fsyncDirectory(projects);
|
|
709
|
+
if (stageParent !== projects) fsyncDirectory(stageParent);
|
|
658
710
|
} catch (error) {
|
|
659
711
|
try {
|
|
660
712
|
fs.renameSync(destination, stage);
|
|
661
713
|
fsyncDirectory(projects);
|
|
714
|
+
if (stageParent !== projects) fsyncDirectory(stageParent);
|
|
662
715
|
} catch (rollbackError) {
|
|
663
716
|
throw failure('STATE_MIGRATION_COMMIT_UNCERTAIN',
|
|
664
717
|
`Cannot confirm or roll back migration commit at ${destination}`, rollbackError);
|
|
@@ -677,7 +730,7 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
|
|
|
677
730
|
const current = existing(stage);
|
|
678
731
|
if (current && current.isDirectory() && sameFile(current, stageStat)) {
|
|
679
732
|
fs.rmSync(stage, { recursive: true, force: false });
|
|
680
|
-
fsyncDirectory(
|
|
733
|
+
fsyncDirectory(stageParent);
|
|
681
734
|
}
|
|
682
735
|
} catch { /* Preserve the original error; an uncommitted stage is inert. */ }
|
|
683
736
|
}
|
package/lib/shared/redact.mjs
CHANGED
|
@@ -52,7 +52,10 @@ export function redactSecrets(value, seen = new WeakSet()) {
|
|
|
52
52
|
|
|
53
53
|
const clone = {};
|
|
54
54
|
for (const [key, entry] of Object.entries(value)) {
|
|
55
|
-
|
|
55
|
+
// Boolean isSecret describes an input control; its value is not a credential.
|
|
56
|
+
clone[key] = key === 'isSecret' && typeof entry === 'boolean'
|
|
57
|
+
? entry
|
|
58
|
+
: isSecretKey(key) ? REDACTED : redactSecrets(entry, seen);
|
|
56
59
|
}
|
|
57
60
|
return clone;
|
|
58
61
|
} finally {
|
package/lib/terminal/tmux.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import process from 'node:process';
|
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { performance } from 'node:perf_hooks';
|
|
6
6
|
import { CliError } from '../shared/errors.mjs';
|
|
7
|
+
import { privateProjectGenerationFromPath } from '../runtime/private-state.mjs';
|
|
7
8
|
import { shellQuoteArg } from '../format.mjs';
|
|
8
9
|
import {
|
|
9
10
|
LAUNCH_FINGERPRINT_ENV,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
} from '../core/peers/format.mjs';
|
|
16
17
|
|
|
17
18
|
export function runTmux(args, opts = {}) {
|
|
18
|
-
const result = spawnSync('tmux', args, {
|
|
19
|
+
const result = (opts.spawn || spawnSync)('tmux', args, {
|
|
19
20
|
encoding: 'utf8',
|
|
20
21
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
22
|
input: opts.input || '',
|
|
@@ -28,7 +29,10 @@ export function runTmux(args, opts = {}) {
|
|
|
28
29
|
if (result.error.code === 'ETIMEDOUT' || result.killed) {
|
|
29
30
|
throw new CliError('TMUX_ERROR', `tmux timed out after ${opts.timeout ?? 5000}ms: tmux ${args.join(' ')}`);
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
const preSpawnCode = (!Number.isInteger(result.pid) || result.pid <= 0) &&
|
|
33
|
+
['ENOENT', 'EACCES'].includes(result.error.code) ? result.error.code : null;
|
|
34
|
+
throw new CliError('TMUX_ERROR', `tmux failed: ${result.error.message}`,
|
|
35
|
+
preSpawnCode ? { preSpawnCode } : {});
|
|
32
36
|
}
|
|
33
37
|
if (result.status !== 0) {
|
|
34
38
|
const message = (result.stderr || result.stdout || '').trim() || `tmux ${args.join(' ')} failed`;
|
|
@@ -201,6 +205,11 @@ function legacyManagedProjectRoot(root) {
|
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
export function tmuxManagedSessionPrefix(ctx) {
|
|
208
|
+
// A replacement project at the same spelling keeps A's v1 namespace, but
|
|
209
|
+
// its v2 private DB lives in an independent immutable generation. Derive the
|
|
210
|
+
// new tmux namespace from that captured DB route, never today's root inode.
|
|
211
|
+
const generation = privateProjectGenerationFromPath(ctx.dbPath);
|
|
212
|
+
if (generation) return `hcc2-${generation}-`;
|
|
204
213
|
return `hcc-${shortHash(managedProjectRoot(ctx.root))}-`;
|
|
205
214
|
}
|
|
206
215
|
|
|
@@ -228,6 +237,7 @@ function rootsIdentifySameProject(left, right) {
|
|
|
228
237
|
export function tmuxManagedSessionPrefixMatches(ctx, sessionName, sessionRoot = null) {
|
|
229
238
|
if (!sessionName) return false;
|
|
230
239
|
if (sessionName.startsWith(tmuxManagedSessionPrefix(ctx))) return true;
|
|
240
|
+
if (tmuxManagedSessionPrefix(ctx).startsWith('hcc2-')) return false;
|
|
231
241
|
return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
|
|
232
242
|
sessionName.startsWith(legacyTmuxManagedSessionPrefix({ root: sessionRoot }));
|
|
233
243
|
}
|
|
@@ -235,6 +245,7 @@ export function tmuxManagedSessionPrefixMatches(ctx, sessionName, sessionRoot =
|
|
|
235
245
|
export function tmuxManagedSessionNameMatches(ctx, sessionName, peerId, sessionRoot = null) {
|
|
236
246
|
if (!sessionName) return false;
|
|
237
247
|
if (sessionName === tmuxManagedSessionName(ctx, peerId)) return true;
|
|
248
|
+
if (tmuxManagedSessionPrefix(ctx).startsWith('hcc2-')) return false;
|
|
238
249
|
return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
|
|
239
250
|
sessionName === legacyTmuxManagedSessionName({ root: sessionRoot }, peerId);
|
|
240
251
|
}
|
package/lib/ui/help.mjs
CHANGED
|
@@ -59,6 +59,7 @@ Environment:
|
|
|
59
59
|
HCC_PEER Default peer id; inferred automatically when absent
|
|
60
60
|
HCC_WEB_TOKEN Set an explicit web access token
|
|
61
61
|
HCC_RUNTIME_CA CA file for HCC_RUNTIME_URL=https endpoints
|
|
62
|
+
HCC_PINNED_LAUNCH_MODE pinned (default) or hold new HCC-managed launches
|
|
62
63
|
`);
|
|
63
64
|
},
|
|
64
65
|
|
|
@@ -534,7 +534,7 @@ export function createCodexAppServer({
|
|
|
534
534
|
|
|
535
535
|
async function call(method, params, mutation = false, absorb = true) {
|
|
536
536
|
await initialize();
|
|
537
|
-
if (method === 'thread/list') assertCwdHandoff();
|
|
537
|
+
if (method === 'thread/list' || method === 'turn/steer') assertCwdHandoff();
|
|
538
538
|
return request(method, params, mutation, absorb);
|
|
539
539
|
}
|
|
540
540
|
|
|
@@ -611,6 +611,7 @@ export function createCodexAppServer({
|
|
|
611
611
|
[...uncertain.values()].some((entry) => entry.threadId === threadId)) {
|
|
612
612
|
throw new CodexAppServerError('CODEX_THREAD_BUSY', 'thread has an active or uncertain turn; no input was submitted');
|
|
613
613
|
}
|
|
614
|
+
assertCwdHandoff();
|
|
614
615
|
currentThreadId = threadId; submittingThreads.add(threadId);
|
|
615
616
|
try {
|
|
616
617
|
return await request('turn/start', { ...objectParams(params), threadId, input: normalized }, true);
|
|
@@ -639,6 +640,7 @@ export function createCodexAppServer({
|
|
|
639
640
|
// No await between checking a live request and replying to its exact RPC id.
|
|
640
641
|
assertActiveTurn(threadId, turnId);
|
|
641
642
|
if (approvals.get(rpcKey(requestId)) !== approval) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval was resolved');
|
|
643
|
+
if (decision === 'accept' || (approval.kind === 'userInput' && decision !== 'cancel')) assertCwdHandoff();
|
|
642
644
|
write({ id: requestId, result });
|
|
643
645
|
approval.status = 'submitted';
|
|
644
646
|
approval.params = redactMcpUrlInteraction(approval).params;
|
|
@@ -8,7 +8,14 @@ import { childSessionEnv } from '../core/sessions/launch.mjs';
|
|
|
8
8
|
import { createCodexAppServer } from './codex-app-server.mjs';
|
|
9
9
|
import { waitForLiveProcessIdentity } from '../process/identity.mjs';
|
|
10
10
|
import { createScopedMcpConfig } from '../mcp/scope.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { assertSelectedCwdSnapshot,
|
|
12
|
+
sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
13
|
+
import { selectedCodexRootSnapshot, readCodexThreadRootBindings,
|
|
14
|
+
assertCodexThreadRoot, recordCodexThreadRoot } from '../core/sessions/codex-thread-root.mjs';
|
|
15
|
+
|
|
16
|
+
function selectedRootSnapshot(pctx) {
|
|
17
|
+
return selectedCodexRootSnapshot(pctx.root, pctx.rootIdentity);
|
|
18
|
+
}
|
|
12
19
|
|
|
13
20
|
// Each structured session owns one executor. Existing TUI sessions are never
|
|
14
21
|
// silently resumed here: provider identity and live-owner evidence fence that.
|
|
@@ -21,7 +28,35 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
21
28
|
|
|
22
29
|
function assertSelectedProject(pctx) {
|
|
23
30
|
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
24
|
-
pctx.rootIdentity?.assertUnchanged();
|
|
31
|
+
if (typeof pctx.rootIdentity?.assertUnchanged === 'function') pctx.rootIdentity.assertUnchanged();
|
|
32
|
+
else if (pctx.rootIdentity) assertSelectedCwdSnapshot(pctx.rootIdentity);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertRecordedThreadRoot(pctx, threadId, knownBindings = null) {
|
|
36
|
+
assertSelectedProject(pctx);
|
|
37
|
+
let original;
|
|
38
|
+
if (knownBindings) original = assertCodexThreadRoot(null, threadId, selectedRootSnapshot(pctx), knownBindings);
|
|
39
|
+
else {
|
|
40
|
+
const db = connectWebProject(pctx);
|
|
41
|
+
try { original = assertCodexThreadRoot(db, threadId, selectedRootSnapshot(pctx)); }
|
|
42
|
+
finally { db.close(); }
|
|
43
|
+
}
|
|
44
|
+
assertSelectedProject(pctx);
|
|
45
|
+
return original;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordThreadRoot(pctx, threadId, snapshot, origin) {
|
|
49
|
+
threadIdentifier(threadId);
|
|
50
|
+
assertSelectedCwdSnapshot(snapshot);
|
|
51
|
+
assertSelectedProject(pctx);
|
|
52
|
+
const db = connectWebProject(pctx);
|
|
53
|
+
try {
|
|
54
|
+
tx(db, () => {
|
|
55
|
+
assertSelectedCwdSnapshot(snapshot);
|
|
56
|
+
recordCodexThreadRoot(db, addEvent, threadId, snapshot, origin);
|
|
57
|
+
});
|
|
58
|
+
} finally { db.close(); }
|
|
59
|
+
assertSelectedCwdSnapshot(snapshot);
|
|
25
60
|
}
|
|
26
61
|
|
|
27
62
|
function managedSessionMatchesProject(session, pctx) {
|
|
@@ -47,6 +82,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
47
82
|
const pctx = input.projectCtx;
|
|
48
83
|
assertSelectedProject(pctx);
|
|
49
84
|
const cwd = pctx.root;
|
|
85
|
+
const selectedRoot = selectedRootSnapshot(pctx);
|
|
50
86
|
const mode = input.mode || 'new';
|
|
51
87
|
if (!['new', 'resume'].includes(mode)) throw new CliError('BAD_REQUEST', 'App Server supports new or explicit resume');
|
|
52
88
|
const threadId = mode === 'resume' ? String(input.resume || '').trim() : null;
|
|
@@ -75,6 +111,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
75
111
|
previousPeer = owner.id;
|
|
76
112
|
}
|
|
77
113
|
if (bindings.length > 1) throw new CliError('THREAD_IN_USE', 'Provider thread has ambiguous peer ownership');
|
|
114
|
+
assertRecordedThreadRoot(pctx, threadId);
|
|
78
115
|
}
|
|
79
116
|
} finally { db.close(); }
|
|
80
117
|
startingThreads.add(reservation);
|
|
@@ -88,7 +125,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
88
125
|
let closeScheduled = false;
|
|
89
126
|
let adapter;
|
|
90
127
|
let ownerIdentity = null;
|
|
91
|
-
try { adapter = adapterFactory({ cwd, expectedCwdIdentity: pctx.rootIdentity ||
|
|
128
|
+
try { adapter = adapterFactory({ cwd, expectedCwdIdentity: pctx.rootIdentity || selectedRoot,
|
|
92
129
|
changeView: true, env: childSessionEnv({
|
|
93
130
|
HCC_PEER: id, HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1'
|
|
94
131
|
}, process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }), async onSpawn({ pid, executorId }) {
|
|
@@ -97,7 +134,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
97
134
|
assertSelectedProject(pctx);
|
|
98
135
|
ownerIdentity = owner.identity;
|
|
99
136
|
session.mcpCapability = mcpConfigFactory({ root: pctx.root, dbPath: pctx.dbPath,
|
|
100
|
-
peer: id, executorId, ownerIdentity,
|
|
137
|
+
peer: id, executorId, ownerIdentity, rootIdentity: pctx.rootIdentity || selectedRoot,
|
|
101
138
|
binding: { transport: 'app-server', runtimeSessionId: id } });
|
|
102
139
|
return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
|
|
103
140
|
}, onChange(state, event, metadata) {
|
|
@@ -127,6 +164,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
127
164
|
if (threadId) {
|
|
128
165
|
const source = await adapter.peekThread(threadId);
|
|
129
166
|
assertProjectThread(pctx, source.thread, threadId);
|
|
167
|
+
assertRecordedThreadRoot(pctx, threadId);
|
|
130
168
|
}
|
|
131
169
|
assertSelectedProject(pctx);
|
|
132
170
|
const response = threadId ? await adapter.resumeThread(threadId, params) : await adapter.startThread(params);
|
|
@@ -134,6 +172,8 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
134
172
|
const thread = response.thread;
|
|
135
173
|
if (!thread?.id) throw new CliError('CODEX_PROTOCOL_ERROR', 'App Server returned no thread identity');
|
|
136
174
|
if (threadId && thread.id !== threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Resumed thread identity changed');
|
|
175
|
+
assertSelectedCwdSnapshot(selectedRoot);
|
|
176
|
+
if (!threadId) recordThreadRoot(pctx, thread.id, selectedRoot, 'new');
|
|
137
177
|
const executorState = adapter.snapshot();
|
|
138
178
|
if (executorState.status !== 'ready' || executorState.processExited) {
|
|
139
179
|
throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
|
|
@@ -233,7 +273,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
233
273
|
broadcast(session, { type: 'exit', event: { reason: 'executor stopped' } });
|
|
234
274
|
closeSessionClients(session);
|
|
235
275
|
}
|
|
236
|
-
function
|
|
276
|
+
function assertThreadPath(pctx, thread, expectedId = null) {
|
|
237
277
|
assertSelectedProject(pctx);
|
|
238
278
|
let matches = false;
|
|
239
279
|
try { matches = typeof thread?.cwd === 'string' && fs.realpathSync(thread.cwd) === fs.realpathSync(pctx.root); } catch {}
|
|
@@ -244,6 +284,12 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
244
284
|
return thread;
|
|
245
285
|
}
|
|
246
286
|
|
|
287
|
+
function assertProjectThread(pctx, thread, expectedId = null, knownBindings = null) {
|
|
288
|
+
assertThreadPath(pctx, thread, expectedId);
|
|
289
|
+
assertRecordedThreadRoot(pctx, thread.id, knownBindings);
|
|
290
|
+
return thread;
|
|
291
|
+
}
|
|
292
|
+
|
|
247
293
|
function threadIdentifier(value) {
|
|
248
294
|
if (typeof value !== 'string' || !value.trim() || value.length > 512 || /[\r\n\0]/.test(value)) {
|
|
249
295
|
throw new CliError('BAD_REQUEST', 'A bounded thread identity is required');
|
|
@@ -253,18 +299,22 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
253
299
|
|
|
254
300
|
async function withHistoryAdapter(pctx, action, fresh = false) {
|
|
255
301
|
assertSelectedProject(pctx);
|
|
302
|
+
const selectedRoot = selectedRootSnapshot(pctx);
|
|
256
303
|
const managed = !fresh && [...sessions.values()].find(session => session.type === 'app-server' &&
|
|
257
304
|
managedSessionMatchesProject(session, pctx) && session.status === 'running' && session.adapter.snapshot().status === 'ready');
|
|
258
305
|
if (managed) {
|
|
259
306
|
const result = await action(managed.adapter);
|
|
307
|
+
assertSelectedCwdSnapshot(selectedRoot);
|
|
260
308
|
assertSelectedProject(pctx);
|
|
261
309
|
return result;
|
|
262
310
|
}
|
|
263
311
|
const adapter = adapterFactory({ cwd: pctx.root,
|
|
312
|
+
expectedCwdIdentity: pctx.rootIdentity || selectedRoot,
|
|
264
313
|
env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' },
|
|
265
314
|
process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }) });
|
|
266
315
|
try {
|
|
267
316
|
const result = await action(adapter);
|
|
317
|
+
assertSelectedCwdSnapshot(selectedRoot);
|
|
268
318
|
assertSelectedProject(pctx);
|
|
269
319
|
return result;
|
|
270
320
|
} finally {
|
|
@@ -283,10 +333,17 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
283
333
|
}
|
|
284
334
|
const result = await withHistoryAdapter(pctx, adapter => adapter.listThreads({ cwd: pctx.root, limit, cursor }));
|
|
285
335
|
assertSelectedProject(pctx);
|
|
336
|
+
const db = connectWebProject(pctx);
|
|
337
|
+
let knownBindings;
|
|
338
|
+
try { knownBindings = readCodexThreadRootBindings(db); }
|
|
339
|
+
finally { db.close(); }
|
|
340
|
+
let unverifiedCount = 0;
|
|
286
341
|
const threads = (result.data || []).filter(thread => {
|
|
287
|
-
try { assertProjectThread(pctx, thread); return true; }
|
|
342
|
+
try { assertProjectThread(pctx, thread, null, knownBindings); return true; }
|
|
288
343
|
catch (error) {
|
|
289
344
|
if (error?.code === 'PROJECT_PATH_CHANGED') throw error;
|
|
345
|
+
if (error?.code === 'CODEX_HISTORY_UNVERIFIED' || error?.code === 'CODEX_HISTORY_BINDING_INVALID') unverifiedCount += 1;
|
|
346
|
+
else if (error?.code !== 'PROJECT_PATH_FORBIDDEN') throw error;
|
|
290
347
|
return false;
|
|
291
348
|
}
|
|
292
349
|
}).map(thread => {
|
|
@@ -295,11 +352,12 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
295
352
|
return { ...thread, managedSessionId: managed?.id || null };
|
|
296
353
|
});
|
|
297
354
|
assertSelectedProject(pctx);
|
|
298
|
-
return { threads, nextCursor: result.nextCursor || null };
|
|
355
|
+
return { threads, nextCursor: result.nextCursor || null, unverifiedCount };
|
|
299
356
|
}
|
|
300
357
|
|
|
301
358
|
async function readCodexThread(pctx, threadId) {
|
|
302
359
|
threadIdentifier(threadId);
|
|
360
|
+
assertRecordedThreadRoot(pctx, threadId);
|
|
303
361
|
const result = await withHistoryAdapter(pctx, adapter => adapter.peekThread(threadId));
|
|
304
362
|
return { thread: assertProjectThread(pctx, result.thread, threadId) };
|
|
305
363
|
}
|
|
@@ -313,6 +371,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
313
371
|
if (!['confirmed', 'lastTurnId', 'action_token', 'actionToken', 'epoch', 'authorizeMutation'].includes(key)) throw new CliError('BAD_REQUEST', `Unsupported history fork option: ${key}`);
|
|
314
372
|
}
|
|
315
373
|
if (input.lastTurnId !== undefined) threadIdentifier(input.lastTurnId);
|
|
374
|
+
assertRecordedThreadRoot(pctx, threadId);
|
|
316
375
|
// Fork on a temporary executor and close it before resume. A live source
|
|
317
376
|
// keeps its process and active turn; the new thread has one managed owner.
|
|
318
377
|
const result = await withHistoryAdapter(pctx, async adapter => {
|
|
@@ -320,8 +379,9 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
320
379
|
assertProjectThread(pctx, source.thread, threadId);
|
|
321
380
|
assertSelectedProject(pctx);
|
|
322
381
|
const forked = await adapter.forkThread(threadId, input.lastTurnId ? { lastTurnId: input.lastTurnId } : {}, input.authorizeMutation);
|
|
323
|
-
|
|
382
|
+
assertThreadPath(pctx, forked.thread);
|
|
324
383
|
if (forked.thread.id === threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Fork did not create a new thread');
|
|
384
|
+
recordThreadRoot(pctx, forked.thread.id, selectedRootSnapshot(pctx), 'fork');
|
|
325
385
|
return forked;
|
|
326
386
|
}, true);
|
|
327
387
|
try {
|