@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +7 -4
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Asks the operator's permission for removals the installation record cannot prove safe: uninstall's
3
+ * shared image under an incomplete inventory (Plan_65 D9) and purge's two consents (D6, D12 item 5).
4
+ *
5
+ * An old installation record never named every host of the home, so leaving the last KNOWN owner
6
+ * behind, or finding no owner at all, is not proof that nobody else runs from the image. Only the
7
+ * operator can say so; "yes" is that permission, anything else keeps the image with a hint how to
8
+ * come back to the question.
9
+ */
10
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
11
+ import { readRulesRegistry } from './rules-owners.mjs';
12
+ import { registryHintLines } from './inventory-transition.mjs';
13
+
14
+ const KEPT_DATA = 'config.json, conventions.md and run data stay.';
15
+
16
+ export function lastOwnerQuestion(host, candidates) {
17
+ return [
18
+ `Home: ${host.brandRoot}`,
19
+ `Known owners: only this host (${host.root}), which is leaving.`,
20
+ 'The installation inventory is incomplete: an old installation record did not name every host that used this home.',
21
+ ...registryHintLines(host, candidates),
22
+ `Is ${host.root} the last host using this home? Yes removes the shared image and the installation record; ${KEPT_DATA}`,
23
+ ].join('\n');
24
+ }
25
+
26
+ export function orphanQuestion(host, candidates) {
27
+ return [
28
+ `Home: ${host.brandRoot}`,
29
+ 'No host is recorded as using this home, and the inventory is incomplete.',
30
+ ...registryHintLines(host, candidates),
31
+ `Remove the shared image and the installation record of this home? The package's files and hooks in ${host.root} are removed either way; ${KEPT_DATA}`,
32
+ ].join('\n');
33
+ }
34
+
35
+ const QUESTIONS = { 'last-owner': lastOwnerQuestion, orphan: orphanQuestion };
36
+
37
+ export async function askRemoval(host, kind, options) {
38
+ const question = QUESTIONS[kind];
39
+ if (!question) throw new TypeError(`Unknown inventory removal kind: ${kind}`);
40
+ if (!isInteractive(options)) return 'keep';
41
+ const candidates = options.candidates ?? (await readRulesRegistry(host))?.owners ?? [];
42
+ const answer = await askYesNo(question(host, candidates), options);
43
+ if (answer === 'yes') return 'remove';
44
+ if (answer === 'no') return 'keep';
45
+ return 'cancel';
46
+ }
47
+
48
+ // Plan_65 D6/D12 item 5: purge needs two separate consents, in this order. The first replaces the
49
+ // inventory the record cannot prove; the second is the only permission to delete the operator's own data.
50
+ export function purgeInventoryQuestion(host, candidates) {
51
+ return [
52
+ `Home: ${host.brandRoot}`,
53
+ `Purge removes this home for good, including what you edited in it. Leaving host: ${host.root}.`,
54
+ ...registryHintLines(host, candidates),
55
+ 'Is the inventory complete — does no other host use this home?',
56
+ ].join('\n');
57
+ }
58
+
59
+ export function purgeDataQuestion(host, dataFiles) {
60
+ const listed = dataFiles.length ? dataFiles.map((file) => ` ${file}`) : [' none found'];
61
+ return [
62
+ `Your data in ${host.brandRoot}:`,
63
+ ...listed,
64
+ 'Delete it? This cannot be undone; run artifacts outside the home stay.',
65
+ ].join('\n');
66
+ }
67
+
68
+ /**
69
+ * Asks one purge consent. Unlike askRemoval, "no terminal" is answered here as 'no': purge without
70
+ * a person at the keyboard has no consent to rely on, and the caller refuses rather than keeping.
71
+ */
72
+ export async function askPurgeConsent(host, kind, options, detail = []) {
73
+ if (kind !== 'inventory' && kind !== 'data') throw new TypeError(`Unknown purge consent: ${kind}`);
74
+ if (!isInteractive(options)) return 'no';
75
+ const question = kind === 'inventory'
76
+ ? purgeInventoryQuestion(host, options.candidates ?? (await readRulesRegistry(host))?.owners ?? [])
77
+ : purgeDataQuestion(host, detail);
78
+ return askYesNo(question, options);
79
+ }
80
+
81
+ export function removalHint(host) {
82
+ return `Run codex-bridge uninstall --host "${host.root}" again in a terminal to confirm that no other host uses `
83
+ + `${host.brandRoot} and remove it.`;
84
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * D9: The inventory becomes incomplete in one transition known before any write; ask then and
3
+ * store nothing about a refusal.
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
8
+ import { isFormat2 } from './install-owners.mjs';
9
+ import { readInstallRecordFile } from './install-record.mjs';
10
+ import { normalizedRulesOwner, readRulesRegistry } from './rules-owners.mjs';
11
+
12
+ async function exists(target) {
13
+ try {
14
+ await fs.access(target);
15
+ return true;
16
+ } catch (err) {
17
+ if (err.code === 'ENOENT') return false;
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ export async function detectTransition(host) {
23
+ const [record, homeHadImage] = await Promise.all([
24
+ readInstallRecordFile(host),
25
+ exists(path.join(host.brandRoot, 'lib')),
26
+ ]);
27
+ return {
28
+ transition: (record !== null && !isFormat2(record)) || (record === null && homeHadImage),
29
+ homeHadImage,
30
+ };
31
+ }
32
+
33
+ // The uninstall questions (cli/inventory-removal.mjs) show the same hint: one rendering, one wording.
34
+ export function registryHintLines(host, candidates) {
35
+ const owner = normalizedRulesOwner(host);
36
+ const otherCandidates = candidates.filter((root) => root !== owner);
37
+ return [
38
+ 'Codex rules registry host roots are a hint only; they may be stale or belong to another home:',
39
+ ...(otherCandidates.length ? otherCandidates.map((root) => ` ${root}`) : [' none found']),
40
+ ];
41
+ }
42
+
43
+ export function transitionQuestion(host, candidates) {
44
+ return [
45
+ `Home: ${host.brandRoot}`,
46
+ `Known owners: none yet besides this host (${host.root}).`,
47
+ 'The old installation record did not name every host that used this home.',
48
+ ...registryHintLines(host, candidates),
49
+ `Is ${host.root} the only host using this home?`,
50
+ ].join('\n');
51
+ }
52
+
53
+ export async function askTransition(host, options) {
54
+ if (!isInteractive(options)) return 'incomplete';
55
+ const candidates = options.candidates ?? (await readRulesRegistry(host))?.owners ?? [];
56
+ const answer = await askYesNo(transitionQuestion(host, candidates), options);
57
+ if (answer === 'yes') return 'complete';
58
+ if (answer === 'no') return 'incomplete';
59
+ return 'cancel';
60
+ }
61
+ /** What the operator is told after a transition: which answer was recorded and what it keeps. */
62
+ export function transitionOutcome(host, inventory) {
63
+ return inventory === 'complete'
64
+ ? `Recorded this host as the only one using ${host.brandRoot}.`
65
+ : 'The inventory stays incomplete: the shared image is kept until an uninstall confirms no other host uses it.';
66
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Removes a crashed holder's file lock, and only that (Plan_65 D11). D8 forbids ever removing a live
3
+ * holder's lock; advice A14 showed two clearers that both read dead holder X can remove the lock a new
4
+ * holder Y created in between, so clearers queue on a gate file taken before the authoritative read.
5
+ * Rename-then-verify is forbidden: moving a replacement live lock already frees its name.
6
+ */
7
+ import fsp from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
10
+ import { holderLiveness, lifecycleLockStrategy, parseHolder } from './lifecycle-lock.mjs';
11
+
12
+ const ARTIFACT_ID = 'install-record';
13
+ const errorReason = (error) => error?.code || error?.message || String(error);
14
+
15
+ async function clearHeldGate(lockPath, identity, writer) {
16
+ let before;
17
+ try {
18
+ before = await fsp.lstat(lockPath);
19
+ } catch (error) {
20
+ if (error?.code === 'ENOENT') return { outcome: 'free' };
21
+ throw error;
22
+ }
23
+ if (!before.isFile()) return { outcome: 'refused', reason: 'not a regular file' };
24
+ const content = await fsp.readFile(lockPath);
25
+ const holder = parseHolder(content.toString('utf8').split(/\r?\n/, 1)[0]);
26
+ if (!holder) return { outcome: 'refused', reason: 'holder record is unreadable or incomplete' };
27
+ const liveness = holderLiveness(holder, identity);
28
+ if (liveness !== 'dead') return { outcome: 'refused', reason: `the holder process is ${liveness}`, holder };
29
+
30
+ let current;
31
+ let after;
32
+ try {
33
+ current = await fsp.readFile(lockPath);
34
+ after = await fsp.lstat(lockPath);
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') {
37
+ return { outcome: 'refused', reason: 'the lock changed while clearing', holder };
38
+ }
39
+ return { outcome: 'refused', reason: errorReason(error), holder };
40
+ }
41
+ if (!after.isFile() || !content.equals(current)
42
+ || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
43
+ return { outcome: 'refused', reason: 'the lock changed while clearing', holder };
44
+ }
45
+ try {
46
+ await writer.unlink(ARTIFACT_ID, lockPath);
47
+ } catch (error) {
48
+ return { outcome: 'refused', reason: errorReason(error), holder };
49
+ }
50
+ try {
51
+ await fsp.lstat(lockPath);
52
+ return { outcome: 'refused', reason: 'the lock is still present after clearing', holder };
53
+ } catch (error) {
54
+ if (error?.code === 'ENOENT') return { outcome: 'cleared', holder };
55
+ return { outcome: 'refused', reason: errorReason(error), holder };
56
+ }
57
+ }
58
+
59
+ export async function clearLifecycleLock(homeRoot, {
60
+ platform = process.platform,
61
+ identity = {},
62
+ writer,
63
+ } = {}) {
64
+ const strategy = lifecycleLockStrategy(platform);
65
+ const lockPath = strategy === 'file' ? path.join(homeRoot, '.installed.json.lock') : undefined;
66
+ const base = { homeRoot, strategy, ...(lockPath === undefined ? {} : { lockPath }) };
67
+ const gatePath = lockPath === undefined ? undefined : `${lockPath}.clear`;
68
+ let handle;
69
+ let result;
70
+ try {
71
+ let home;
72
+ try {
73
+ home = await fsp.stat(homeRoot);
74
+ } catch (error) {
75
+ if (error?.code === 'ENOENT') return { ...base, outcome: 'no-home' };
76
+ throw error;
77
+ }
78
+ if (!home.isDirectory()) return { ...base, outcome: 'refused', reason: 'home is not a directory' };
79
+ if (strategy !== 'file') return { ...base, outcome: 'kernel-managed' };
80
+ writer ??= createHomeWriter({ root: homeRoot });
81
+ try {
82
+ handle = await writer.open(ARTIFACT_ID, gatePath, 'wx');
83
+ } catch (error) {
84
+ if (error?.code === 'EEXIST') return { ...base, outcome: 'gate-busy', gatePath };
85
+ throw error;
86
+ }
87
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
88
+ result = { ...base, ...await clearHeldGate(lockPath, identity, writer) };
89
+ } catch (error) {
90
+ result = { ...base, outcome: 'refused', reason: errorReason(error) };
91
+ } finally {
92
+ if (handle) {
93
+ // The outcome already happened: a gate that cannot be closed must not turn a removed lock into
94
+ // "nothing was removed". If it also cannot be unlinked, gateLeft below names it.
95
+ await handle.close().catch(() => {});
96
+ try {
97
+ await writer.unlink(ARTIFACT_ID, gatePath);
98
+ } catch (error) {
99
+ if (error?.code !== 'ENOENT') result = { ...result, gateLeft: gatePath };
100
+ }
101
+ }
102
+ }
103
+ return result;
104
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Inspects a lifecycle lock without changing its home or lock file for unlock diagnosis (Plan_65 D11).
3
+ * A14: a silent holder cannot prove freedom; only a refused connection followed by an exclusive
4
+ * trial hold can, and a dead file-lock holder still owns the present file until explicit clearing.
5
+ */
6
+ import fsp from 'node:fs/promises';
7
+ import net from 'node:net';
8
+ import { tryHoldSocket } from '../src/home/lib/kernel-lock.mjs';
9
+ import {
10
+ holderLiveness, lifecycleLockAddress, lifecycleLockStrategy, parseHolder,
11
+ } from './lifecycle-lock.mjs';
12
+
13
+ const refusedCodes = new Set(['ECONNREFUSED', 'ENOENT']);
14
+ const errorReason = (error) => error?.code || error?.message || String(error);
15
+
16
+ function readSocketAnswer(address, timeoutMs, createConnection) {
17
+ return new Promise((resolve) => {
18
+ let socket;
19
+ let timer;
20
+ let data = '';
21
+ let settled = false;
22
+ const finish = (outcome) => {
23
+ if (settled) return;
24
+ settled = true;
25
+ clearTimeout(timer);
26
+ socket?.destroy();
27
+ resolve(outcome);
28
+ };
29
+ const failed = (error) => finish({
30
+ kind: refusedCodes.has(error?.code) ? 'refused' : 'error',
31
+ reason: errorReason(error),
32
+ });
33
+ const answered = (line) => {
34
+ const holder = parseHolder(line);
35
+ finish(holder ? { kind: 'answer', holder } : { kind: 'malformed', reason: 'malformed holder answer' });
36
+ };
37
+ try {
38
+ socket = createConnection(address);
39
+ timer = setTimeout(() => finish({ kind: 'timeout', reason: 'holder answer timed out' }), timeoutMs);
40
+ socket.on('data', (chunk) => {
41
+ data += chunk.toString('utf8');
42
+ const newline = data.indexOf('\n');
43
+ if (newline !== -1) answered(data.slice(0, newline));
44
+ });
45
+ socket.once('end', () => answered(data));
46
+ socket.once('error', failed);
47
+ socket.once('close', () => finish({ kind: 'error', reason: 'connection closed without a holder answer' }));
48
+ } catch (error) {
49
+ failed(error);
50
+ }
51
+ });
52
+ }
53
+
54
+ export async function inspectLifecycleLock(homeRoot, {
55
+ platform = process.platform,
56
+ createServer = net.createServer,
57
+ answerTimeoutMs = 1_000,
58
+ identity = {},
59
+ createConnection = net.createConnection,
60
+ } = {}) {
61
+ const observation = {
62
+ strategy: lifecycleLockStrategy(platform),
63
+ homeRoot,
64
+ observedAt: new Date().toISOString(),
65
+ };
66
+ const result = (value) => ({ ...observation, ...value });
67
+ try {
68
+ const target = lifecycleLockAddress(homeRoot, { platform });
69
+ if (target === null) return result({ state: 'no-home' });
70
+ if (target.strategy === 'file') {
71
+ let content;
72
+ try {
73
+ content = await fsp.readFile(target.address, 'utf8');
74
+ } catch (error) {
75
+ if (error?.code === 'ENOENT') return result({ state: 'free' });
76
+ return result({ state: 'unverified', reason: errorReason(error) });
77
+ }
78
+ const holder = parseHolder(content.split(/\r?\n/, 1)[0]);
79
+ if (!holder) return result({ state: 'unverified', reason: 'malformed holder file' });
80
+ return result({ state: 'held', holder, liveness: holderLiveness(holder, identity) });
81
+ }
82
+ const answer = await readSocketAnswer(target.address, answerTimeoutMs, createConnection);
83
+ if (answer.kind === 'answer') return result({ state: 'held', holder: answer.holder });
84
+ if (answer.kind !== 'refused') return result({ state: 'unverified', reason: answer.reason });
85
+ const release = await tryHoldSocket(target.address, createServer, 'lifecycle lock');
86
+ if (release === 'in-use') return result({ state: 'held', holder: null });
87
+ await release();
88
+ return result({ state: 'free' });
89
+ } catch (error) {
90
+ return result({ state: 'unverified', reason: errorReason(error) });
91
+ }
92
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Serializes install, update and uninstall of one shared package home, which several Claude Code
3
+ * hosts may use at once (Plan_65 D5).
4
+ *
5
+ * The shared file lock deletes a lock older than its window, which would steal from an install
6
+ * paused by sleep or an antivirus scan. Deleting a lock whose holder looks dead is no safer: two
7
+ * waiters both see holder X gone, A deletes X's lock and takes it, B deletes A's on its stale
8
+ * observation, and both write (advice A5). So nothing here ever deletes another holder's lock, and
9
+ * where the kernel can release a lock on the holder's death, the lock is a kernel object: a named
10
+ * pipe on Windows, an abstract socket on Linux — both reproduced on real machines (hard kill, name
11
+ * free at once, nothing on disk), which matters on a machine that lost power six times in two weeks.
12
+ * macOS has no abstract namespace, so it gets an exclusively created file that only its own holder
13
+ * removes, and a crash leaves it for `codex-bridge unlock --lifecycle` (D8).
14
+ *
15
+ * The name comes from the home directory's `dev`+`ino`, not its path: a junction, another letter
16
+ * case or forward slashes reach the same home and must reach the same lock. A second listen inside
17
+ * the holder must fail, or the platform did not give exclusivity and the call refuses rather than
18
+ * switch protocols. What the holder answers a waiter is diagnosis only — it never grants anything.
19
+ */
20
+ import { randomUUID } from 'node:crypto';
21
+ import fsp from 'node:fs/promises';
22
+ import net from 'node:net';
23
+ import path from 'node:path';
24
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
25
+ import {
26
+ acquireKernelLock, directoryDigest, kernelLockAddress, kernelLockStrategy,
27
+ } from '../src/home/lib/kernel-lock.mjs';
28
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
29
+ import { processIdentity } from '../src/home/lib/process-identity.mjs';
30
+
31
+ const ARTIFACT_ID = 'install-record';
32
+ const LOCK_NAME = '.installed.json.lock';
33
+ const TAKEN_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
34
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
35
+
36
+ function validateOptions(homeRoot, { command, hostRoot, waitMs, retryMs, answerTimeoutMs }) {
37
+ if (typeof homeRoot !== 'string' || homeRoot.length === 0) throw new TypeError('homeRoot must be a non-empty path');
38
+ if (typeof command !== 'string' || command.length === 0) throw new TypeError('command must be a non-empty string');
39
+ if (typeof hostRoot !== 'string' || hostRoot.length === 0) throw new TypeError('hostRoot must be a non-empty path');
40
+ for (const [name, value, allowZero] of [
41
+ ['waitMs', waitMs, true], ['retryMs', retryMs, false], ['answerTimeoutMs', answerTimeoutMs, true],
42
+ ]) {
43
+ if (!Number.isFinite(value) || value < (allowZero ? 0 : 1)) {
44
+ throw new TypeError(`${name} must be a ${allowZero ? 'non-negative' : 'positive'} number`);
45
+ }
46
+ }
47
+ }
48
+
49
+ function statIdentity(homeRoot) {
50
+ try {
51
+ return directoryDigest(homeRoot);
52
+ } catch (error) {
53
+ if (error.message === `${homeRoot} is not a directory`) {
54
+ throw new Error(`lifecycle lock home is not a directory: ${homeRoot}`);
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export function lifecycleLockStrategy(platform = process.platform) {
61
+ return kernelLockStrategy(platform) ?? 'file';
62
+ }
63
+
64
+ function lockAddress(homeRoot, digest, platform) {
65
+ const strategy = lifecycleLockStrategy(platform);
66
+ const address = kernelLockAddress('lifecycle', digest, platform);
67
+ return { strategy, address: address ?? path.join(homeRoot, LOCK_NAME) };
68
+ }
69
+
70
+ export function lifecycleLockAddress(homeRoot, { platform = process.platform } = {}) {
71
+ const digest = statIdentity(homeRoot);
72
+ return digest === null ? null : lockAddress(homeRoot, digest, platform);
73
+ }
74
+
75
+ async function prepareHome(homeRoot) {
76
+ const writer = createHomeWriter({ root: homeRoot });
77
+ let digest = statIdentity(homeRoot);
78
+ if (digest === null) {
79
+ try {
80
+ await writer.mkdir(ARTIFACT_ID, homeRoot, { recursive: true });
81
+ } catch (error) {
82
+ throw new Error(`could not create lifecycle lock home through the home adapter: ${error.message}`, { cause: error });
83
+ }
84
+ digest = statIdentity(homeRoot);
85
+ if (digest === null) throw new Error(`home adapter did not create lifecycle lock home: ${homeRoot}`);
86
+ }
87
+ return { writer, digest };
88
+ }
89
+
90
+ function validHolder(value) {
91
+ return value && Number.isInteger(value.pid) && value.pid > 0
92
+ && typeof value.command === 'string' && value.command.length > 0
93
+ && typeof value.hostRoot === 'string' && value.hostRoot.length > 0
94
+ && typeof value.acquiredAt === 'string' && value.acquiredAt.length > 0
95
+ && typeof value.token === 'string' && value.token.length > 0;
96
+ }
97
+
98
+ export function parseHolder(line) {
99
+ try {
100
+ const holder = parseJsonText('<lifecycle lock holder>', line);
101
+ return validHolder(holder) ? holder : null;
102
+ } catch { return null; }
103
+ }
104
+
105
+ export function holderLiveness(holder, options = {}) {
106
+ // A14: acquisition is a run-style timestamp, not the holder process's exact start time.
107
+ return processIdentity({
108
+ pid: holder.pid,
109
+ started_at: holder.acquiredAt,
110
+ ignoreHeartbeat: true,
111
+ kill: options.kill,
112
+ probe: options.probe,
113
+ now: options.now,
114
+ });
115
+ }
116
+
117
+ function busyMessage(homeRoot, holder, hint = '') {
118
+ const details = validHolder(holder)
119
+ ? `held by ${holder.command} for ${holder.hostRoot}, pid ${holder.pid}, since ${holder.acquiredAt}`
120
+ : 'the holder did not answer (unverified)';
121
+ return new Error(`lifecycle lock busy for ${homeRoot}: ${details}; nothing was changed${hint}`);
122
+ }
123
+
124
+ function lockResult(token, strategy, releaseAction) {
125
+ let releasePromise;
126
+ return {
127
+ token,
128
+ strategy,
129
+ release() {
130
+ if (!releasePromise) releasePromise = releaseAction();
131
+ return releasePromise;
132
+ },
133
+ };
134
+ }
135
+
136
+ async function acquireSocketLock(homeRoot, holder, {
137
+ address, strategy, waitMs, retryMs, answerTimeoutMs, createServer,
138
+ }) {
139
+ const result = await acquireKernelLock({
140
+ address, holder, waitMs, retryMs, answerTimeoutMs, createServer,
141
+ parseAnswer: parseHolder,
142
+ label: 'lifecycle lock',
143
+ });
144
+ if (!result.held) throw busyMessage(homeRoot, result.holder);
145
+ return lockResult(holder.token, strategy, result.release);
146
+ }
147
+
148
+ async function acquireFileLock(homeRoot, writer, holder, { waitMs, retryMs }) {
149
+ const lockPath = path.join(homeRoot, LOCK_NAME);
150
+ const deadline = Date.now() + waitMs;
151
+ let handle;
152
+ for (;;) {
153
+ try {
154
+ handle = await writer.open(ARTIFACT_ID, lockPath, 'wx');
155
+ break;
156
+ } catch (error) {
157
+ if (!error || !TAKEN_CODES.has(error.code)) throw error;
158
+ const remaining = deadline - Date.now();
159
+ if (remaining <= 0) {
160
+ let current = null;
161
+ try { current = parseHolder((await fsp.readFile(lockPath, 'utf8')).split(/\r?\n/, 1)[0]); } catch { /* Diagnosis only. */ }
162
+ throw busyMessage(
163
+ homeRoot,
164
+ current,
165
+ '. If no install, update or uninstall is running, the lock was left by a crash: codex-bridge unlock --lifecycle',
166
+ );
167
+ }
168
+ await wait(Math.min(retryMs, remaining));
169
+ }
170
+ }
171
+
172
+ holder.acquiredAt = new Date().toISOString();
173
+ try {
174
+ await handle.writeFile(`${JSON.stringify(holder)}\n`);
175
+ } catch (error) {
176
+ await handle.close().catch(() => {});
177
+ await writer.unlink(ARTIFACT_ID, lockPath).catch(() => {});
178
+ throw error;
179
+ }
180
+ return lockResult(holder.token, 'file', async () => {
181
+ let closeError;
182
+ try { await handle.close(); } catch (error) { closeError = error; }
183
+ try {
184
+ await writer.unlink(ARTIFACT_ID, lockPath);
185
+ } catch (error) {
186
+ if (!error || error.code !== 'ENOENT') throw error;
187
+ }
188
+ if (closeError) throw closeError;
189
+ });
190
+ }
191
+
192
+ export async function acquireLifecycleLock(homeRoot, {
193
+ command,
194
+ hostRoot,
195
+ waitMs = 15_000,
196
+ retryMs = 100,
197
+ platform = process.platform,
198
+ answerTimeoutMs = 1_000,
199
+ createServer = net.createServer,
200
+ } = {}) {
201
+ validateOptions(homeRoot, { command, hostRoot, waitMs, retryMs, answerTimeoutMs });
202
+ const { writer, digest } = await prepareHome(homeRoot);
203
+ const holder = {
204
+ pid: process.pid,
205
+ command,
206
+ hostRoot,
207
+ token: randomUUID(),
208
+ };
209
+
210
+ if (platform === 'win32' || platform === 'linux') {
211
+ const { address, strategy } = lockAddress(homeRoot, digest, platform);
212
+ return acquireSocketLock(homeRoot, holder, {
213
+ address,
214
+ strategy,
215
+ waitMs,
216
+ retryMs,
217
+ answerTimeoutMs,
218
+ createServer,
219
+ });
220
+ }
221
+ return acquireFileLock(homeRoot, writer, holder, { waitMs, retryMs });
222
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Runs one install, update or uninstall of a shared package home as a single transaction under
3
+ * the home's lifecycle lock (Plan_65 D8).
4
+ *
5
+ * Two installs into one home used to interleave their image copies and their record writes, and
6
+ * uninstall removed permission rules from settings.json before it had read anything — so a
7
+ * refused uninstall would already have changed the host. The lock is therefore taken before the
8
+ * first read, outside the settings backup scope (backup bookkeeping is not exclusion). `update`
9
+ * nests `install`, and a second acquire inside one process is refused by design (the lock's own
10
+ * self-check), so the outer transaction hands the nested call a ticket instead: live only while
11
+ * the transaction runs, and bound to its home.
12
+ *
13
+ * Only install may bring a home into existence: the lock needs the directory, and an update or an
14
+ * uninstall that created an empty home folder just to lock it would leave debris behind a removal.
15
+ * A home that does not exist has nothing shared to protect, so those run without the lock.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { acquireLifecycleLock } from './lifecycle-lock.mjs';
20
+
21
+ const liveTickets = new WeakMap();
22
+
23
+ /**
24
+ * Throws unless `ticket` belongs to a transaction still running on this host's home. The purge authorization
25
+ * (Plan_65 D14) binds itself to the ticket through this check, so the two can never disagree about "live".
26
+ */
27
+ export function assertLiveTicket(ticket, host) {
28
+ if (!ticket || typeof ticket !== 'object' || !liveTickets.has(ticket)) {
29
+ throw new Error('lifecycle ticket is no longer active');
30
+ }
31
+ if (typeof host?.brandRoot !== 'string' || path.resolve(host.brandRoot) !== liveTickets.get(ticket)) {
32
+ throw new Error('lifecycle ticket belongs to a different home');
33
+ }
34
+ }
35
+
36
+ export async function withLifecycle(host, command, action, { ticket, waitMs, createHome = false } = {}) {
37
+ if (typeof action !== 'function') throw new TypeError('lifecycle action must be a function');
38
+
39
+ if (ticket !== undefined) {
40
+ assertLiveTicket(ticket, host);
41
+ return action(ticket);
42
+ }
43
+
44
+ if (!createHome && !fs.existsSync(host.brandRoot)) return action(undefined);
45
+
46
+ const lockOptions = { command, hostRoot: host.root };
47
+ if (waitMs !== undefined) lockOptions.waitMs = waitMs;
48
+ const lock = await acquireLifecycleLock(host.brandRoot, lockOptions);
49
+ const transactionTicket = Object.freeze({});
50
+ liveTickets.set(transactionTicket, path.resolve(host.brandRoot));
51
+ try {
52
+ return await action(transactionTicket);
53
+ } finally {
54
+ liveTickets.delete(transactionTicket);
55
+ await lock.release();
56
+ }
57
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Says whether a delete target is reached only through real directories below a trusted root.
3
+ *
4
+ * Lexical containment alone is satisfiable: independent review replaced `<root>/<project>` with a
5
+ * junction to an outside directory, and a target that still spelled out as inside the root deleted
6
+ * what was outside it. Paths are deliberately never resolved (`realpath` returns `\\?\` and UNC
7
+ * forms on Windows and creates a new class of mismatch), so every segment is checked with lstat,
8
+ * top-down, right before deletion. Plan_65 D4 made this one module for prune and the coming purge
9
+ * after prune's own copy was found wrong twice: it walked from the leaf up, touching paths under a
10
+ * link before reaching the link, and it read every lstat error as a clear path. Only ENOENT means
11
+ * gone here; any other error is a finding, and the caller keeps the entry. The root itself is taken
12
+ * as written, because relocating the runs root or the home through a link is legitimate.
13
+ * Containment compares through normalizePath, which folds Windows case: a root spelled `C:\Users`
14
+ * against a target resolved as `c:\users` must not read as "outside".
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { normalizePath } from '../src/home/hooks/live-runs.mjs';
19
+
20
+ export function inspectSegments(target, root, { lstat = (file) => fs.lstatSync(file) } = {}) {
21
+ const normalizedRoot = normalizePath(root);
22
+ const normalizedTarget = normalizePath(target);
23
+ if (!normalizedRoot || !normalizedTarget) return { kind: 'outside' };
24
+
25
+ const rootPrefix = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
26
+ if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(rootPrefix)) {
27
+ return { kind: 'outside' };
28
+ }
29
+
30
+ const rootPath = path.resolve(root);
31
+ const relativeTarget = path.relative(rootPath, path.resolve(target));
32
+ let current = rootPath;
33
+ for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {
34
+ current = path.join(current, segment);
35
+ let entry;
36
+ try {
37
+ entry = lstat(current);
38
+ } catch (error) {
39
+ if (error.code === 'ENOENT') return { kind: 'missing', at: current };
40
+ return { kind: 'error', at: current, code: error.code };
41
+ }
42
+ if (entry.isSymbolicLink()) return { kind: 'link', at: current };
43
+ }
44
+ return { kind: 'clear' };
45
+ }