@lyupro/codex-bridge 0.6.8 → 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 +109 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- 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 +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- 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 +11 -5
- 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 +88 -75
- 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/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/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- 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 +26 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- 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 +2 -7
- 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 +7 -4
- 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 +7 -4
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gathers what the removal plan of the home needs and builds it, without changing anything.
|
|
3
|
+
*
|
|
4
|
+
* Plan_65 D12 item 1 combines the registry's fixed paths, recorded image members, and the
|
|
5
|
+
* current package's members: a missing record must remain cleanable, and a member dropped
|
|
6
|
+
* from a newer package must still be found. D12 item 4 keeps corrupt records distinct from
|
|
7
|
+
* missing records so an unreadable installation record blocks removal.
|
|
8
|
+
*/
|
|
9
|
+
import { asFormat2, imageMembers, readInstallRecordFile } from './install-record.mjs';
|
|
10
|
+
import { validateFormat2 } from './install-owners.mjs';
|
|
11
|
+
import { imagePackageIndex, judgeImageFile } from './image-evidence.mjs';
|
|
12
|
+
import { inspectHome } from './home-inspection.mjs';
|
|
13
|
+
import { planHomeRemoval } from './removal-plan.mjs';
|
|
14
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
15
|
+
|
|
16
|
+
export async function buildHomeRemovalPlan({ command, host, packageRoot, imagePolicy }) {
|
|
17
|
+
let recordState;
|
|
18
|
+
let format2;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = await readInstallRecordFile(host);
|
|
21
|
+
if (parsed === null) {
|
|
22
|
+
recordState = 'missing';
|
|
23
|
+
} else {
|
|
24
|
+
const candidate = asFormat2(parsed, host);
|
|
25
|
+
validateFormat2(candidate);
|
|
26
|
+
format2 = candidate;
|
|
27
|
+
recordState = 'valid';
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
recordState = 'corrupt';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const packageIndex = await imagePackageIndex(host, packageRoot);
|
|
34
|
+
const recordedMembers = recordState === 'valid'
|
|
35
|
+
? imageMembers(format2, host).filter((entry) => entry.root === 'brand').map((entry) => entry.path)
|
|
36
|
+
: [];
|
|
37
|
+
const members = [...new Set([...recordedMembers, ...packageIndex.keys()])].sort();
|
|
38
|
+
const inspection = inspectHome(host.brandRoot, { imageMembers: members });
|
|
39
|
+
const fingerprints = recordState === 'valid'
|
|
40
|
+
? { brand: format2.image.fingerprints?.brand }
|
|
41
|
+
: undefined;
|
|
42
|
+
const imageEvidence = new Map();
|
|
43
|
+
for (const file of inspection.files) {
|
|
44
|
+
if (file.id !== 'install-image' || file.role !== 'primary') continue;
|
|
45
|
+
imageEvidence.set(file.relative, await judgeImageFile(
|
|
46
|
+
host, { root: 'brand', path: file.relative }, fingerprints, packageIndex,
|
|
47
|
+
));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
command,
|
|
52
|
+
...planHomeRemoval({
|
|
53
|
+
command, inspection, imageMembers: members, imageEvidence, imagePolicy,
|
|
54
|
+
recordState, format2, ownerKey: normalizeRepoPath(host.root),
|
|
55
|
+
}),
|
|
56
|
+
recordState,
|
|
57
|
+
homeRoot: inspection.root,
|
|
58
|
+
format2,
|
|
59
|
+
imageMembers: members,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognizes the package's own hook entries in a host's settings by their exact spelling, without
|
|
3
|
+
* an installation record (Plan_65 D10 item 1).
|
|
4
|
+
*
|
|
5
|
+
* A format-1 record never named its host, so a host without an owner entry could only be cleaned
|
|
6
|
+
* through somebody else's record. What the package wrote into a host carries its name; this module
|
|
7
|
+
* reads that mark. Only exact commands count: a substring, another interpreter or an unknown file
|
|
8
|
+
* is a foreign hook, and `commandForm` (settings-merge) only classifies spelling, it proves nothing.
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { HOOK_DEFINITIONS } from './manifest.mjs';
|
|
12
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
13
|
+
|
|
14
|
+
// Every directory a released version installed hook files into. Checked against git history:
|
|
15
|
+
// agents/codex-bridge never held hooks — they moved into the brand home (7a43fa4, 2026-08-10) a
|
|
16
|
+
// day before that directory existed (4eafc85).
|
|
17
|
+
const HOOK_DIRECTORIES = Object.freeze([
|
|
18
|
+
Object.freeze({ root: 'brandRoot', segments: ['hooks'] }),
|
|
19
|
+
// Before 7a43fa4 the path form pointed into host.agentsDir/hooks, and agentsDir was agents/codex.
|
|
20
|
+
Object.freeze({ root: 'root', segments: ['agents', 'codex', 'hooks'] }),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const DEFINITION_NAMES = new Set(HOOK_DEFINITIONS.map(({ name }) => name));
|
|
24
|
+
const SHORT_COMMAND = /^codex-bridge hook (\S+)$/;
|
|
25
|
+
const PATH_COMMAND = /^node "([^"\r\n]+)"$/;
|
|
26
|
+
|
|
27
|
+
// Windows compares file names without case, like normalizeRepoPath does for the rest of the path.
|
|
28
|
+
const sameFile = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
29
|
+
|
|
30
|
+
function inKnownDirectory(hookPath, host) {
|
|
31
|
+
const directory = normalizeRepoPath(path.dirname(hookPath));
|
|
32
|
+
return HOOK_DIRECTORIES.some(({ root, segments }) => typeof host?.[root] === 'string'
|
|
33
|
+
&& directory === normalizeRepoPath(path.join(host[root], ...segments)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One file can serve several hooks (dispatcher-gate.mjs is three), so a path names its hook only
|
|
37
|
+
// together with the event; without one, or with no match, the entry is still ours but unnamed.
|
|
38
|
+
function nameForFile(file, event) {
|
|
39
|
+
const candidates = HOOK_DEFINITIONS.filter((definition) => sameFile(definition.file, file));
|
|
40
|
+
const matching = event === undefined ? candidates : candidates.filter((definition) => definition.event === event);
|
|
41
|
+
return matching.length === 1 ? matching[0].name : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isOwnHookCommand(command, host, event) {
|
|
45
|
+
if (typeof command !== 'string') return { own: false };
|
|
46
|
+
const short = SHORT_COMMAND.exec(command);
|
|
47
|
+
if (short && DEFINITION_NAMES.has(short[1])) return { own: true, name: short[1], form: 'short' };
|
|
48
|
+
const spelled = PATH_COMMAND.exec(command);
|
|
49
|
+
if (!spelled || !path.isAbsolute(spelled[1])) return { own: false };
|
|
50
|
+
const file = path.basename(spelled[1]);
|
|
51
|
+
if (!HOOK_DEFINITIONS.some((definition) => sameFile(definition.file, file)) || !inKnownDirectory(spelled[1], host)) {
|
|
52
|
+
return { own: false };
|
|
53
|
+
}
|
|
54
|
+
return { own: true, name: nameForFile(file, event), form: 'path' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
|
|
59
|
+
/** Lists own hook entries across every event; unreadable settings throw, because unknown is not "none". */
|
|
60
|
+
export function findOwnHooks(settings, host) {
|
|
61
|
+
if (!isObject(settings)) throw new TypeError('settings must be an object');
|
|
62
|
+
if (!isObject(settings.hooks)) return [];
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
65
|
+
if (!Array.isArray(groups)) continue;
|
|
66
|
+
groups.forEach((group, groupIndex) => {
|
|
67
|
+
if (!isObject(group) || !Array.isArray(group.hooks)) return;
|
|
68
|
+
group.hooks.forEach((hook, hookIndex) => {
|
|
69
|
+
if (hook?.type !== 'command') return;
|
|
70
|
+
const recognized = isOwnHookCommand(hook.command, host, event);
|
|
71
|
+
if (!recognized.own) return;
|
|
72
|
+
const { name, form } = recognized;
|
|
73
|
+
found.push({ event, groupIndex, hookIndex, matcher: group.matcher, command: hook.command, name, form });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
package/cli/host-contract.mjs
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
-
import fs from 'node:fs/promises';
|
|
16
15
|
import path from 'node:path';
|
|
17
16
|
import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
|
|
18
17
|
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
18
|
+
import { createHomeWriter } from '../src/home/lib/home-write.mjs';
|
|
19
19
|
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
20
20
|
|
|
21
21
|
export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
|
|
@@ -69,12 +69,18 @@ export async function writeHostContract(host, { version, result, now = new Date(
|
|
|
69
69
|
Number.isFinite(Date.parse(entry?.checkedAt)) && Date.parse(entry.checkedAt) >= cutoff));
|
|
70
70
|
hosts[version] = { result, checkedAt: now.toISOString() };
|
|
71
71
|
const record = { hosts };
|
|
72
|
-
|
|
72
|
+
const writer = createHomeWriter({ root: host.brandRoot });
|
|
73
|
+
writer.assertArtifact('host-contract', target);
|
|
74
|
+
await writer.mkdir('host-contract', host.brandRoot, { recursive: true });
|
|
73
75
|
try {
|
|
74
|
-
await
|
|
75
|
-
await
|
|
76
|
+
await writer.writeFile('host-contract', temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
77
|
+
await writer.rename('host-contract', temporary, target);
|
|
76
78
|
} finally {
|
|
77
|
-
|
|
79
|
+
try {
|
|
80
|
+
await writer.unlink('host-contract', temporary);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== 'ENOENT') throw error;
|
|
83
|
+
}
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspects what the package left in one host and which of it may be deleted, without changing
|
|
3
|
+
* anything (Plan_65 D10 items 1 and 3).
|
|
4
|
+
*
|
|
5
|
+
* What lies in a host is read from the package's marks there, not from the home record: a format-1
|
|
6
|
+
* record never named its host. Deletion needs content evidence — the host's own recorded
|
|
7
|
+
* fingerprint or the current package's bytes — because uninstall used to delete recorded files
|
|
8
|
+
* without comparing anything, and an operator-edited agent file was lost with them.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
buildInstallPlan,
|
|
14
|
+
contentFingerprint,
|
|
15
|
+
legacyInstallRecordPath,
|
|
16
|
+
} from './manifest.mjs';
|
|
17
|
+
import { plannedContent } from './copy.mjs';
|
|
18
|
+
import { findOwnHooks } from './hook-recognizer.mjs';
|
|
19
|
+
import { readSettings } from './settings-merge.mjs';
|
|
20
|
+
import { inspectPermissions } from './permissions.mjs';
|
|
21
|
+
|
|
22
|
+
const SEED_NAMES = new Set(['config.json', 'conventions.md']);
|
|
23
|
+
const posix = (value) => value.split(path.sep).join('/');
|
|
24
|
+
|
|
25
|
+
function result(context, target, disposition, reason) {
|
|
26
|
+
const relativeToHost = posix(path.relative(context.host.root, target));
|
|
27
|
+
return { target, relativeToHost, disposition, reason };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const unreadable = (context, target, error) => result(context, target, 'keep', `unreadable: ${error.code ?? 'UNKNOWN'}`);
|
|
31
|
+
|
|
32
|
+
function isWithin(directory, target) {
|
|
33
|
+
const relative = path.relative(directory, target);
|
|
34
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function judgeFile(context, target, item) {
|
|
38
|
+
const { host, owner } = context;
|
|
39
|
+
let bytes;
|
|
40
|
+
try {
|
|
41
|
+
bytes = await fs.readFile(target);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return unreadable(context, target, error);
|
|
44
|
+
}
|
|
45
|
+
const relative = posix(path.relative(host.root, target));
|
|
46
|
+
const recorded = owner?.fingerprints?.claude?.[relative];
|
|
47
|
+
if (typeof recorded === 'string' && contentFingerprint(bytes) === recorded) {
|
|
48
|
+
return result(context, target, 'remove', "matches this host's record");
|
|
49
|
+
}
|
|
50
|
+
if (item && bytes.equals(await plannedContent(item, host.brandRoot))) {
|
|
51
|
+
return result(context, target, 'remove', 'matches the package');
|
|
52
|
+
}
|
|
53
|
+
return result(context, target, 'keep', item ? 'changed' : 'unknown');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Links are never followed (Plan_65 D4): a junction inside a package folder points somewhere else.
|
|
57
|
+
async function lstatOrReport(context, target) {
|
|
58
|
+
try {
|
|
59
|
+
return await fs.lstat(target);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code !== 'ENOENT') context.files.push(unreadable(context, target, error));
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function walk(context, target) {
|
|
67
|
+
const stat = await lstatOrReport(context, target);
|
|
68
|
+
if (!stat) return;
|
|
69
|
+
if (stat.isSymbolicLink()) {
|
|
70
|
+
context.files.push(result(context, target, 'keep', 'link'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (stat.isDirectory()) {
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = await fs.readdir(target);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
context.files.push(unreadable(context, target, error));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const entry of entries) await walk(context, path.join(target, entry));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { host, planByTarget } = context;
|
|
85
|
+
if (SEED_NAMES.has(path.basename(target)) && [host.agentsDir, host.commandsDir].includes(path.dirname(target))) {
|
|
86
|
+
context.files.push(result(context, target, 'keep', 'seed'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
context.files.push(await judgeFile(context, target, planByTarget.get(path.resolve(target))));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The pre-0.6 folder name is not unique to the package: only the names the package installs are
|
|
93
|
+
// looked at, never the folder as a whole. Each is judged against the package file of the same name.
|
|
94
|
+
async function inspectLegacyDirectory(context, directory, items) {
|
|
95
|
+
const stat = await lstatOrReport(context, directory);
|
|
96
|
+
if (!stat) return;
|
|
97
|
+
if (stat.isSymbolicLink()) {
|
|
98
|
+
context.files.push(result(context, directory, 'keep', 'link'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!stat.isDirectory()) return;
|
|
102
|
+
for (const item of items) {
|
|
103
|
+
const target = path.join(directory, path.basename(item.target));
|
|
104
|
+
const entry = await lstatOrReport(context, target);
|
|
105
|
+
if (!entry) continue;
|
|
106
|
+
if (entry.isSymbolicLink()) context.files.push(result(context, target, 'keep', 'link'));
|
|
107
|
+
else if (entry.isFile()) context.files.push(await judgeFile(context, target, item));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function inspectLegacyRecord(context) {
|
|
112
|
+
const target = legacyInstallRecordPath(context.host);
|
|
113
|
+
const stat = await lstatOrReport(context, target);
|
|
114
|
+
if (stat?.isFile()) context.files.push(result(context, target, 'remove', 'old installation record'));
|
|
115
|
+
else if (stat?.isSymbolicLink()) context.files.push(result(context, target, 'keep', 'link'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function inspectSettings(host) {
|
|
119
|
+
try {
|
|
120
|
+
const state = await readSettings(host.settingsPath);
|
|
121
|
+
return { hooks: state.exists ? findOwnHooks(state.settings, host) : [], settingsError: null };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return { hooks: [], settingsError: error.message };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Own permission strings are reported, not counted as an installation: `codex-bridge permissions
|
|
128
|
+
// add` sets them alone. An unparseable settings file is already named by settingsError.
|
|
129
|
+
async function inspectOwnPermissions(host) {
|
|
130
|
+
try {
|
|
131
|
+
return { present: (await inspectPermissions(host.settingsPath)).present };
|
|
132
|
+
} catch {
|
|
133
|
+
return { present: null };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function inspectHost(host, { owner = null, packageRoot } = {}) {
|
|
138
|
+
const plan = await buildInstallPlan(host, packageRoot);
|
|
139
|
+
const context = {
|
|
140
|
+
host,
|
|
141
|
+
owner,
|
|
142
|
+
planByTarget: new Map(plan.map((item) => [path.resolve(item.target), item])),
|
|
143
|
+
files: [],
|
|
144
|
+
};
|
|
145
|
+
await walk(context, host.agentsDir);
|
|
146
|
+
await walk(context, host.commandsDir);
|
|
147
|
+
await inspectLegacyDirectory(context, host.legacyAgentsDir, plan.filter((item) => isWithin(host.agentsDir, item.target)));
|
|
148
|
+
await inspectLegacyDirectory(context, host.legacyCommandsDir, plan.filter((item) => isWithin(host.commandsDir, item.target)));
|
|
149
|
+
await inspectLegacyRecord(context);
|
|
150
|
+
const files = context.files.sort((a, b) => a.relativeToHost.localeCompare(b.relativeToHost));
|
|
151
|
+
return { files, ...(await inspectSettings(host)), permissions: await inspectOwnPermissions(host) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function hasPackageMarks(inspection) {
|
|
155
|
+
return inspection.files.length > 0 || inspection.hooks.length > 0;
|
|
156
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies a host inspection by removing eligible host files and this package's hooks (Plan_65 D10
|
|
3
|
+
* items 3 and 4). The shared image is left for uninstall, which may remove it only after the host
|
|
4
|
+
* has no hook pointing to it.
|
|
5
|
+
*/
|
|
6
|
+
import { recordHomeWriter, removeOutside } from './record-removal.mjs';
|
|
7
|
+
import { claudeBoundary, removeEmptyParents } from './remove-layout.mjs';
|
|
8
|
+
import { recordTarget } from './install-record.mjs';
|
|
9
|
+
import { commandFor, removeHook, shortCommandFor } from './settings-merge.mjs';
|
|
10
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
+
|
|
12
|
+
// Plan_65 D10 item 5: only the host's own owner entry proves we created a group; legacy never does.
|
|
13
|
+
function recordedCreation(owner, host, found) {
|
|
14
|
+
if (!owner || !found.name || normalizeRepoPath(owner.root) !== normalizeRepoPath(host.root)) return false;
|
|
15
|
+
return (owner.hooks ?? []).some((hook) => {
|
|
16
|
+
if (hook?.event !== found.event || hook.createdGroup !== true) return false;
|
|
17
|
+
let command = hook.command;
|
|
18
|
+
if (typeof command !== 'string') {
|
|
19
|
+
try {
|
|
20
|
+
command = commandFor(recordTarget(host, hook));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return command === found.command || command === shortCommandFor(found.name);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Decided per group before anything is removed: with two own hooks in one group and evidence on
|
|
30
|
+
// only one, a per-hook flag made the outcome depend on removal order. removeHook drops a group
|
|
31
|
+
// only once it is empty, so an emptied group we created goes and a shared one stays.
|
|
32
|
+
function createdGroups(owner, host, hooks) {
|
|
33
|
+
const created = new Set();
|
|
34
|
+
for (const hook of hooks) {
|
|
35
|
+
if (recordedCreation(owner, host, hook)) created.add(`${hook.event}#${hook.groupIndex}`);
|
|
36
|
+
}
|
|
37
|
+
return created;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Hooks go first and files only once the host is detached. Removing files ahead of a failed hook
|
|
41
|
+
// took the old per-host installation record with them (acceptance of H3b, 2026-09-28): the host kept
|
|
42
|
+
// hooks into an image that no longer had any inventory, so no later uninstall could remove it. Either
|
|
43
|
+
// the host is detached, or its files are exactly as they were and a repeat run finishes the job.
|
|
44
|
+
async function removeOwnHooks(host, inspection, owner, dryRun, lines) {
|
|
45
|
+
if (inspection.settingsError != null) {
|
|
46
|
+
lines.push(`Left the hooks in ${host.settingsPath}: ${inspection.settingsError}`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
let detached = true;
|
|
50
|
+
const created = createdGroups(owner, host, inspection.hooks);
|
|
51
|
+
for (const hook of inspection.hooks) {
|
|
52
|
+
const identity = hook.name ?? hook.command;
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
lines.push(`Would remove the ${hook.event} hook ${identity}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await removeHook(host.settingsPath, {
|
|
59
|
+
event: hook.event,
|
|
60
|
+
matcher: hook.matcher,
|
|
61
|
+
command: hook.command,
|
|
62
|
+
alternateCommands: [],
|
|
63
|
+
}, { createdGroup: created.has(`${hook.event}#${hook.groupIndex}`) });
|
|
64
|
+
lines.push(`Removed the ${hook.event} hook ${identity}`);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
detached = false;
|
|
67
|
+
lines.push(`Failed to remove the ${hook.event} hook ${identity}: ${error?.message ?? String(error)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return detached;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function removeHostSide(host, inspection, { owner = null, dryRun = false } = {}) {
|
|
74
|
+
const lines = [];
|
|
75
|
+
const detached = await removeOwnHooks(host, inspection, owner, dryRun, lines);
|
|
76
|
+
const writer = dryRun ? null : recordHomeWriter(host, []);
|
|
77
|
+
for (const file of inspection.files) {
|
|
78
|
+
if (file.disposition !== 'remove') {
|
|
79
|
+
lines.push(`Left ${file.relativeToHost} (${file.reason})`);
|
|
80
|
+
} else if (!detached) {
|
|
81
|
+
lines.push(`${dryRun ? 'Would leave' : 'Left'} ${file.relativeToHost} (this host's hooks could not be removed)`);
|
|
82
|
+
} else {
|
|
83
|
+
if (!dryRun) {
|
|
84
|
+
await removeOutside(writer, file.target);
|
|
85
|
+
await removeEmptyParents(file.target, claudeBoundary(host, file.target));
|
|
86
|
+
}
|
|
87
|
+
lines.push(`${dryRun ? 'Would remove' : 'Removed'} ${file.relativeToHost}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { lines, detached };
|
|
91
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judges whether an image file may be removed by content evidence (D10 item 3).
|
|
3
|
+
* Plan_65 D12 requires uninstall and the home removal plan to share this read-only judgment,
|
|
4
|
+
* so their protection of operator edits cannot drift into two different implementations.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import { buildInstallPlan, contentFingerprint } from './manifest.mjs';
|
|
8
|
+
import { plannedContent } from './copy.mjs';
|
|
9
|
+
import { fingerprintFor, recordTarget } from './install-record.mjs';
|
|
10
|
+
|
|
11
|
+
export async function imagePackageIndex(host, packageRoot) {
|
|
12
|
+
const plan = await buildInstallPlan(host, packageRoot);
|
|
13
|
+
return new Map(plan
|
|
14
|
+
.filter((item) => item.root === 'brand')
|
|
15
|
+
.map((item) => [item.relativeToRoot, item]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function judgeImageFile(host, file, fingerprints, packageIndex) {
|
|
19
|
+
const target = recordTarget(host, file);
|
|
20
|
+
let stat;
|
|
21
|
+
try {
|
|
22
|
+
stat = await fs.lstat(target);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === 'ENOENT') return { verdict: 'missing', reason: 'missing' };
|
|
25
|
+
return { verdict: 'keep', reason: `unreadable: ${error.code ?? 'UNKNOWN'}` };
|
|
26
|
+
}
|
|
27
|
+
if (stat.isSymbolicLink()) return { verdict: 'keep', reason: 'link' };
|
|
28
|
+
|
|
29
|
+
let bytes;
|
|
30
|
+
try {
|
|
31
|
+
bytes = await fs.readFile(target);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT') return { verdict: 'missing', reason: 'missing' };
|
|
34
|
+
return { verdict: 'keep', reason: `unreadable: ${error.code ?? 'UNKNOWN'}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const recorded = fingerprintFor({ fingerprints }, file);
|
|
38
|
+
const matchesRecord = typeof recorded === 'string' && contentFingerprint(bytes) === recorded;
|
|
39
|
+
if (matchesRecord) return { verdict: 'remove', reason: 'recorded' };
|
|
40
|
+
const item = packageIndex.get(file.path);
|
|
41
|
+
const matchesPackage = item ? bytes.equals(await plannedContent(item, host.brandRoot)) : false;
|
|
42
|
+
if (matchesPackage) return { verdict: 'remove', reason: 'package' };
|
|
43
|
+
return { verdict: 'keep', reason: 'changed' };
|
|
44
|
+
}
|