@lyupro/codex-bridge 0.6.7 → 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 +39 -0
- package/README.md +5 -3
- package/bin/codex-bridge.mjs +9 -4
- package/cli/dispatcher-contract-record.mjs +18 -9
- package/cli/dispatcher-contract.mjs +6 -5
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +18 -6
- package/cli/handback-witness-check.mjs +10 -18
- package/cli/host-contract.mjs +25 -16
- package/cli/install.mjs +14 -3
- package/cli/probe-contract.mjs +26 -10
- package/cli/probe-target.mjs +33 -0
- package/cli/session-hosts.mjs +51 -0
- package/cli/uninstall.mjs +3 -2
- package/package.json +2 -2
- package/scripts/run-tests.mjs +7 -0
- package/src/home/hooks/dispatcher-gate.mjs +15 -2
- package/src/home/hooks/reply-guard.mjs +22 -2
- package/src/home/lib/handback-witness.mjs +26 -21
- package/src/home/lib/host-observations.mjs +57 -0
- package/src/home/lib/host-version.mjs +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.8] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Every session records the version of the host that runs its hooks, read from the `version` the
|
|
12
|
+
host writes into its own transcript, in `state/host-observations.json`. `doctor` prints a
|
|
13
|
+
`sessionHost` line and one `otherHost:<version>` line for each other host seen in the last 30
|
|
14
|
+
days. Sessions ran in the VS Code extension 2.1.282 while `claude` on PATH was 2.1.281, and the
|
|
15
|
+
extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` are inherited by every `claude`
|
|
16
|
+
it starts, so neither PATH nor the environment names the host.
|
|
17
|
+
- A subagent stop that carries an `agent_id` but no `agent_type` records a witness alarm and tells
|
|
18
|
+
the session that the dispatcher gate cannot recognise dispatchers. That was the one host change the
|
|
19
|
+
gate would otherwise have passed silently.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `.host-contract.json` and `state/dispatcher-contract.json` keep one verdict per host version, so a
|
|
24
|
+
CLI session and an extension session on different versions no longer overwrite each other's
|
|
25
|
+
measurement. Existing single-version files are read as the record of their own version.
|
|
26
|
+
- `doctor --probe-contract` measures the executable of the newest observed host. `claude` on PATH
|
|
27
|
+
and `CLAUDE_CODE_EXECPATH` are used only when their own `--version` matches it;
|
|
28
|
+
`--probe-executable <path>` names one explicitly. A host that completes the probe but writes
|
|
29
|
+
another version into its transcript records nothing.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `doctor` and `install` judge the hosts sessions actually ran on instead of `claude` on PATH. A
|
|
34
|
+
machine whose sessions run in an editor extension had its contracts reported as verified for a
|
|
35
|
+
different program, and a permanent `handbackWitness` warning asked for a dispatcher run that could
|
|
36
|
+
never clear it. With no session observed yet, `doctor` says so rather than falling back to PATH.
|
|
37
|
+
- The handback witness records the host version from the transcript and compares it exactly. It had
|
|
38
|
+
matched an SDK version to a CLI version by patch number, which accepted unrelated versions sharing
|
|
39
|
+
a patch. An older SDK-based witness file reads as "not seen yet" and keeps its alarms.
|
|
40
|
+
- `uninstall` names the host observations among the files it leaves in the package home.
|
|
41
|
+
|
|
42
|
+
After installing 0.6.8, run `codex-bridge update` once so the home receives the observation code,
|
|
43
|
+
then run any shell command in a session. If `doctor` reports the session host as probed on another
|
|
44
|
+
version, run `codex-bridge doctor --probe-contract` once (about two minutes of Claude quota).
|
|
45
|
+
|
|
7
46
|
## [0.6.7] - 2026-09-25
|
|
8
47
|
|
|
9
48
|
### Added
|
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
|
|
|
62
62
|
- **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
|
|
63
63
|
- **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
|
|
64
64
|
|
|
65
|
-
The current suite contains **
|
|
65
|
+
The current suite contains **1667 automated tests: 1666 passing and 1 skipped**.
|
|
66
66
|
|
|
67
67
|
## Verify
|
|
68
68
|
|
|
@@ -91,7 +91,9 @@ Output on a healthy host (trimmed):
|
|
|
91
91
|
[ok] liveRuns: 0 runs working right now
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
`doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It
|
|
94
|
+
`doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It judges contracts against the newest host version observed in a session transcript, not `claude` on `PATH`: on 2026-09-25 the VS Code extension ran sessions on 2.1.282 while `PATH` resolved to 2.1.281, and the extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` were inherited by every descendant `claude`. The transcript entry's `version` identifies the host. The dispatcher gate records it once per session in `state/host-observations.json` (sessions kept 7 days, hosts 30); `doctor` and `install` judge the newest observed host. `doctor` prints `sessionHost` and one `otherHost:<version>` line for each other observed version, with `ok`, `warn` and a probe command, or `fail` with exit 1. If no session has been observed, they say so instead of using `PATH`. Refusal and dispatcher contract records keep one verdict per host version, so a measurement of version V applies to every host of version V; older single-version records count only for their recorded version.
|
|
95
|
+
|
|
96
|
+
`doctor` also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. Handback sightings and alarms carry the transcript version, and sightings match only that exact version; an older SDK-based record counts as no sighting while preserving its alarms. If a subagent stop has an `agent_id` but no `agent_type`, the reply guard records an alarm and tells the session that the dispatcher gate cannot recognise dispatchers. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
|
|
95
97
|
|
|
96
98
|
If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
|
|
97
99
|
|
|
@@ -128,7 +130,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
|
|
|
128
130
|
| `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
|
|
129
131
|
| `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
|
|
130
132
|
| `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
|
|
131
|
-
| `doctor [--scope user\|project] [--host <path>] [--probe-contract]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract`
|
|
133
|
+
| `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
|
|
132
134
|
| `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
|
|
133
135
|
| `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
|
|
134
136
|
| `projects [<name>] [--json]` | List projects or runs in the run store. |
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
/** Dispatches codex-bridge CLI arguments to focused command modules. */
|
|
3
3
|
import { diagnose, renderDoctor } from '../cli/doctor.mjs';
|
|
4
4
|
import { probeContract } from '../cli/probe-contract.mjs';
|
|
5
|
+
import { resolveProbeTarget } from '../cli/probe-target.mjs';
|
|
6
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
5
7
|
import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
|
|
6
8
|
import { hook } from '../cli/hook.mjs';
|
|
7
9
|
import { resolveHost } from '../cli/hosts.mjs';
|
|
@@ -25,7 +27,7 @@ Usage:
|
|
|
25
27
|
codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
|
|
26
28
|
codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
|
|
27
29
|
codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
|
|
28
|
-
codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
|
|
30
|
+
codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]
|
|
29
31
|
codex-bridge run <runner options> --task-file <path>
|
|
30
32
|
codex-bridge model [list]
|
|
31
33
|
codex-bridge projects [<name>] [--json]
|
|
@@ -74,10 +76,10 @@ export function commandOptions(command, argv) {
|
|
|
74
76
|
options[flagNames.get(arg)] = true;
|
|
75
77
|
continue;
|
|
76
78
|
}
|
|
77
|
-
if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
|
|
79
|
+
if (arg !== '--scope' && arg !== '--host' && !(command === 'doctor' && arg === '--probe-executable')) throw new Error(`unknown ${command} option "${arg}"`);
|
|
78
80
|
const value = argv[index + 1];
|
|
79
81
|
if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
|
|
80
|
-
options[arg === '--scope' ? 'scope' : 'host'] = value;
|
|
82
|
+
options[arg === '--scope' ? 'scope' : arg === '--host' ? 'host' : 'probeExecutable'] = value;
|
|
81
83
|
index += 1;
|
|
82
84
|
}
|
|
83
85
|
return options;
|
|
@@ -96,13 +98,16 @@ export async function main(argv, io = console) {
|
|
|
96
98
|
if (command === 'run') return runCodex(rest);
|
|
97
99
|
if (command === 'doctor') {
|
|
98
100
|
const options = commandOptions(command, rest);
|
|
101
|
+
if (options.probeExecutable && !options.probeContract) throw new Error('--probe-executable requires --probe-contract');
|
|
99
102
|
const host = resolveHost(options);
|
|
100
103
|
// The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
|
|
101
104
|
// measured. Printing the diagnosis first and the measurement after would answer one question
|
|
102
105
|
// twice in one output, with the older answer on top (Plan_52 D25).
|
|
103
106
|
let probe = null;
|
|
104
107
|
if (options.probeContract) {
|
|
105
|
-
|
|
108
|
+
const target = resolveProbeTarget({ stateDir: brandStateDir(host.brandRoot), executable: options.probeExecutable });
|
|
109
|
+
if (!target.error) io.log(`probe: target ${target.version} at ${target.executable} (${target.source})`);
|
|
110
|
+
probe = await probeContract({ host, target });
|
|
106
111
|
io.log(`probe: ${probe.message}`);
|
|
107
112
|
for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
|
|
108
113
|
io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Keeps the latest conclusive verdict per dispatcher host contract,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Keeps the latest conclusive verdict per dispatcher host contract, per host version: on 2026-09-25 the
|
|
3
|
+
* VS Code host 2.1.282 ran beside the CLI host 2.1.281, and probing either must keep the other's verdict.
|
|
4
|
+
* Its own file rather than a field of .host-contract.json (Plan_62 D14, D19), and an inconclusive probe
|
|
5
|
+
* must never erase an earlier verdict.
|
|
5
6
|
*/
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
@@ -19,9 +20,17 @@ export function readDispatcherContract({ stateDir }) {
|
|
|
19
20
|
}
|
|
20
21
|
try {
|
|
21
22
|
const record = parseJsonText(file, source);
|
|
22
|
-
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
23
|
-
|
|
24
|
-
return
|
|
23
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return { corrupt: true };
|
|
24
|
+
if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
|
|
25
|
+
if (!record.contracts || typeof record.contracts !== 'object' || Array.isArray(record.contracts)) return { corrupt: true };
|
|
26
|
+
const hosts = {};
|
|
27
|
+
for (const [name, item] of Object.entries(record.contracts)) {
|
|
28
|
+
if (!item || typeof item.version !== 'string' || !item.version) continue;
|
|
29
|
+
hosts[item.version] ??= { contracts: {} };
|
|
30
|
+
const { version: _version, ...entry } = item;
|
|
31
|
+
hosts[item.version].contracts[name] = entry;
|
|
32
|
+
}
|
|
33
|
+
return { hosts };
|
|
25
34
|
} catch { return { corrupt: true }; }
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -29,14 +38,14 @@ export function writeDispatcherContract({ stateDir, version, verdicts, now = new
|
|
|
29
38
|
const file = path.join(stateDir, DISPATCHER_CONTRACT_FILE);
|
|
30
39
|
const previous = readDispatcherContract({ stateDir });
|
|
31
40
|
if (previous?.corrupt) throw new Error(`Cannot update corrupt dispatcher contract: ${file}`);
|
|
32
|
-
const contracts = { ...(previous?.contracts ?? {}) };
|
|
41
|
+
const contracts = { ...(previous?.hosts?.[version]?.contracts ?? {}) };
|
|
33
42
|
for (const name of DISPATCHER_CONTRACTS) {
|
|
34
43
|
const result = verdicts?.[name]?.result;
|
|
35
44
|
if (result === 'honored' || result === 'changed') {
|
|
36
|
-
contracts[name] = { result,
|
|
45
|
+
contracts[name] = { result, checkedAt: new Date(now).toISOString(), detail: verdicts[name].detail };
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
|
-
const record = { contracts };
|
|
48
|
+
const record = { hosts: { ...(previous?.hosts ?? {}), [version]: { contracts } } };
|
|
40
49
|
writeJsonAtomic(file, record);
|
|
41
50
|
return record;
|
|
42
51
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Judges dispatcher host behaviour
|
|
1
|
+
/** Judges dispatcher host behaviour per version: the 2026-09-25 incident ran VS Code 2.1.282 beside CLI 2.1.281. */
|
|
2
2
|
import { parseJsonText } from '../src/home/lib/json-file.mjs';
|
|
3
3
|
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
4
4
|
|
|
@@ -68,15 +68,16 @@ export function judgeDispatcherContracts({ entries, hostHealthy, okCommand, fail
|
|
|
68
68
|
|
|
69
69
|
export function dispatcherContractStatus({ record, version }) {
|
|
70
70
|
return DISPATCHER_CONTRACTS.map((contract) => {
|
|
71
|
-
const item = record?.contracts?.[contract];
|
|
71
|
+
const item = record?.hosts?.[version]?.contracts?.[contract];
|
|
72
72
|
let state;
|
|
73
73
|
let message;
|
|
74
74
|
if (version == null) {
|
|
75
75
|
state = 'unknown-host'; message = 'Host version is unknown; dispatcher contracts cannot be judged.';
|
|
76
76
|
} else if (!item) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${
|
|
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}.`; }
|
|
80
81
|
} else if (item.result === 'honored') {
|
|
81
82
|
state = 'verified'; message = `Dispatcher contract ${contract} is verified on host ${version}.`;
|
|
82
83
|
} else if (item.result === 'changed') {
|
|
@@ -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
|
@@ -16,13 +16,15 @@ import {
|
|
|
16
16
|
rulesCheck,
|
|
17
17
|
} from './doctor-installation.mjs';
|
|
18
18
|
import { hookChecks } from './doctor-hooks.mjs';
|
|
19
|
-
import { contractStatus,
|
|
19
|
+
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
20
20
|
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
21
21
|
import { dispatcherContractStatus } from './dispatcher-contract.mjs';
|
|
22
22
|
import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
|
|
23
23
|
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
24
24
|
import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
|
|
25
25
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
26
|
+
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
27
|
+
import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
|
|
26
28
|
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
27
29
|
|
|
28
30
|
export { renderDoctor };
|
|
@@ -81,6 +83,7 @@ export async function diagnose({
|
|
|
81
83
|
handbackWitnessRecord,
|
|
82
84
|
dispatcherContractRecord,
|
|
83
85
|
hostVersion,
|
|
86
|
+
observations,
|
|
84
87
|
} = {}) {
|
|
85
88
|
const checks = [sourceCheck()];
|
|
86
89
|
const hostExists = await exists(host.root);
|
|
@@ -124,27 +127,35 @@ export async function diagnose({
|
|
|
124
127
|
const bridge = bridgeProbe();
|
|
125
128
|
checks.push(bridgeCommandCheck(bridge));
|
|
126
129
|
checks.push(...await hookChecks(host, record, launcherProbe));
|
|
127
|
-
const
|
|
130
|
+
const stateDir = brandStateDir(host.brandRoot);
|
|
131
|
+
const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
|
|
132
|
+
const observedVersions = sessionHostVersions(hostObservations);
|
|
133
|
+
const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
|
|
134
|
+
checks.push(sessionHostCheck(observedVersions, hostObservations));
|
|
135
|
+
const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
|
|
128
136
|
const hostContract = contractStatus({
|
|
129
|
-
record:
|
|
137
|
+
record: refusalRecord,
|
|
130
138
|
version: detectedHostVersion,
|
|
131
139
|
});
|
|
132
140
|
const hostContractStatus = hostContract.state === 'verified'
|
|
133
141
|
? 'ok'
|
|
134
142
|
: hostContract.state === 'ignored' ? 'fail' : 'warn';
|
|
135
143
|
checks.push(check('hostContract', hostContractStatus, hostContract.message));
|
|
136
|
-
const stateDir = brandStateDir(host.brandRoot);
|
|
137
144
|
const witness = handbackWitnessStatus({
|
|
138
145
|
record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
|
|
139
146
|
hostVersion: detectedHostVersion,
|
|
140
147
|
stateDir,
|
|
141
148
|
});
|
|
142
149
|
checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
|
|
150
|
+
const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
|
|
143
151
|
checks.push(...dispatcherContractChecks({
|
|
144
|
-
record:
|
|
152
|
+
record: dispatcherRecord,
|
|
145
153
|
version: detectedHostVersion,
|
|
146
154
|
stateDir,
|
|
147
155
|
}));
|
|
156
|
+
for (const version of observedVersions.filter((item) => item !== detectedHostVersion)) {
|
|
157
|
+
checks.push(otherHostCheck({ version, contractRecord: refusalRecord, dispatcherRecord }));
|
|
158
|
+
}
|
|
148
159
|
const retention = retentionCheck(host);
|
|
149
160
|
checks.push(retention);
|
|
150
161
|
const conventions = await conventionsCheck(host);
|
|
@@ -163,7 +174,8 @@ export async function diagnose({
|
|
|
163
174
|
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
164
175
|
|| hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
|
|
165
176
|
|| projectRuns.status === 'fail'
|
|
166
|
-
|| checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
|
|
177
|
+
|| checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
|
|
178
|
+
|| checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
|
|
167
179
|
checks,
|
|
168
180
|
record,
|
|
169
181
|
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
|
}
|
package/cli/host-contract.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
|
|
7
7
|
* package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
|
|
8
8
|
* never that the host APPLIED it. The record is bound to a host version because that is what
|
|
9
|
-
* changes underneath an installation: the host updates itself, the package does not.
|
|
9
|
+
* changes underneath an installation: the host updates itself, the package does not. The
|
|
10
|
+
* 2026-09-25 incident ran VS Code host 2.1.282 beside CLI host 2.1.281, so each version keeps
|
|
11
|
+
* its own verdict and probing either must preserve the other.
|
|
10
12
|
*/
|
|
11
13
|
import { spawnSync } from 'node:child_process';
|
|
12
14
|
import { randomUUID } from 'node:crypto';
|
|
@@ -40,13 +42,12 @@ export async function readHostContract(host) {
|
|
|
40
42
|
const target = hostContractPath(host);
|
|
41
43
|
try {
|
|
42
44
|
const record = await readJsonFile(target);
|
|
43
|
-
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
version: record.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
};
|
|
45
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
|
|
46
|
+
if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
|
|
47
|
+
if (typeof record.version === 'string' && record.version) {
|
|
48
|
+
return { hosts: { [record.version]: { result: record.result, checkedAt: record.checkedAt } } };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
50
51
|
} catch {
|
|
51
52
|
return null;
|
|
52
53
|
}
|
|
@@ -62,7 +63,12 @@ export async function writeHostContract(host, { version, result, now = new Date(
|
|
|
62
63
|
|
|
63
64
|
const target = hostContractPath(host);
|
|
64
65
|
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
65
|
-
const
|
|
66
|
+
const previous = await readHostContract(host);
|
|
67
|
+
const cutoff = now.getTime() - 180 * 24 * 60 * 60 * 1000;
|
|
68
|
+
const hosts = Object.fromEntries(Object.entries(previous?.hosts ?? {}).filter(([, entry]) =>
|
|
69
|
+
Number.isFinite(Date.parse(entry?.checkedAt)) && Date.parse(entry.checkedAt) >= cutoff));
|
|
70
|
+
hosts[version] = { result, checkedAt: now.toISOString() };
|
|
71
|
+
const record = { hosts };
|
|
66
72
|
await fs.mkdir(host.brandRoot, { recursive: true });
|
|
67
73
|
try {
|
|
68
74
|
await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
@@ -77,9 +83,9 @@ export function parseHostVersion(text) {
|
|
|
77
83
|
return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
export function detectHostVersion({ run = spawnSync } = {}) {
|
|
86
|
+
export function detectHostVersion({ run = spawnSync, executable = 'claude' } = {}) {
|
|
81
87
|
try {
|
|
82
|
-
const result = run(
|
|
88
|
+
const result = run(executable, ['--version'], {
|
|
83
89
|
encoding: 'utf8',
|
|
84
90
|
shell: false,
|
|
85
91
|
timeout: 5000,
|
|
@@ -107,13 +113,16 @@ export function contractStatus({ record, version }) {
|
|
|
107
113
|
message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
|
-
|
|
116
|
+
const entry = record.hosts?.[version];
|
|
117
|
+
if (!entry && Object.keys(record.hosts ?? {}).length) {
|
|
118
|
+
const [otherVersion] = Object.entries(record.hosts).sort((a, b) => Date.parse(b[1]?.checkedAt) - Date.parse(a[1]?.checkedAt))[0];
|
|
111
119
|
return {
|
|
112
120
|
state: 'stale',
|
|
113
|
-
message: `The refusal contract was probed on host ${
|
|
121
|
+
message: `The refusal contract was probed on host ${otherVersion}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
|
|
114
122
|
};
|
|
115
123
|
}
|
|
116
|
-
if (
|
|
124
|
+
if (!entry) return { state: 'unverified', message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.` };
|
|
125
|
+
if (entry.result === 'ignored') {
|
|
117
126
|
const guards = HOOK_DEFINITIONS
|
|
118
127
|
.filter((definition) => definition.event === 'PreToolUse')
|
|
119
128
|
.map((definition) => definition.name)
|
|
@@ -123,10 +132,10 @@ export function contractStatus({ record, version }) {
|
|
|
123
132
|
message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
|
|
124
133
|
};
|
|
125
134
|
}
|
|
126
|
-
if (
|
|
135
|
+
if (entry.result === 'honored') {
|
|
127
136
|
return {
|
|
128
137
|
state: 'verified',
|
|
129
|
-
message: `Host ${version} honours hook refusals (verified ${
|
|
138
|
+
message: `Host ${version} honours hook refusals (verified ${entry.checkedAt}).`,
|
|
130
139
|
};
|
|
131
140
|
}
|
|
132
141
|
return {
|
package/cli/install.mjs
CHANGED
|
@@ -25,7 +25,10 @@ import {
|
|
|
25
25
|
} from './settings-merge.mjs';
|
|
26
26
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
27
27
|
import { addPermissionRules, inspectPermissions } from './permissions.mjs';
|
|
28
|
-
import { contractStatus,
|
|
28
|
+
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
29
|
+
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
30
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
31
|
+
import { sessionHostVersions } from './session-hosts.mjs';
|
|
29
32
|
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
30
33
|
|
|
31
34
|
const WARNING = '\u001b[33m';
|
|
@@ -109,14 +112,22 @@ async function installInRun({
|
|
|
109
112
|
env = process.env,
|
|
110
113
|
contractRecord,
|
|
111
114
|
hostVersion,
|
|
115
|
+
observations,
|
|
112
116
|
} = {}) {
|
|
113
117
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
114
118
|
await readRulesRegistry(host);
|
|
115
119
|
const configuredRetentionLine = retentionLine(host);
|
|
116
|
-
const
|
|
120
|
+
const hostObservations = observations === undefined
|
|
121
|
+
? readHostObservations({ stateDir: brandStateDir(host.brandRoot) }) : observations;
|
|
122
|
+
const observedVersion = sessionHostVersions(hostObservations)[0] ?? null;
|
|
123
|
+
const currentHostVersion = hostVersion === undefined ? observedVersion : hostVersion;
|
|
124
|
+
let hostContract = contractStatus({
|
|
117
125
|
record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
|
|
118
|
-
version:
|
|
126
|
+
version: currentHostVersion,
|
|
119
127
|
});
|
|
128
|
+
const noObservedHostMessage = currentHostVersion === null
|
|
129
|
+
? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
|
|
130
|
+
if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
|
|
120
131
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
121
132
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
122
133
|
const currentPackage = await packageInfo(packageRoot);
|
package/cli/probe-contract.mjs
CHANGED
|
@@ -7,13 +7,14 @@ import { spawnSync } from 'node:child_process';
|
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { writeHostContract } from './host-contract.mjs';
|
|
11
11
|
import { buildRig } from './probe-rig.mjs';
|
|
12
12
|
import { DISPATCHER_CONTRACTS, judgeDispatcherContracts } from './dispatcher-contract.mjs';
|
|
13
13
|
import { writeDispatcherContract } from './dispatcher-contract-record.mjs';
|
|
14
14
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
15
15
|
import { parseJsonText } from '../src/home/lib/json-file.mjs';
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { transcriptHostVersion } from '../src/home/lib/host-version.mjs';
|
|
17
18
|
|
|
18
19
|
export const PROBE_MARKER = 'codex-bridge-contract-probe';
|
|
19
20
|
|
|
@@ -60,17 +61,18 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
|
|
|
60
61
|
|
|
61
62
|
export async function probeContract({
|
|
62
63
|
host,
|
|
63
|
-
|
|
64
|
+
target,
|
|
64
65
|
runHost = spawnSync,
|
|
65
66
|
rigRoot = os.tmpdir(),
|
|
66
67
|
now = new Date(),
|
|
67
68
|
}) {
|
|
68
|
-
if (version
|
|
69
|
+
if (target?.error || !target?.version || !target?.executable) {
|
|
70
|
+
const message = target?.error ?? 'The probe target is unavailable.';
|
|
69
71
|
return {
|
|
70
72
|
state: 'inconclusive',
|
|
71
73
|
result: null,
|
|
72
74
|
version: null,
|
|
73
|
-
message
|
|
75
|
+
message,
|
|
74
76
|
recorded: false,
|
|
75
77
|
dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Host version is unavailable.' }])),
|
|
76
78
|
};
|
|
@@ -83,7 +85,7 @@ export async function probeContract({
|
|
|
83
85
|
const rig = await buildRig(dir, token);
|
|
84
86
|
let hostResult;
|
|
85
87
|
try {
|
|
86
|
-
hostResult = runHost(
|
|
88
|
+
hostResult = runHost(target.executable, [
|
|
87
89
|
'--setting-sources', 'project',
|
|
88
90
|
'--allowedTools', 'Bash,Agent',
|
|
89
91
|
'-p', rig.prompt,
|
|
@@ -106,6 +108,20 @@ export async function probeContract({
|
|
|
106
108
|
} catch (error) {
|
|
107
109
|
if (error.code !== 'ENOENT') malformed = true;
|
|
108
110
|
}
|
|
111
|
+
// Only a host that completed the run can be asked who it was; a spawn failure or timeout keeps its own
|
|
112
|
+
// reason below instead of reading as "ran on host unknown".
|
|
113
|
+
const hostCompleted = !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0;
|
|
114
|
+
let seen = null;
|
|
115
|
+
const transcriptPath = entries.find((entry) => typeof entry?.payload?.transcript_path === 'string')?.payload.transcript_path;
|
|
116
|
+
if (transcriptPath) seen = await transcriptHostVersion(transcriptPath);
|
|
117
|
+
if (hostCompleted && seen !== target.version) {
|
|
118
|
+
return {
|
|
119
|
+
state: 'inconclusive', result: null, version: target.version,
|
|
120
|
+
message: `The probe ran on host ${seen ?? 'unknown'}, not on ${target.version}; nothing was recorded.`,
|
|
121
|
+
recorded: false,
|
|
122
|
+
dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Probe host version did not match target.' }])),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
109
125
|
const verdict = judgeProbe({
|
|
110
126
|
markerExists: await exists(rig.markerPath),
|
|
111
127
|
hookFired: await exists(rig.journalPath),
|
|
@@ -118,25 +134,25 @@ export async function probeContract({
|
|
|
118
134
|
failOutput: rig.failOutput, agentType: 'probe-agent', promptToken: token,
|
|
119
135
|
});
|
|
120
136
|
if (verdict.result == null) dispatcher = Object.fromEntries(Object.keys(dispatcher).map((name) => [name, { result: 'inconclusive', detail: 'Refusal probe did not complete.' }]));
|
|
121
|
-
if (verdict.result != null &&
|
|
122
|
-
await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version, verdicts: dispatcher, now });
|
|
137
|
+
if (verdict.result != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
|
|
138
|
+
await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version: target.version, verdicts: dispatcher, now });
|
|
123
139
|
}
|
|
124
140
|
if (verdict.result == null) {
|
|
125
141
|
return {
|
|
126
142
|
state: 'inconclusive',
|
|
127
143
|
result: null,
|
|
128
|
-
version,
|
|
144
|
+
version: target.version,
|
|
129
145
|
message: verdict.reason,
|
|
130
146
|
recorded: false,
|
|
131
147
|
dispatcher: Object.fromEntries(Object.entries(dispatcher).map(([name, item]) => [name, { result: 'inconclusive', detail: 'Host did not complete the probe.' }])),
|
|
132
148
|
};
|
|
133
149
|
}
|
|
134
150
|
|
|
135
|
-
await writeHostContract(host, { version, result: verdict.result, now });
|
|
151
|
+
await writeHostContract(host, { version: target.version, result: verdict.result, now });
|
|
136
152
|
return {
|
|
137
153
|
state: 'probed',
|
|
138
154
|
result: verdict.result,
|
|
139
|
-
version,
|
|
155
|
+
version: target.version,
|
|
140
156
|
message: verdict.reason,
|
|
141
157
|
recorded: true,
|
|
142
158
|
dispatcher,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Resolves the session host after the 2026-09-25 PATH host identity incident. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { detectHostVersion } from './host-contract.mjs';
|
|
5
|
+
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
6
|
+
|
|
7
|
+
export function resolveProbeTarget({ stateDir, executable, env = process.env, run = spawnSync, exists = fs.existsSync }) {
|
|
8
|
+
if (executable) {
|
|
9
|
+
const version = detectHostVersion({ run, executable });
|
|
10
|
+
return version === null
|
|
11
|
+
? { error: `Could not read the version of ${executable}.` }
|
|
12
|
+
: { executable, version, source: 'flag' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const observations = readHostObservations({ stateDir });
|
|
16
|
+
const newest = Object.entries(observations.hosts)
|
|
17
|
+
.filter(([, entry]) => Number.isFinite(Date.parse(entry?.lastSeen)))
|
|
18
|
+
.sort((a, b) => Date.parse(b[1].lastSeen) - Date.parse(a[1].lastSeen))[0];
|
|
19
|
+
if (!newest) {
|
|
20
|
+
return { error: 'No Claude Code session has been observed by this installation yet; run any shell command in a session first, or pass --probe-executable <path>.' };
|
|
21
|
+
}
|
|
22
|
+
const [version] = newest;
|
|
23
|
+
const candidates = [];
|
|
24
|
+
if (env.CLAUDE_CODE_EXECPATH && exists(env.CLAUDE_CODE_EXECPATH)) candidates.push(env.CLAUDE_CODE_EXECPATH);
|
|
25
|
+
candidates.push('claude');
|
|
26
|
+
const checked = [];
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
const candidateVersion = detectHostVersion({ run, executable: candidate });
|
|
29
|
+
checked.push(`${candidate} (${candidateVersion ?? 'unknown'})`);
|
|
30
|
+
if (candidateVersion === version) return { executable: candidate, version, source: 'observed' };
|
|
31
|
+
}
|
|
32
|
+
return { error: `No executable of the observed host ${version} was found (checked: ${checked.join(', ')}); pass --probe-executable <path>.` };
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Judges every observed session host after the 2026-09-25 PATH host identity incident. */
|
|
2
|
+
import { contractStatus, PROBE_COMMAND } from './host-contract.mjs';
|
|
3
|
+
import { DISPATCHER_CONTRACTS, dispatcherContractStatus } from './dispatcher-contract.mjs';
|
|
4
|
+
|
|
5
|
+
export function sessionHostVersions(observations) {
|
|
6
|
+
return Object.entries(observations?.hosts ?? {})
|
|
7
|
+
.map(([version, entry]) => ({ version, timestamp: Date.parse(entry?.lastSeen) }))
|
|
8
|
+
.filter(({ timestamp }) => Number.isFinite(timestamp))
|
|
9
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
10
|
+
.map(({ version }) => version);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sessionHostCheck(versions, observations) {
|
|
14
|
+
if (!versions.length) {
|
|
15
|
+
return {
|
|
16
|
+
key: 'sessionHost',
|
|
17
|
+
status: 'warn',
|
|
18
|
+
value: 'No Claude Code session has been observed by this installation yet; host contracts are judged once a session runs a shell command.',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const newest = versions[0];
|
|
22
|
+
const lastSeen = new Date(observations.hosts[newest].lastSeen).toISOString();
|
|
23
|
+
const others = versions.slice(1);
|
|
24
|
+
return {
|
|
25
|
+
key: 'sessionHost',
|
|
26
|
+
status: 'ok',
|
|
27
|
+
value: `Sessions run on host ${newest} (last seen ${lastSeen})${others.length ? `; also seen: ${others.join(', ')}` : ''}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function otherHostCheck({ version, contractRecord, dispatcherRecord }) {
|
|
32
|
+
const refusal = contractStatus({ record: contractRecord, version });
|
|
33
|
+
const dispatchers = dispatcherContractStatus({ record: dispatcherRecord, version });
|
|
34
|
+
const key = `otherHost:${version}`;
|
|
35
|
+
if (refusal.state === 'ignored' || dispatchers.some(({ state }) => state === 'changed')) {
|
|
36
|
+
const changed = dispatchers.filter(({ state }) => state === 'changed').map(({ contract }) => contract);
|
|
37
|
+
return { key, status: 'fail', value: [refusal.state === 'ignored' ? 'refusal contract ignored' : '', ...changed].filter(Boolean).join('; ') };
|
|
38
|
+
}
|
|
39
|
+
if (refusal.state === 'verified' && dispatchers.every(({ state }) => state === 'verified')) {
|
|
40
|
+
return { key, status: 'ok', value: `Host ${version} refusal and dispatcher contracts are verified.` };
|
|
41
|
+
}
|
|
42
|
+
const missing = [];
|
|
43
|
+
if (refusal.state !== 'verified') missing.push('refusal');
|
|
44
|
+
missing.push(...dispatchers.filter(({ state }) => state !== 'verified').map(({ contract }) => contract));
|
|
45
|
+
const probe = `${PROBE_COMMAND} --probe-executable <path to a ${version} executable>`;
|
|
46
|
+
return {
|
|
47
|
+
key,
|
|
48
|
+
status: 'warn',
|
|
49
|
+
value: `Host ${version} contracts not yet measured: ${missing.join(', ')}; run ${probe}.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/cli/uninstall.mjs
CHANGED
|
@@ -42,8 +42,9 @@ function preservationText(host) {
|
|
|
42
42
|
return `Run artifacts in ${path.join(host.root, 'codex-runs')} are preserved, and so is what the package `
|
|
43
43
|
+ `wrote into ${host.brandRoot} while working: the run configuration ${host.brandConfigPath}, the conventions `
|
|
44
44
|
+ `${host.brandConventionsPath}, the host measurement ${hostContractPath(host)} and runtime state in `
|
|
45
|
-
+ `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness,
|
|
46
|
-
+ 'contract verdicts, and hook diagnostics that hold the full hook input). Delete them by
|
|
45
|
+
+ `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness, the host `
|
|
46
|
+
+ 'observations, dispatcher contract verdicts, and hook diagnostics that hold the full hook input). Delete them by '
|
|
47
|
+
+ 'hand for a complete removal.';
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
function displayFile(file) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with 7 guards,
|
|
3
|
+
"version": "0.6.8",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 7 guards, 1667 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -21,6 +21,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
21
21
|
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
22
22
|
import { readmeText, suiteCountMismatch } from './suite-count.mjs';
|
|
23
23
|
|
|
24
|
+
// One pattern only: on 2026-09-25 three files passed as three arguments ran just the first, and a
|
|
25
|
+
// verification reported green for two files it never touched. Refuse before any root is created.
|
|
26
|
+
if (process.argv.length > 3) {
|
|
27
|
+
console.error('run-tests: takes ONE pattern; pass a glob such as "tests/cli/{doctor,install}.test.mjs"');
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
const created = ISOLATED_ROOTS.map((name) => [
|
|
25
32
|
name,
|
|
26
33
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
readDispatcherState,
|
|
18
18
|
updateDispatcherState,
|
|
19
19
|
} from '../lib/dispatcher-state.mjs';
|
|
20
|
-
import {
|
|
20
|
+
import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
|
|
21
|
+
import { observeSessionHost } from '../lib/host-observations.mjs';
|
|
21
22
|
|
|
22
23
|
function emit(decision, toolInput) {
|
|
23
24
|
const hookSpecificOutput = {
|
|
@@ -43,6 +44,18 @@ async function main() {
|
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
if (payload?.hook_event_name === 'PreToolUse'
|
|
48
|
+
&& typeof payload.session_id === 'string' && payload.session_id.length > 0
|
|
49
|
+
&& typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0) {
|
|
50
|
+
try {
|
|
51
|
+
await observeSessionHost({
|
|
52
|
+
stateDir: BRAND_STATE_DIR,
|
|
53
|
+
sessionId: payload.session_id,
|
|
54
|
+
transcriptPath: payload.transcript_path,
|
|
55
|
+
});
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
|
|
47
60
|
|| typeof payload.session_id !== 'string' || payload.session_id.length === 0
|
|
48
61
|
|| typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
|
|
@@ -87,7 +100,7 @@ async function main() {
|
|
|
87
100
|
await recordHandbackWitness({
|
|
88
101
|
stateDir: BRAND_STATE_DIR,
|
|
89
102
|
kind: 'seen',
|
|
90
|
-
|
|
103
|
+
hostVersion: await witnessHostVersion(payload),
|
|
91
104
|
});
|
|
92
105
|
} catch {}
|
|
93
106
|
try {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* argument about EXTERNAL run state cannot be argued away by the model and ends the turn
|
|
23
23
|
* with `continue: false` instead of a silent pass. See MAX_FORM_BLOCKS / MAX_STATE_BLOCKS.
|
|
24
24
|
* In the handback era, SubagentHandback delivers the caller-visible answer before SubagentStop.
|
|
25
|
+
* Alarms name the host by its transcript version: on 2026-09-25 the SDK variable proved inherited.
|
|
25
26
|
* Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
|
|
26
27
|
*
|
|
27
28
|
* Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
|
|
@@ -37,7 +38,7 @@ import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop
|
|
|
37
38
|
import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
|
|
38
39
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
39
40
|
import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
|
|
40
|
-
import {
|
|
41
|
+
import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
|
|
41
42
|
import { runLiveness } from '../lib/meta/run-liveness.mjs';
|
|
42
43
|
import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
43
44
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
@@ -118,6 +119,25 @@ try {
|
|
|
118
119
|
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// A host that stops sending agent_type would let every dispatcher past the gate and this
|
|
123
|
+
// audit without a word (Plan_66 D2 (c)); say so instead of passing silently.
|
|
124
|
+
if (typeof input.agent_id === 'string' && input.agent_id.length > 0
|
|
125
|
+
&& !(typeof input.agent_type === 'string' && input.agent_type.length > 0)) {
|
|
126
|
+
try {
|
|
127
|
+
await recordHandbackWitness({
|
|
128
|
+
stateDir: BRAND_STATE_DIR,
|
|
129
|
+
kind: 'alarm',
|
|
130
|
+
hostVersion: await witnessHostVersion(input),
|
|
131
|
+
detail: `host omitted agent_type for agent ${input.agent_id}`,
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
// Plan_66 H2: the 2026-09-17..24 handback break showed host contract loss must alarm, but never block work.
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(JSON.stringify({
|
|
137
|
+
systemMessage: 'codex-bridge: the host did not report agent_type for a subagent — the dispatcher gate cannot recognise dispatchers, so their answers are unchecked; run codex-bridge doctor.',
|
|
138
|
+
}));
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
121
141
|
// Only the Codex dispatchers have this contract. Every other agent is none of our
|
|
122
142
|
// business — an earlier version inferred the agent from transcript text and blocked an
|
|
123
143
|
// unrelated one, which cost a re-answer for nothing.
|
|
@@ -144,7 +164,7 @@ try {
|
|
|
144
164
|
await recordHandbackWitness({
|
|
145
165
|
stateDir: BRAND_STATE_DIR,
|
|
146
166
|
kind: 'alarm',
|
|
147
|
-
|
|
167
|
+
hostVersion: await witnessHostVersion(input),
|
|
148
168
|
detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
|
|
149
169
|
});
|
|
150
170
|
}
|
|
@@ -1,27 +1,32 @@
|
|
|
1
|
-
/** Records the 2026-09-17..24
|
|
1
|
+
/** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { writeJsonAtomic } from './atomic-json.mjs';
|
|
5
5
|
import { parseJsonText } from './json-file.mjs';
|
|
6
6
|
import { withFileLock } from './file-lock.mjs';
|
|
7
|
+
import { transcriptHostVersion } from './host-version.mjs';
|
|
7
8
|
|
|
8
9
|
export const WITNESS_FILE = 'handback-witness.json';
|
|
9
10
|
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
if (typeof version !== 'string') return null;
|
|
13
|
-
return version.trim() || null;
|
|
11
|
+
export async function witnessHostVersion(payload) {
|
|
12
|
+
return transcriptHostVersion(payload?.transcript_path);
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
function
|
|
17
|
-
const
|
|
18
|
-
&&
|
|
19
|
-
&&
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
&& (record.lastSeen
|
|
24
|
-
|
|
15
|
+
function normalizeWitness(record) {
|
|
16
|
+
const alarmEntry = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
17
|
+
&& (entry.hostVersion === null || typeof entry.hostVersion === 'string')
|
|
18
|
+
&& typeof entry.at === 'string' && typeof entry.detail === 'string';
|
|
19
|
+
if (!record || typeof record !== 'object' || Array.isArray(record) || !Array.isArray(record.alarms)) return null;
|
|
20
|
+
if (record.alarms.every(alarmEntry) && record.lastSeen && typeof record.lastSeen === 'object'
|
|
21
|
+
&& !Array.isArray(record.lastSeen) && !Object.hasOwn(record.lastSeen, 'sdkVersion')
|
|
22
|
+
&& !Object.hasOwn(record.lastSeen, 'at') && Object.values(record.lastSeen).every((at) => typeof at === 'string')) return record;
|
|
23
|
+
const legacy = record.lastSeen === null || (record.lastSeen && typeof record.lastSeen === 'object'
|
|
24
|
+
&& !Array.isArray(record.lastSeen) && typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
|
|
25
|
+
const legacyAlarm = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
26
|
+
&& (entry.sdkVersion === null || typeof entry.sdkVersion === 'string') && typeof entry.at === 'string'
|
|
27
|
+
&& typeof entry.detail === 'string';
|
|
28
|
+
if (legacy && record.alarms.every(legacyAlarm)) return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
|
|
29
|
+
return null;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function readHandbackWitness({ stateDir }) {
|
|
@@ -30,12 +35,12 @@ export function readHandbackWitness({ stateDir }) {
|
|
|
30
35
|
try {
|
|
31
36
|
source = fs.readFileSync(file, 'utf8');
|
|
32
37
|
} catch (error) {
|
|
33
|
-
if (error.code === 'ENOENT') return { lastSeen:
|
|
38
|
+
if (error.code === 'ENOENT') return { lastSeen: {}, alarms: [] };
|
|
34
39
|
return { corrupt: true };
|
|
35
40
|
}
|
|
36
41
|
try {
|
|
37
42
|
const record = parseJsonText(file, source);
|
|
38
|
-
return
|
|
43
|
+
return normalizeWitness(record) || { corrupt: true };
|
|
39
44
|
} catch {
|
|
40
45
|
return { corrupt: true };
|
|
41
46
|
}
|
|
@@ -46,10 +51,10 @@ function timestamp(now) {
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
|
|
49
|
-
export async function recordHandbackWitness({ stateDir, kind,
|
|
54
|
+
export async function recordHandbackWitness({ stateDir, kind, hostVersion, detail = '', now = new Date() }) {
|
|
50
55
|
if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
|
|
51
|
-
if (
|
|
52
|
-
throw new TypeError('
|
|
56
|
+
if (hostVersion !== null && typeof hostVersion !== 'string') {
|
|
57
|
+
throw new TypeError('hostVersion must be a string or null');
|
|
53
58
|
}
|
|
54
59
|
const file = path.join(stateDir, WITNESS_FILE);
|
|
55
60
|
return withFileLock(`${file}.lock`, async () => {
|
|
@@ -57,8 +62,8 @@ export async function recordHandbackWitness({ stateDir, kind, sdkVersion, detail
|
|
|
57
62
|
if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
|
|
58
63
|
const at = timestamp(now);
|
|
59
64
|
const record = kind === 'seen'
|
|
60
|
-
? { ...current, lastSeen: {
|
|
61
|
-
: { ...current, alarms: [...current.alarms, {
|
|
65
|
+
? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
|
|
66
|
+
: { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
|
|
62
67
|
writeJsonAtomic(file, record);
|
|
63
68
|
return record;
|
|
64
69
|
}, { description: 'handback witness' });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Records session host versions after the 2026-09-25 PATH host identity incident. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { writeJsonAtomic } from './atomic-json.mjs';
|
|
5
|
+
import { withFileLock } from './file-lock.mjs';
|
|
6
|
+
import { parseJsonText } from './json-file.mjs';
|
|
7
|
+
import { transcriptHostVersion } from './host-version.mjs';
|
|
8
|
+
|
|
9
|
+
export const HOST_OBSERVATIONS_FILE = 'host-observations.json';
|
|
10
|
+
|
|
11
|
+
export function readHostObservations({ stateDir }) {
|
|
12
|
+
const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error.code === 'ENOENT') return { hosts: {}, sessions: {} };
|
|
18
|
+
return { hosts: {}, sessions: {}, corrupt: true };
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const record = parseJsonText(file, raw);
|
|
22
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
23
|
+
|| !record.hosts || typeof record.hosts !== 'object' || Array.isArray(record.hosts)
|
|
24
|
+
|| !record.sessions || typeof record.sessions !== 'object' || Array.isArray(record.sessions)) {
|
|
25
|
+
return { hosts: {}, sessions: {}, corrupt: true };
|
|
26
|
+
}
|
|
27
|
+
return { hosts: record.hosts, sessions: record.sessions };
|
|
28
|
+
} catch {
|
|
29
|
+
return { hosts: {}, sessions: {}, corrupt: true };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function observeSessionHost({
|
|
34
|
+
stateDir, sessionId, transcriptPath, now = new Date(), readVersion = transcriptHostVersion,
|
|
35
|
+
}) {
|
|
36
|
+
const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
|
|
37
|
+
if (readHostObservations({ stateDir }).sessions[sessionId]) return { recorded: false };
|
|
38
|
+
const version = await readVersion(transcriptPath);
|
|
39
|
+
if (version === null) return { recorded: false };
|
|
40
|
+
return withFileLock(`${file}.lock`, async () => {
|
|
41
|
+
const current = readHostObservations({ stateDir });
|
|
42
|
+
if (current.corrupt) throw new Error(`Cannot update corrupt host observations: ${file}`);
|
|
43
|
+
if (current.sessions[sessionId]) return { recorded: false };
|
|
44
|
+
const at = new Date(now).toISOString();
|
|
45
|
+
const cutoffSessions = Date.parse(at) - 7 * 24 * 60 * 60 * 1000;
|
|
46
|
+
const cutoffHosts = Date.parse(at) - 30 * 24 * 60 * 60 * 1000;
|
|
47
|
+
const sessions = Object.fromEntries(Object.entries(current.sessions)
|
|
48
|
+
.filter(([, entry]) => Date.parse(entry.at) >= cutoffSessions));
|
|
49
|
+
const hosts = Object.fromEntries(Object.entries(current.hosts)
|
|
50
|
+
.filter(([, entry]) => Date.parse(entry.lastSeen) >= cutoffHosts));
|
|
51
|
+
sessions[sessionId] = { version, at };
|
|
52
|
+
const existing = hosts[version];
|
|
53
|
+
hosts[version] = { firstSeen: existing?.firstSeen || at, lastSeen: at };
|
|
54
|
+
writeJsonAtomic(file, { hosts, sessions });
|
|
55
|
+
return { recorded: true };
|
|
56
|
+
}, { description: 'host observations' });
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Reads the host version from the transcript tail after the 2026-09-25 PATH host identity incident. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { parseJsonText } from './json-file.mjs';
|
|
4
|
+
|
|
5
|
+
export async function transcriptHostVersion(transcriptPath, { tailBytes = 65536 } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const handle = await fs.open(transcriptPath, 'r');
|
|
8
|
+
try {
|
|
9
|
+
const { size } = await handle.stat();
|
|
10
|
+
const length = Math.min(size, tailBytes);
|
|
11
|
+
const buffer = Buffer.alloc(length);
|
|
12
|
+
await handle.read(buffer, 0, length, size - length);
|
|
13
|
+
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
14
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
15
|
+
try {
|
|
16
|
+
const entry = parseJsonText(transcriptPath, lines[index]);
|
|
17
|
+
if (typeof entry?.version === 'string' && /^\d+\.\d+\.\d+/.test(entry.version)) {
|
|
18
|
+
return entry.version;
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
} finally {
|
|
23
|
+
await handle.close();
|
|
24
|
+
}
|
|
25
|
+
} catch {}
|
|
26
|
+
return null;
|
|
27
|
+
}
|