@lyupro/codex-bridge 0.6.6 → 0.6.8
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 +79 -0
- package/README.md +7 -5
- package/bin/codex-bridge.mjs +186 -178
- package/cli/dispatcher-contract-record.mjs +51 -0
- package/cli/dispatcher-contract.mjs +90 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +53 -5
- package/cli/handback-witness-check.mjs +31 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +145 -135
- package/cli/install.mjs +23 -17
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +55 -70
- package/cli/probe-rig.mjs +53 -0
- package/cli/probe-target.mjs +33 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/session-hosts.mjs +51 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +16 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/scripts/run-tests.mjs +7 -0
- package/src/agents/codex-advisor.md +3 -4
- package/src/agents/codex-build.md +5 -5
- package/src/agents/codex-review.md +3 -3
- package/src/agents/codex-scout.md +3 -4
- package/src/home/hooks/dispatcher-gate.mjs +132 -0
- package/src/home/hooks/guard-tries.mjs +5 -6
- package/src/home/hooks/live-runs.mjs +1 -0
- package/src/home/hooks/order-gate.mjs +20 -5
- package/src/home/hooks/reply-guard.mjs +95 -10
- package/src/home/hooks/reply-verdicts.mjs +5 -0
- package/src/home/hooks/worktree-lock.mjs +108 -107
- package/src/home/lib/atomic-json.mjs +21 -0
- package/src/home/lib/brand-home.mjs +6 -0
- package/src/home/lib/dispatcher-command.mjs +79 -0
- package/src/home/lib/dispatcher-gate.mjs +95 -0
- package/src/home/lib/dispatcher-order.mjs +30 -8
- package/src/home/lib/dispatcher-state.mjs +111 -0
- package/src/home/lib/dispatcher-stop.mjs +52 -0
- package/src/home/lib/handback-witness.mjs +70 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/host-observations.mjs +57 -0
- package/src/home/lib/host-version.mjs +27 -0
- package/src/home/lib/meta/advice-verdict.mjs +37 -21
- package/src/home/lib/required-inputs.mjs +1 -1
- package/src/home/lib/runner/codex-cmd.mjs +3 -2
- package/src/home/lib/runner/git-state.mjs +1 -1
- package/src/home/lib/runner/preflight.mjs +6 -3
- package/src/home/lib/runner/prompts.mjs +8 -7
- package/src/home/lib/runner/schemas.mjs +33 -7
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Judges dispatcher host behaviour per version: the 2026-09-25 incident ran VS Code 2.1.282 beside CLI 2.1.281. */
|
|
2
|
+
import { parseJsonText } from '../src/home/lib/json-file.mjs';
|
|
3
|
+
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
4
|
+
|
|
5
|
+
export const DISPATCHER_CONTRACTS = ['agentIdentity', 'shellStdout', 'shellFailure', 'agentTranscript'];
|
|
6
|
+
|
|
7
|
+
function inputOf(entry) {
|
|
8
|
+
const input = entry?.payload?.tool_input;
|
|
9
|
+
if (input && typeof input === 'object') return input;
|
|
10
|
+
if (typeof input === 'string') {
|
|
11
|
+
try { return parseJsonText('<hook tool_input>', input); } catch { return {}; }
|
|
12
|
+
}
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function matching(entries, event, command) {
|
|
17
|
+
return entries.filter((entry) => entry?.event === event && inputOf(entry).command === command);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verdict(result, detail) { return { result, detail }; }
|
|
21
|
+
|
|
22
|
+
export function judgeDispatcherContracts({ entries, hostHealthy, okCommand, failCommand, okOutput, failOutput, agentType, promptToken }) {
|
|
23
|
+
const inconclusive = () => verdict('inconclusive', 'Host did not complete the probe.');
|
|
24
|
+
if (hostHealthy === false) return Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, inconclusive()]));
|
|
25
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
26
|
+
const okPre = matching(list, 'PreToolUse', okCommand);
|
|
27
|
+
const okPost = matching(list, 'PostToolUse', okCommand);
|
|
28
|
+
const failPre = matching(list, 'PreToolUse', failCommand);
|
|
29
|
+
const failPost = matching(list, 'PostToolUse', failCommand);
|
|
30
|
+
const failures = matching(list, 'PostToolUseFailure', failCommand);
|
|
31
|
+
let transcriptVerdict;
|
|
32
|
+
|
|
33
|
+
const agentPre = list.filter((entry) => entry?.event === 'PreToolUse' && entry.payload?.agent_id);
|
|
34
|
+
if (!agentPre.length) transcriptVerdict = verdict('inconclusive', 'No agent_id was observed.');
|
|
35
|
+
else {
|
|
36
|
+
const first = agentPre[0];
|
|
37
|
+
if (first.transcript?.error) transcriptVerdict = verdict('changed', `First transcript read failed: ${first.transcript.error}`);
|
|
38
|
+
else {
|
|
39
|
+
let parsed;
|
|
40
|
+
try { parsed = parseJsonText(first.transcript?.path ?? '<transcript>', first.transcript?.firstLine ?? ''); } catch {}
|
|
41
|
+
const content = parsed?.message?.content;
|
|
42
|
+
const text = typeof content === 'string' ? content : Array.isArray(content)
|
|
43
|
+
? content.filter((part) => part?.type === 'text').map((part) => part.text).join('\n') : '';
|
|
44
|
+
transcriptVerdict = parsed?.type === 'user' && text.includes(promptToken)
|
|
45
|
+
? verdict('honored', 'First agent transcript line contains the caller prompt.')
|
|
46
|
+
: verdict('changed', 'First agent transcript line is not the caller user prompt.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
agentIdentity: !okPre.length ? verdict('inconclusive', 'No delegated successful-command call was observed.')
|
|
52
|
+
: okPre.some((entry) => entry.payload.agent_type === agentType && typeof entry.payload.agent_id === 'string' && entry.payload.agent_id.trim())
|
|
53
|
+
? verdict('honored', 'Agent type and non-empty agent_id were present.')
|
|
54
|
+
: verdict('changed', 'Successful-command PreToolUse lacked the expected agent_type or non-empty agent_id.'),
|
|
55
|
+
shellStdout: !okPre.length ? verdict('inconclusive', 'No delegated successful-command call was observed.')
|
|
56
|
+
: okPost.some((entry) => typeof entry.payload?.tool_response?.stdout === 'string' && entry.payload.tool_response.stdout.includes(okOutput))
|
|
57
|
+
? verdict('honored', 'PostToolUse stdout contained the probe output.')
|
|
58
|
+
: verdict('changed', 'PostToolUse stdout did not contain the probe output.'),
|
|
59
|
+
shellFailure: !failPre.length ? verdict('inconclusive', 'No delegated failing-command call was observed.')
|
|
60
|
+
: !failPost.length && failures.some((entry) => typeof entry.payload?.error === 'string'
|
|
61
|
+
&& entry.payload.error.startsWith('Exit code 2') && entry.payload.error.includes(failOutput)
|
|
62
|
+
&& entry.payload.agent_type === agentType)
|
|
63
|
+
? verdict('honored', 'Failure event carried the expected agent type and exit details without PostToolUse.')
|
|
64
|
+
: verdict('changed', 'Expected failure event contract was not observed.'),
|
|
65
|
+
agentTranscript: transcriptVerdict,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function dispatcherContractStatus({ record, version }) {
|
|
70
|
+
return DISPATCHER_CONTRACTS.map((contract) => {
|
|
71
|
+
const item = record?.hosts?.[version]?.contracts?.[contract];
|
|
72
|
+
let state;
|
|
73
|
+
let message;
|
|
74
|
+
if (version == null) {
|
|
75
|
+
state = 'unknown-host'; message = 'Host version is unknown; dispatcher contracts cannot be judged.';
|
|
76
|
+
} else if (!item) {
|
|
77
|
+
const other = Object.entries(record?.hosts ?? {}).filter(([, host]) => host?.contracts?.[contract])
|
|
78
|
+
.sort((a, b) => Date.parse(b[1].contracts[contract].checkedAt) - Date.parse(a[1].contracts[contract].checkedAt))[0];
|
|
79
|
+
if (other) { state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${other[0]}; run ${PROBE_COMMAND}.`; }
|
|
80
|
+
else { state = 'unverified'; message = `Dispatcher contract ${contract} has never been probed on host ${version}; run ${PROBE_COMMAND}.`; }
|
|
81
|
+
} else if (item.result === 'honored') {
|
|
82
|
+
state = 'verified'; message = `Dispatcher contract ${contract} is verified on host ${version}.`;
|
|
83
|
+
} else if (item.result === 'changed') {
|
|
84
|
+
state = 'changed'; message = `Dispatcher gate cannot be trusted on host ${version}: ${contract} changed; see Plan_62 D19.`;
|
|
85
|
+
} else {
|
|
86
|
+
state = 'unverified'; message = `Dispatcher contract ${contract} has no valid verdict; run ${PROBE_COMMAND}.`;
|
|
87
|
+
}
|
|
88
|
+
return { contract, state, message };
|
|
89
|
+
});
|
|
90
|
+
}
|
package/cli/doctor-hooks.mjs
CHANGED
|
@@ -1,114 +1,104 @@
|
|
|
1
|
-
/** Reports the health of every hook this package registered in the host. */
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { HOOK_DEFINITIONS } from './manifest.mjs';
|
|
5
|
-
import { recordTarget } from './install-record.mjs';
|
|
6
|
-
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
7
|
-
import { check } from './doctor-format.mjs';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
106
|
-
const failure = startFailure(definition, starts[index]);
|
|
107
|
-
const commandVersion = form === 'short' ? bridge() : null;
|
|
108
|
-
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command; ${hookVersion(command, record, commandVersion)}${failure ? `; ${failure}` : ''})`);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
111
|
-
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
112
|
-
}
|
|
113
|
-
}));
|
|
114
|
-
}
|
|
1
|
+
/** Reports the health of every hook this package registered in the host. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { HOOK_DEFINITIONS } from './manifest.mjs';
|
|
5
|
+
import { recordTarget } from './install-record.mjs';
|
|
6
|
+
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
7
|
+
import { check } from './doctor-format.mjs';
|
|
8
|
+
import { probeHookLauncher } from './launcher-probe.mjs';
|
|
9
|
+
|
|
10
|
+
function hookVersion(record) {
|
|
11
|
+
return `installed copy ${record.name}@${record.version}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function startHook(target) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(process.execPath, [target], {
|
|
17
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
let stderr = '';
|
|
21
|
+
child.stderr.setEncoding('utf8');
|
|
22
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
23
|
+
child.on('error', (error) => resolve({ status: null, stderr: error.message }));
|
|
24
|
+
child.on('close', (status) => resolve({ status, stderr }));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function startFailure(definition, result) {
|
|
29
|
+
if (result.status === 0) return null;
|
|
30
|
+
const lines = result.stderr.trim().split(/\r?\n/);
|
|
31
|
+
const resolver = lines.find((line) => /^Error \[ERR_MODULE_NOT_FOUND\]|Cannot find module/.test(line));
|
|
32
|
+
return `${definition.file} did not start: ${resolver || lines.find(Boolean) || `exit code ${result.status}`}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `sourceKind` arrives as an argument rather than being read here, because the function that knows
|
|
37
|
+
* which copy of the package is answering lives beside `diagnose`. Importing it back would make this
|
|
38
|
+
* module and its caller depend on each other, and a cycle between a check and its orchestrator is
|
|
39
|
+
* how one of them ends up half-initialised at import time.
|
|
40
|
+
*/
|
|
41
|
+
export async function hookChecks(host, record, launcherProbe = probeHookLauncher) {
|
|
42
|
+
const launcher = launcherProbe({ env: process.env, brandRoot: host.brandRoot });
|
|
43
|
+
const starts = record ? await Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
44
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
45
|
+
&& path.basename(hook.path) === definition.file);
|
|
46
|
+
return recorded ? startHook(recordTarget(host, recorded)) : null;
|
|
47
|
+
})) : [];
|
|
48
|
+
return Promise.all(HOOK_DEFINITIONS.map(async (definition, index) => {
|
|
49
|
+
const key = `hook:${definition.event}`;
|
|
50
|
+
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
51
|
+
// The file, not just the event: PreToolUse carries two hooks of this package, and matching
|
|
52
|
+
// by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
|
|
53
|
+
// hook line is the only place an operator can see WHICH file a matcher was registered for,
|
|
54
|
+
// so a wrong name here is worse than no line at all.
|
|
55
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
56
|
+
&& path.basename(hook.path) === definition.file);
|
|
57
|
+
if (!recorded) {
|
|
58
|
+
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
59
|
+
}
|
|
60
|
+
const expected = recordTarget(host, recorded);
|
|
61
|
+
const fullCommand = commandFor(expected);
|
|
62
|
+
const shortCommand = `codex-bridge hook ${definition.name}`;
|
|
63
|
+
try {
|
|
64
|
+
const state = await inspectHook(host.settingsPath, {
|
|
65
|
+
event: definition.event,
|
|
66
|
+
matcher: definition.matcher,
|
|
67
|
+
command: recorded.command || fullCommand,
|
|
68
|
+
alternateCommands: [fullCommand, shortCommand],
|
|
69
|
+
});
|
|
70
|
+
if (state.present) {
|
|
71
|
+
const recordedMatcher = state.matchedMatcher;
|
|
72
|
+
const command = state.matchedCommand || recorded.command || fullCommand;
|
|
73
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
74
|
+
const reason = form === 'short'
|
|
75
|
+
? launcher.ok
|
|
76
|
+
? 'codex-bridge on PATH launches guards from this home'
|
|
77
|
+
: `short command but ${launcher.reason}: the command on PATH runs its own package copy, not this home, and refuses names it does not know; run codex-bridge update`
|
|
78
|
+
: launcher.ok
|
|
79
|
+
? 'a short command is now safe; run codex-bridge update'
|
|
80
|
+
: 'full path uses the copy placed by the last install';
|
|
81
|
+
const problems = [startFailure(definition, starts[index])].filter(Boolean);
|
|
82
|
+
// The matcher printed is the one the host recorded, never the one this package declares.
|
|
83
|
+
// Until 2026-08-23 the line stated the registry's own value, so a host stuck on the
|
|
84
|
+
// pre-shell matcher read as [ok] while the worktree lock never saw a Bash command — the
|
|
85
|
+
// same shape as the 2026-08-10 defect, where doctor counted files instead of
|
|
86
|
+
// reading them. A check that compares the package to itself cannot fail.
|
|
87
|
+
if (recordedMatcher !== definition.matcher) problems.push(`recorded matcher ${recordedMatcher} differs from expected ${definition.matcher}; run codex-bridge update --force`);
|
|
88
|
+
const health = problems.length ? `; ${problems.join('; ')}` : '';
|
|
89
|
+
const status = form === 'short' && !launcher.ok ? 'fail'
|
|
90
|
+
: form === 'path' && launcher.ok || problems.length ? 'warn' : 'ok';
|
|
91
|
+
return check(key, status, `${definition.event} matcher ${recordedMatcher} -> ${expected} (${form} command; ${reason}${form === 'path' ? `; ${hookVersion(record)}` : ''}${health})`);
|
|
92
|
+
}
|
|
93
|
+
const command = recorded.command || fullCommand;
|
|
94
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
95
|
+
const failure = startFailure(definition, starts[index]);
|
|
96
|
+
// An entry missing from settings is a registration problem whatever the launcher can do;
|
|
97
|
+
// update restores it in the form the proof allows.
|
|
98
|
+
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command${form === 'path' ? `; ${hookVersion(record)}` : ''}${failure ? `; ${failure}` : ''})`);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
101
|
+
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|