@lyupro/codex-bridge 0.6.7 → 0.6.9
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 +148 -0
- package/README.md +10 -6
- package/bin/codex-bridge.mjs +35 -172
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +21 -11
- package/cli/dispatcher-contract.mjs +6 -5
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +35 -7
- package/cli/handback-witness-check.mjs +10 -18
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +36 -21
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +101 -77
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/probe-contract.mjs +26 -10
- package/cli/probe-target.mjs +33 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/session-hosts.mjs +51 -0
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -126
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +33 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/dispatcher-gate.mjs +15 -2
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +24 -9
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +33 -25
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +60 -0
- package/src/home/lib/host-version.mjs +27 -0
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
|
@@ -1,111 +1,111 @@
|
|
|
1
|
-
/** Reports whether what this package installed into the host still matches the package. */
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
buildInstallPlan,
|
|
6
|
-
contentFingerprint,
|
|
7
|
-
fileFingerprint,
|
|
8
|
-
} from './manifest.mjs';
|
|
9
|
-
import { plannedContent } from './copy.mjs';
|
|
10
|
-
import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
|
|
11
|
-
import { readRulesRegistry } from './rules-owners.mjs';
|
|
12
|
-
import { parseFrontmatter } from './frontmatter.mjs';
|
|
13
|
-
import { check } from './doctor-format.mjs';
|
|
14
|
-
|
|
15
|
-
export async function exists(target) {
|
|
16
|
-
try {
|
|
17
|
-
await fs.access(target);
|
|
18
|
-
return true;
|
|
19
|
-
} catch {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function isFile(target) {
|
|
25
|
-
try {
|
|
26
|
-
return (await fs.stat(target)).isFile();
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function agentsCheck(host, record) {
|
|
33
|
-
if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
|
|
34
|
-
const plan = await buildInstallPlan(host);
|
|
35
|
-
const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
|
|
36
|
-
const mismatches = [];
|
|
37
|
-
const unreadable = [];
|
|
38
|
-
for (const item of agents) {
|
|
39
|
-
const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
|
|
40
|
-
if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
|
|
41
|
-
const expectedName = path.basename(item.source, '.md');
|
|
42
|
-
try {
|
|
43
|
-
const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
|
|
44
|
-
if (!frontmatter) throw new Error('frontmatter is missing');
|
|
45
|
-
if (frontmatter.name !== expectedName) {
|
|
46
|
-
throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
|
|
47
|
-
}
|
|
48
|
-
} catch (err) {
|
|
49
|
-
unreadable.push(`${path.basename(item.target)} (${err.message})`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
|
|
53
|
-
// advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
|
|
54
|
-
// the package command — in an installed definition: doctor kept exit 0 while every delegation
|
|
55
|
-
// would stop on a permission prompt. A definition that is not this package's is not judged healthy.
|
|
56
|
-
if (unreadable.length) {
|
|
57
|
-
return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
|
|
58
|
-
}
|
|
59
|
-
return mismatches.length
|
|
60
|
-
? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
|
|
61
|
-
: check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function conventionsCheck(host) {
|
|
65
|
-
const file = host.brandConventionsPath;
|
|
66
|
-
let content;
|
|
67
|
-
try {
|
|
68
|
-
content = await fs.readFile(file, 'utf8');
|
|
69
|
-
} catch (err) {
|
|
70
|
-
if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
|
|
71
|
-
return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
|
|
72
|
-
}
|
|
73
|
-
return content.trim()
|
|
74
|
-
? check('conventions', 'ok', `${file} (found)`)
|
|
75
|
-
: check('conventions', 'warn', `${file} (found but empty)`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export async function rulesCheck(host, record) {
|
|
79
|
-
if (!record) return check('rules', 'warn', 'cannot check before installation');
|
|
80
|
-
if (!record.rules) {
|
|
81
|
-
return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
|
|
82
|
-
}
|
|
83
|
-
let registry;
|
|
84
|
-
try {
|
|
85
|
-
registry = await readRulesRegistry(host);
|
|
86
|
-
} catch (err) {
|
|
87
|
-
// Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
|
|
88
|
-
return check('rules', 'fail', err.message);
|
|
89
|
-
}
|
|
90
|
-
const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
|
|
91
|
-
const fingerprint = await fileFingerprint(record.rules.path);
|
|
92
|
-
if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
|
|
93
|
-
return fingerprint === record.rules.fingerprint
|
|
94
|
-
? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
|
|
95
|
-
: check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function permissionsCheck(host) {
|
|
99
|
-
try {
|
|
100
|
-
const status = await inspectPermissions(host.settingsPath);
|
|
101
|
-
// Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
|
|
102
|
-
// doctor does not turn a healthy installation red merely because hardening was not requested.
|
|
103
|
-
// The ask count is part of the line because a full set shadowed by `ask` reads as working and
|
|
104
|
-
// is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
|
|
105
|
-
const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
|
|
106
|
-
return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
|
|
107
|
-
`${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
|
|
108
|
-
} catch (err) {
|
|
109
|
-
return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
1
|
+
/** Reports whether what this package installed into the host still matches the package. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
buildInstallPlan,
|
|
6
|
+
contentFingerprint,
|
|
7
|
+
fileFingerprint,
|
|
8
|
+
} from './manifest.mjs';
|
|
9
|
+
import { plannedContent } from './copy.mjs';
|
|
10
|
+
import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
|
|
11
|
+
import { readRulesRegistry } from './rules-owners.mjs';
|
|
12
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
13
|
+
import { check } from './doctor-format.mjs';
|
|
14
|
+
|
|
15
|
+
export async function exists(target) {
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(target);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function isFile(target) {
|
|
25
|
+
try {
|
|
26
|
+
return (await fs.stat(target)).isFile();
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function agentsCheck(host, record) {
|
|
33
|
+
if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
|
|
34
|
+
const plan = await buildInstallPlan(host);
|
|
35
|
+
const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
|
|
36
|
+
const mismatches = [];
|
|
37
|
+
const unreadable = [];
|
|
38
|
+
for (const item of agents) {
|
|
39
|
+
const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
|
|
40
|
+
if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
|
|
41
|
+
const expectedName = path.basename(item.source, '.md');
|
|
42
|
+
try {
|
|
43
|
+
const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
|
|
44
|
+
if (!frontmatter) throw new Error('frontmatter is missing');
|
|
45
|
+
if (frontmatter.name !== expectedName) {
|
|
46
|
+
throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
unreadable.push(`${path.basename(item.target)} (${err.message})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
|
|
53
|
+
// advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
|
|
54
|
+
// the package command — in an installed definition: doctor kept exit 0 while every delegation
|
|
55
|
+
// would stop on a permission prompt. A definition that is not this package's is not judged healthy.
|
|
56
|
+
if (unreadable.length) {
|
|
57
|
+
return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
|
|
58
|
+
}
|
|
59
|
+
return mismatches.length
|
|
60
|
+
? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
|
|
61
|
+
: check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function conventionsCheck(host) {
|
|
65
|
+
const file = host.brandConventionsPath;
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = await fs.readFile(file, 'utf8');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
|
|
71
|
+
return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
return content.trim()
|
|
74
|
+
? check('conventions', 'ok', `${file} (found)`)
|
|
75
|
+
: check('conventions', 'warn', `${file} (found but empty)`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function rulesCheck(host, record) {
|
|
79
|
+
if (!record) return check('rules', 'warn', 'cannot check before installation');
|
|
80
|
+
if (!record.rules) {
|
|
81
|
+
return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
|
|
82
|
+
}
|
|
83
|
+
let registry;
|
|
84
|
+
try {
|
|
85
|
+
registry = await readRulesRegistry(host);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
|
|
88
|
+
return check('rules', 'fail', err.message);
|
|
89
|
+
}
|
|
90
|
+
const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
|
|
91
|
+
const fingerprint = await fileFingerprint(record.rules.path);
|
|
92
|
+
if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
|
|
93
|
+
return fingerprint === record.rules.fingerprint
|
|
94
|
+
? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
|
|
95
|
+
: check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function permissionsCheck(host) {
|
|
99
|
+
try {
|
|
100
|
+
const status = await inspectPermissions(host.settingsPath);
|
|
101
|
+
// Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
|
|
102
|
+
// doctor does not turn a healthy installation red merely because hardening was not requested.
|
|
103
|
+
// The ask count is part of the line because a full set shadowed by `ask` reads as working and
|
|
104
|
+
// is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
|
|
105
|
+
const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
|
|
106
|
+
return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
|
|
107
|
+
`${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { packageSource } from './package-source.mjs';
|
|
6
6
|
import { readInstallRecord, packageInfo } from './manifest.mjs';
|
|
7
|
-
import { recordTarget } from './install-record.mjs';
|
|
7
|
+
import { readInstallRecordFile, recordTarget } from './install-record.mjs';
|
|
8
8
|
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
9
9
|
import { check, renderDoctor } from './doctor-format.mjs';
|
|
10
|
+
import { homeOwnersCheck, hostSideCheck, ownerEntry, ownersInSyncCheck } from './doctor-host-side.mjs';
|
|
11
|
+
import { inspectHost } from './host-inspection.mjs';
|
|
10
12
|
import {
|
|
11
13
|
agentsCheck,
|
|
12
14
|
conventionsCheck,
|
|
@@ -16,13 +18,15 @@ import {
|
|
|
16
18
|
rulesCheck,
|
|
17
19
|
} from './doctor-installation.mjs';
|
|
18
20
|
import { hookChecks } from './doctor-hooks.mjs';
|
|
19
|
-
import { contractStatus,
|
|
21
|
+
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
20
22
|
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
21
23
|
import { dispatcherContractStatus } from './dispatcher-contract.mjs';
|
|
22
24
|
import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
|
|
23
25
|
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
24
26
|
import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
|
|
25
27
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
28
|
+
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
29
|
+
import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
|
|
26
30
|
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
27
31
|
|
|
28
32
|
export { renderDoctor };
|
|
@@ -81,6 +85,7 @@ export async function diagnose({
|
|
|
81
85
|
handbackWitnessRecord,
|
|
82
86
|
dispatcherContractRecord,
|
|
83
87
|
hostVersion,
|
|
88
|
+
observations,
|
|
84
89
|
} = {}) {
|
|
85
90
|
const checks = [sourceCheck()];
|
|
86
91
|
const hostExists = await exists(host.root);
|
|
@@ -105,6 +110,20 @@ export async function diagnose({
|
|
|
105
110
|
checks.push(check('installation', 'fail', `broken record: ${err.message}`));
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
let rawRecord = null;
|
|
114
|
+
let rawRecordError = null;
|
|
115
|
+
try {
|
|
116
|
+
rawRecord = await readInstallRecordFile(host);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
rawRecordError = err;
|
|
119
|
+
}
|
|
120
|
+
// The owner row goes into the inspection so "kept by uninstall" names exactly what uninstall would keep.
|
|
121
|
+
const inspection = await inspectHost(host, { owner: ownerEntry(rawRecord, host) });
|
|
122
|
+
checks.push(hostSideCheck(host, inspection));
|
|
123
|
+
checks.push(rawRecordError
|
|
124
|
+
? check('home owners', 'fail', `broken record: ${rawRecordError.message}`)
|
|
125
|
+
: await homeOwnersCheck(host, rawRecord, inspection));
|
|
126
|
+
if (rawRecordError === null) checks.push(ownersInSyncCheck(rawRecord));
|
|
108
127
|
const missingFiles = [];
|
|
109
128
|
if (record) {
|
|
110
129
|
for (const file of record.files) {
|
|
@@ -124,27 +143,35 @@ export async function diagnose({
|
|
|
124
143
|
const bridge = bridgeProbe();
|
|
125
144
|
checks.push(bridgeCommandCheck(bridge));
|
|
126
145
|
checks.push(...await hookChecks(host, record, launcherProbe));
|
|
127
|
-
const
|
|
146
|
+
const stateDir = brandStateDir(host.brandRoot);
|
|
147
|
+
const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
|
|
148
|
+
const observedVersions = sessionHostVersions(hostObservations);
|
|
149
|
+
const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
|
|
150
|
+
checks.push(sessionHostCheck(observedVersions, hostObservations));
|
|
151
|
+
const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
|
|
128
152
|
const hostContract = contractStatus({
|
|
129
|
-
record:
|
|
153
|
+
record: refusalRecord,
|
|
130
154
|
version: detectedHostVersion,
|
|
131
155
|
});
|
|
132
156
|
const hostContractStatus = hostContract.state === 'verified'
|
|
133
157
|
? 'ok'
|
|
134
158
|
: hostContract.state === 'ignored' ? 'fail' : 'warn';
|
|
135
159
|
checks.push(check('hostContract', hostContractStatus, hostContract.message));
|
|
136
|
-
const stateDir = brandStateDir(host.brandRoot);
|
|
137
160
|
const witness = handbackWitnessStatus({
|
|
138
161
|
record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
|
|
139
162
|
hostVersion: detectedHostVersion,
|
|
140
163
|
stateDir,
|
|
141
164
|
});
|
|
142
165
|
checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
|
|
166
|
+
const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
|
|
143
167
|
checks.push(...dispatcherContractChecks({
|
|
144
|
-
record:
|
|
168
|
+
record: dispatcherRecord,
|
|
145
169
|
version: detectedHostVersion,
|
|
146
170
|
stateDir,
|
|
147
171
|
}));
|
|
172
|
+
for (const version of observedVersions.filter((item) => item !== detectedHostVersion)) {
|
|
173
|
+
checks.push(otherHostCheck({ version, contractRecord: refusalRecord, dispatcherRecord }));
|
|
174
|
+
}
|
|
148
175
|
const retention = retentionCheck(host);
|
|
149
176
|
checks.push(retention);
|
|
150
177
|
const conventions = await conventionsCheck(host);
|
|
@@ -163,7 +190,8 @@ export async function diagnose({
|
|
|
163
190
|
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
164
191
|
|| hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
|
|
165
192
|
|| projectRuns.status === 'fail'
|
|
166
|
-
|| checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
|
|
193
|
+
|| checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
|
|
194
|
+
|| checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
|
|
167
195
|
checks,
|
|
168
196
|
record,
|
|
169
197
|
missingFiles,
|
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
/** Surfaces handback drift
|
|
1
|
+
/** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
|
|
4
4
|
|
|
5
|
-
function versionParts(value) {
|
|
6
|
-
if (typeof value !== 'string') return null;
|
|
7
|
-
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/);
|
|
8
|
-
return match ? { version: match[0].trim(), patch: match[3] } : null;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
5
|
export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
|
|
12
6
|
if (record?.corrupt) {
|
|
13
7
|
return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
|
|
@@ -16,24 +10,22 @@ export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
|
|
|
16
10
|
const alarm = record.alarms.at(-1);
|
|
17
11
|
return {
|
|
18
12
|
state: 'alarm',
|
|
19
|
-
message: `${record.alarms.length} dispatcher alarm(s); newest: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
|
|
13
|
+
message: `${record.alarms.length} dispatcher alarm(s); newest on ${alarm.hostVersion ?? 'unknown host'}: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
|
|
20
14
|
};
|
|
21
15
|
}
|
|
22
|
-
if (!record?.lastSeen) {
|
|
16
|
+
if (!record?.lastSeen || Object.keys(record.lastSeen).length === 0) {
|
|
23
17
|
return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
|
|
24
18
|
}
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
if (record.lastSeen[hostVersion]) {
|
|
21
|
+
return { state: 'seen', message: `Handback contract last seen on host ${hostVersion} at ${record.lastSeen[hostVersion]}.` };
|
|
22
|
+
}
|
|
23
|
+
const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
|
|
24
|
+
if (newest) {
|
|
30
25
|
return {
|
|
31
26
|
state: 'stale',
|
|
32
|
-
message: `
|
|
27
|
+
message: `Handback contract last seen on host ${newest} at ${iso}, not yet on host ${hostVersion}; it is recorded the next time a dispatcher runs in an interactive session on that host.`,
|
|
33
28
|
};
|
|
34
29
|
}
|
|
35
|
-
return {
|
|
36
|
-
state: 'seen',
|
|
37
|
-
message: `Handback contract last seen on SDK ${record.lastSeen.sdkVersion} (host ${hostVersion}) at ${record.lastSeen.at}.`,
|
|
38
|
-
};
|
|
30
|
+
return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
|
|
39
31
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lists what lies in the package home, classified by the home registry, without crossing a link.
|
|
3
|
+
*
|
|
4
|
+
* Plan_65 D4 trusts the root as written: a relocated home or CODEX_BRIDGE_HOME may legitimately
|
|
5
|
+
* reach it through a link. Below that root, links and junctions stay and are named, never read
|
|
6
|
+
* through or removed. Walking ancestors first prevents even an lstat below a discovered link.
|
|
7
|
+
* Only ENOENT means gone; every other read failure must stay visible to a later removal planner.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
HOME_ARTIFACTS,
|
|
13
|
+
HOME_DIRECTORIES,
|
|
14
|
+
classifyHomePath,
|
|
15
|
+
homeArtifact,
|
|
16
|
+
} from '../src/home/lib/home-registry.mjs';
|
|
17
|
+
|
|
18
|
+
function knownDirectories(imageMembers) {
|
|
19
|
+
const directories = new Set(HOME_DIRECTORIES);
|
|
20
|
+
const addAncestors = (relative) => {
|
|
21
|
+
let parent = path.posix.dirname(relative);
|
|
22
|
+
while (parent !== '.' && parent !== '/') {
|
|
23
|
+
directories.add(parent);
|
|
24
|
+
parent = path.posix.dirname(parent);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (const artifact of HOME_ARTIFACTS) {
|
|
28
|
+
if (Array.isArray(artifact.primary)) {
|
|
29
|
+
for (const relative of artifact.primary) addAncestors(relative);
|
|
30
|
+
} else {
|
|
31
|
+
directories.add(artifact.primary.dir);
|
|
32
|
+
addAncestors(artifact.primary.dir);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const relative of imageMembers) addAncestors(relative);
|
|
36
|
+
return directories;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const byRelative = (left, right) => (
|
|
40
|
+
left.relative < right.relative ? -1 : left.relative > right.relative ? 1 : 0
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export function inspectHome(brandRoot, {
|
|
44
|
+
imageMembers = [],
|
|
45
|
+
lstat = fs.lstatSync,
|
|
46
|
+
readdir = fs.readdirSync,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const result = {
|
|
49
|
+
root: 'present',
|
|
50
|
+
rootCode: null,
|
|
51
|
+
files: [],
|
|
52
|
+
unknown: [],
|
|
53
|
+
links: [],
|
|
54
|
+
errors: [],
|
|
55
|
+
directories: [],
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
// D4 deliberately follows the root itself, unlike every entry below it.
|
|
59
|
+
if (!fs.statSync(brandRoot).isDirectory()) {
|
|
60
|
+
result.root = 'error';
|
|
61
|
+
result.rootCode = 'ENOTDIR';
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
result.root = error.code === 'ENOENT' ? 'missing' : 'error';
|
|
66
|
+
result.rootCode = error.code === 'ENOENT' ? null : error.code;
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const known = knownDirectories(imageMembers);
|
|
71
|
+
const recordError = (relative, error) => {
|
|
72
|
+
if (error.code !== 'ENOENT') result.errors.push({ relative, code: error.code });
|
|
73
|
+
};
|
|
74
|
+
const walk = (relative) => {
|
|
75
|
+
const absolute = relative ? path.join(brandRoot, ...relative.split('/')) : brandRoot;
|
|
76
|
+
let names;
|
|
77
|
+
try {
|
|
78
|
+
names = readdir(absolute);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
recordError(relative, error);
|
|
81
|
+
return error.code !== 'ENOENT';
|
|
82
|
+
}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
const child = relative ? `${relative}/${name}` : name;
|
|
85
|
+
let stats;
|
|
86
|
+
try {
|
|
87
|
+
stats = lstat(path.join(absolute, name));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
recordError(child, error);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (stats.isSymbolicLink()) {
|
|
93
|
+
result.links.push({ relative: child });
|
|
94
|
+
} else if (stats.isFile()) {
|
|
95
|
+
const match = classifyHomePath(child, { imageMembers });
|
|
96
|
+
if (match) {
|
|
97
|
+
result.files.push({ relative: child, ...match, removal: homeArtifact(match.id).removal });
|
|
98
|
+
} else {
|
|
99
|
+
result.unknown.push({ relative: child, kind: 'file' });
|
|
100
|
+
}
|
|
101
|
+
} else if (stats.isDirectory()) {
|
|
102
|
+
// An undeclared folder is unknown as a whole: its contents cannot be ours, and an
|
|
103
|
+
// operator's large folder (a backup, a checkout) must not be walked to say so.
|
|
104
|
+
if (known.has(child)) {
|
|
105
|
+
if (walk(child)) result.directories.push({ relative: child });
|
|
106
|
+
} else {
|
|
107
|
+
result.unknown.push({ relative: child, kind: 'directory' });
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
result.unknown.push({ relative: child, kind: 'other' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
walk('');
|
|
116
|
+
for (const list of [result.files, result.unknown, result.links, result.errors]) list.sort(byRelative);
|
|
117
|
+
// Deepest first: the order in which a later empty-folder removal can take them down.
|
|
118
|
+
result.directories.sort((left, right) => (
|
|
119
|
+
right.relative.split('/').length - left.relative.split('/').length || byRelative(left, right)
|
|
120
|
+
));
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carries out a home removal plan and records what happened to every entry.
|
|
3
|
+
*
|
|
4
|
+
* Plan_65 D12 item 7 requires the final message to describe outcomes: claiming removal from
|
|
5
|
+
* intentions hid edited files that uninstall actually kept. D4 forbids following links and
|
|
6
|
+
* recursive removal; segment checks run immediately before each planned removal.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createHomeWriter } from '../src/home/lib/home-write.mjs';
|
|
11
|
+
import { directoryArtifact } from '../src/home/lib/home-registry.mjs';
|
|
12
|
+
import { inspectSegments } from './link-segments.mjs';
|
|
13
|
+
import { installRecordPath, removeInstallOwner } from './install-record.mjs';
|
|
14
|
+
import { removeEmptyHome } from './remove-layout.mjs';
|
|
15
|
+
import { consumePurgeAuthorization } from './purge-preflight.mjs';
|
|
16
|
+
|
|
17
|
+
function findingReason(finding, root) {
|
|
18
|
+
if (finding.kind === 'link') {
|
|
19
|
+
const relative = path.relative(root, finding.at).split(path.sep).join('/');
|
|
20
|
+
return `link at ${relative}`;
|
|
21
|
+
}
|
|
22
|
+
if (finding.kind === 'error') return `unreadable: ${finding.code}`;
|
|
23
|
+
return finding.kind;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const failure = (error) => error.code ?? error.message;
|
|
27
|
+
|
|
28
|
+
async function executeFiles(host, plan, writer, add) {
|
|
29
|
+
for (const row of plan.rows) {
|
|
30
|
+
if (row.action === 'keep' || row.action === 'blocked' || row.action === 'missing') {
|
|
31
|
+
add(row.relative, row.action === 'keep' ? 'kept' : row.action, row.reason, row.id);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (row.action !== 'remove') throw new TypeError(`Invalid home file action: ${row.action}`);
|
|
35
|
+
const target = path.join(host.brandRoot, row.relative);
|
|
36
|
+
const finding = inspectSegments(target, host.brandRoot);
|
|
37
|
+
if (finding.kind !== 'clear') {
|
|
38
|
+
add(row.relative, finding.kind === 'missing' ? 'missing' : 'kept',
|
|
39
|
+
findingReason(finding, host.brandRoot), row.id);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await writer.unlink(row.id, target);
|
|
44
|
+
add(row.relative, 'removed', row.reason, row.id);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// One failed file does not stop the rest: each is reported, and a repeat run retries it.
|
|
47
|
+
add(row.relative, error.code === 'ENOENT' ? 'missing' : 'failed', failure(error), row.id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// After the files, even when one failed: the record is the inventory a repeat run needs, and
|
|
53
|
+
// it changes only for a detached host (cli/uninstall.mjs, D10 item 4).
|
|
54
|
+
async function executeRecord(host, record, writer, detached, add) {
|
|
55
|
+
const recordRelative = '.installed.json';
|
|
56
|
+
if (record.dependsOnDetach && !detached) {
|
|
57
|
+
add(recordRelative, 'kept', 'this host is still attached');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (record.operation === 'retain' || record.operation === 'none') {
|
|
61
|
+
add(recordRelative, record.operation === 'retain' ? 'kept' : 'missing', record.reason);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (record.operation !== 'delete' && record.operation !== 'remove-current-owner') {
|
|
65
|
+
throw new TypeError(`Invalid home record operation: ${record.operation}`);
|
|
66
|
+
}
|
|
67
|
+
const target = installRecordPath(host);
|
|
68
|
+
// D4 also guards the owner update, which reads the existing record before publishing it.
|
|
69
|
+
const finding = inspectSegments(target, host.brandRoot);
|
|
70
|
+
if (finding.kind !== 'clear') {
|
|
71
|
+
add(recordRelative, finding.kind === 'missing' ? 'missing' : 'kept', findingReason(finding, host.brandRoot));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
if (record.operation === 'delete') {
|
|
76
|
+
await writer.unlink('install-record', target);
|
|
77
|
+
add(recordRelative, 'removed', record.reason);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const updated = await removeInstallOwner(host);
|
|
81
|
+
add(recordRelative, updated === null ? 'missing' : 'kept',
|
|
82
|
+
updated === null ? 'missing' : 'this host removed from the record');
|
|
83
|
+
} catch (error) {
|
|
84
|
+
add(recordRelative, error.code === 'ENOENT' ? 'missing' : 'failed', failure(error));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function executeDirectories(host, directories, writer, imageMembers, add) {
|
|
89
|
+
for (const directory of directories) {
|
|
90
|
+
if (directory.action === 'keep') continue;
|
|
91
|
+
if (directory.action !== 'remove-if-empty') {
|
|
92
|
+
throw new TypeError(`Invalid home directory action: ${directory.action}`);
|
|
93
|
+
}
|
|
94
|
+
const target = path.join(host.brandRoot, directory.relative);
|
|
95
|
+
if (directory.relative !== '') {
|
|
96
|
+
const finding = inspectSegments(target, host.brandRoot);
|
|
97
|
+
if (finding.kind === 'missing') continue;
|
|
98
|
+
if (finding.kind !== 'clear') {
|
|
99
|
+
add(directory.relative, 'kept', findingReason(finding, host.brandRoot));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
if ((await fs.readdir(target)).length !== 0) continue;
|
|
105
|
+
if (directory.relative === '') {
|
|
106
|
+
await removeEmptyHome(writer, 'install-image', host.brandRoot);
|
|
107
|
+
// The helper tolerates absence and a newly non-empty root; only absence proves removal.
|
|
108
|
+
try {
|
|
109
|
+
await fs.lstat(target);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.code !== 'ENOENT') throw error;
|
|
112
|
+
add('', 'removed', directory.reason);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
await writer.rmdir(directoryArtifact(directory.relative, { imageMembers }), target);
|
|
116
|
+
add(directory.relative, 'removed', directory.reason);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error.code !== 'ENOENT') add(directory.relative, 'failed', failure(error));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function executeHomePlan(host, plan, { detached, imageMembers, authorization }) {
|
|
125
|
+
if (plan.command === 'purge') {
|
|
126
|
+
consumePurgeAuthorization(authorization, host, plan);
|
|
127
|
+
} else if (plan.command !== 'uninstall') {
|
|
128
|
+
throw new TypeError(`Invalid removal command: ${plan.command}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const writer = createHomeWriter({ root: host.brandRoot, imageMembers });
|
|
132
|
+
const outcomes = [];
|
|
133
|
+
// Each step tags its own outcomes: the renderer must not infer a folder from its position.
|
|
134
|
+
const adder = (kind, fixedId) => (relative, result, reason, id = fixedId) => (
|
|
135
|
+
outcomes.push({ lane: 'home', kind, id, relative, result, reason }));
|
|
136
|
+
await executeFiles(host, plan, writer, adder('file', null));
|
|
137
|
+
await executeRecord(host, plan.record, writer, detached, adder('record', 'install-record'));
|
|
138
|
+
await executeDirectories(host, plan.directories, writer, imageMembers, adder('directory', null));
|
|
139
|
+
return { outcomes };
|
|
140
|
+
}
|