@lyupro/codex-bridge 0.6.11 → 0.6.12
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 +34 -0
- package/README.md +3 -2
- package/cli/active-hosts.mjs +44 -0
- package/cli/command-registry.mjs +18 -2
- package/cli/dispatcher-model-check.mjs +14 -12
- package/cli/doctor-host-side.mjs +117 -111
- package/cli/doctor.mjs +17 -10
- package/cli/handback-witness-check.mjs +23 -19
- package/cli/install-record.mjs +5 -0
- package/cli/inventory-confirm.mjs +75 -0
- package/cli/inventory-transition.mjs +3 -1
- package/package.json +2 -2
- package/src/home/lib/install-owner-roots.mjs +55 -53
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ 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.12] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `codex-bridge inventory confirm [--scope user|project] [--host <path>] [--dry-run]` lets the
|
|
12
|
+
operator declare that the recorded hosts are all the hosts using this home. An old installation
|
|
13
|
+
record migrated to format 2 without a terminal stays `inventory: incomplete`: later updates do
|
|
14
|
+
not ask again, and since 0.6.11 every dispatcher model observation leaves the pin undetermined.
|
|
15
|
+
The package cannot prove that no unrecorded host uses the home; only the operator can.
|
|
16
|
+
The command shows the home, recorded host roots and Codex rules registry hints, marking missing
|
|
17
|
+
folders because the registry can retain deleted scratchpads. Enroll an unlisted host with
|
|
18
|
+
`codex-bridge install --host "<path>"` before confirming. A terminal "yes" changes only
|
|
19
|
+
`inventory` to `complete` and removes `legacy`; owners, fingerprints and every other field are
|
|
20
|
+
left as read. This permits ordinary shared-image cleanup when the last recorded host uninstalls;
|
|
21
|
+
it does not record a model match or clear a violation. No, EOF or a non-TTY exits 1 without changes;
|
|
22
|
+
Ctrl+C exits 130; a valid dry run shows the declaration without writing and exits 0. Confirmation
|
|
23
|
+
and an already-complete inventory without `legacy` exit 0. The `doctor` owner rows and the
|
|
24
|
+
incomplete-inventory pin reason now name this command.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `doctor` judges handback witness and dispatcher model state for every host whose dispatchers
|
|
29
|
+
ran in the last 24 hours. On 2026-10-07 two VS Code windows ran 2.1.291 and 2.1.292: an observation
|
|
30
|
+
on the non-newest host appeared only as history, and a model violation there would have printed
|
|
31
|
+
`ok`. Active witness violations identify their host with `host <version>:`, and each active
|
|
32
|
+
host's model entries have their own rows with that prefix. A model `fail` on any active host
|
|
33
|
+
reaches exit code 1. Older unresolved entries remain in one history sentence, without active
|
|
34
|
+
warning rows. The newest-host selector still serves contracts, `otherHost:*` and install.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Dispatcher model rows no longer print a doubled period after a pin reason: the live `doctor`
|
|
39
|
+
output had added its own period to a reason that already ended with one.
|
|
40
|
+
|
|
7
41
|
## [0.6.11] - 2026-10-07
|
|
8
42
|
|
|
9
43
|
### Changed
|
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 **3267 automated tests: 3266 passing and 1 skipped**.
|
|
66
66
|
|
|
67
67
|
## Verify
|
|
68
68
|
|
|
@@ -93,7 +93,7 @@ Output on a healthy host (trimmed):
|
|
|
93
93
|
|
|
94
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
95
|
|
|
96
|
-
`doctor` also reports `handbackWitness`, `dispatcherModel:<type>` rows, 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. The witness warns
|
|
96
|
+
`doctor` also reports `handbackWitness`, `dispatcherModel:<type>` rows, 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. Witness and model checks cover every host whose dispatchers ran in the last 24 hours; two VS Code windows on 2.1.291 and 2.1.292 on 2026-10-07 showed why the newest host alone could hide an active host's violation in history. The witness warns for unresolved causes on any active host; later disproving evidence clears the warning while retaining history. Intercepted handbacks are not recovery evidence, and old untyped alarms without dispatcher evidence remain unverified history. A stop missing `agent_type` alarms only with dispatcher evidence for that stop. Each active host's model entries have their own rows prefixed `host <version>:`, comparing transcript-observed families with the installed contract: a latest violation on any active host is `fail` with exit 1, uncertainty is `warn`, and a match, including recovery, is `ok`; no model entries for active hosts is also `ok`. Hosts outside the 24-hour window and recovered incidents remain history, not permanent warnings. Contracts, `otherHost:*` and install still use the newest observed host selector. See [Response guard](docs/overview.md#response-guard) for the evidence rules. 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.
|
|
97
97
|
|
|
98
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:
|
|
99
99
|
|
|
@@ -141,6 +141,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
|
|
|
141
141
|
| `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
|
|
142
142
|
| `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
|
|
143
143
|
| `uninstall [--scope user\|project] [--host <path>] [--dry-run] [--purge]` | Remove recorded package files while preserving run artifacts; `--purge` also deletes your data and the home after one preflight and two consents (see [Uninstall and purge](docs/overview.md#uninstall-and-purge)). |
|
|
144
|
+
| `inventory confirm [--scope user\|project] [--host <path>] [--dry-run]` | Confirm in a terminal that the recorded hosts are all the hosts using this home; until then the dispatcher model pin stays undetermined and `doctor` names this command. Changes only the inventory mark of the installation record. |
|
|
144
145
|
| `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. |
|
|
145
146
|
| `run --agent <type> --task-file "<abs path>" [--no-wait]` | Start or attach to a delegated run through the permission-stable package command. The task-file header carries the whole order; its body carries the statement, scout questions and verification command. Its path must be absolute and use forward slashes; `$`, doubled backslashes and a final backslash are refused. Every other runner flag is a free refusal; stdin is not an input channel. |
|
|
146
147
|
| `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. |
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Names the hosts whose dispatchers ran in the last 24 hours, the hosts whose witness and model state is active;
|
|
2
|
+
* Plan_67 D12: concurrent VS Code hosts 2.1.291 and 2.1.292 on 2026-10-07 must both be judged, regardless of session liveness.
|
|
3
|
+
*/
|
|
4
|
+
export const ACTIVE_HOST_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function activeHostVersions({ observations, witnessRecord, modelRecord, now = new Date() }) {
|
|
7
|
+
const currentTime = new Date(now).getTime();
|
|
8
|
+
const activity = new Map();
|
|
9
|
+
function recordActivity(version, at) {
|
|
10
|
+
if (typeof version !== 'string' || !version || version === 'unknown') return;
|
|
11
|
+
if (typeof at !== 'string'
|
|
12
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(at)) return;
|
|
13
|
+
const time = Date.parse(at);
|
|
14
|
+
const date = Date.parse(`${at.slice(0, 10)}T00:00:00Z`);
|
|
15
|
+
if (!Number.isFinite(time) || !Number.isFinite(date)
|
|
16
|
+
|| new Date(date).toISOString().slice(0, 10) !== at.slice(0, 10)) return;
|
|
17
|
+
// Plan_67 R2: future evidence must not hide a valid past source for the same host.
|
|
18
|
+
if (time > currentTime) return;
|
|
19
|
+
if (!activity.has(version) || time > activity.get(version)) activity.set(version, time);
|
|
20
|
+
}
|
|
21
|
+
function ledgerActivity(ledger) {
|
|
22
|
+
if (!ledger || ledger.corrupt) return;
|
|
23
|
+
for (const entry of Object.values(ledger.entries || {})) {
|
|
24
|
+
if (!entry || entry.corrupt || entry.lastObservation?.corrupt) continue;
|
|
25
|
+
const observation = entry.lastObservation;
|
|
26
|
+
recordActivity(observation?.data?.hostVersion, observation?.at);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (observations && !observations.corrupt) {
|
|
30
|
+
for (const [version, host] of Object.entries(observations.hosts || {})) {
|
|
31
|
+
if (host && !host.corrupt) recordActivity(version, host.lastSeen);
|
|
32
|
+
}
|
|
33
|
+
for (const session of Object.values(observations.sessions || {})) {
|
|
34
|
+
if (session && !session.corrupt) recordActivity(session.version, session.at);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (witnessRecord && !witnessRecord.corrupt) {
|
|
38
|
+
for (const [version, at] of Object.entries(witnessRecord.intercepted || {})) recordActivity(version, at);
|
|
39
|
+
ledgerActivity(witnessRecord.ledger);
|
|
40
|
+
}
|
|
41
|
+
ledgerActivity(modelRecord);
|
|
42
|
+
return [...activity].filter(([, at]) => currentTime - at >= 0 && currentTime - at <= ACTIVE_HOST_WINDOW_MS)
|
|
43
|
+
.sort((a, b) => b[1] - a[1]).map(([version]) => version);
|
|
44
|
+
}
|
package/cli/command-registry.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { resolveProbeTarget } from './probe-target.mjs';
|
|
|
9
9
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
10
10
|
import { hook } from './hook.mjs';
|
|
11
11
|
import { resolveHost } from './hosts.mjs';
|
|
12
|
-
import { install } from './install.mjs';
|
|
12
|
+
import { install } from './install.mjs';
|
|
13
|
+
import { inventoryConfirm } from './inventory-confirm.mjs';
|
|
13
14
|
import { model } from './model.mjs';
|
|
14
15
|
import { projects } from './projects.mjs';
|
|
15
16
|
import { read } from './read.mjs';
|
|
@@ -25,7 +26,8 @@ import { update } from './update.mjs';
|
|
|
25
26
|
export function commandOptions(command, argv) {
|
|
26
27
|
const options = {};
|
|
27
28
|
const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
|
|
28
|
-
: command === 'uninstall' ? new Set(['--dry-run', '--purge'])
|
|
29
|
+
: command === 'uninstall' ? new Set(['--dry-run', '--purge'])
|
|
30
|
+
: command === 'inventory' ? new Set(['--dry-run'])
|
|
29
31
|
: command === 'doctor' ? new Set(['--probe-contract']) : new Set();
|
|
30
32
|
const flagNames = new Map([
|
|
31
33
|
['--dry-run', 'dryRun'],
|
|
@@ -123,6 +125,20 @@ export const COMMANDS = [
|
|
|
123
125
|
},
|
|
124
126
|
},
|
|
125
127
|
{
|
|
128
|
+
name: 'inventory',
|
|
129
|
+
summary: 'Confirm that the recorded hosts are all the hosts using this home',
|
|
130
|
+
usage: ['codex-bridge inventory confirm [--scope user|project] [--host <path>] [--dry-run]'],
|
|
131
|
+
section: 'public',
|
|
132
|
+
async handler(argv, io) {
|
|
133
|
+
if (argv[0] !== 'confirm') throw new Error(`unknown inventory action "${argv[0] ?? ''}"`);
|
|
134
|
+
const options = commandOptions('inventory', argv.slice(1));
|
|
135
|
+
const host = resolveHost(options);
|
|
136
|
+
const result = await inventoryConfirm({ host, dryRun: options.dryRun });
|
|
137
|
+
io.log(result.output);
|
|
138
|
+
return result.exitCode;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
126
142
|
name: 'doctor',
|
|
127
143
|
summary: 'Diagnose the selected Claude Code host (--probe-contract measures on a live host)',
|
|
128
144
|
usage: ['codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]'],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** turns the dispatcher-model ledger into doctor rows; Plan_67 D4/D8
|
|
1
|
+
/** turns the dispatcher-model ledger into doctor rows; Plan_67 D4/D8/D12 judge every recently active host. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { DISPATCHER_MODEL_FILE } from '../src/home/lib/dispatcher-model.mjs';
|
|
4
4
|
import { entryState } from '../src/home/lib/observation-ledger.mjs';
|
|
@@ -12,48 +12,50 @@ function entryCheck(entry) {
|
|
|
12
12
|
const observation = entry.lastObservation;
|
|
13
13
|
const { data, verdict, at } = observation;
|
|
14
14
|
const key = `dispatcherModel:${data.agentType}`;
|
|
15
|
+
const prefix = `host ${data.hostVersion}: `;
|
|
15
16
|
const state = entryState(entry);
|
|
16
17
|
// D4: only the latest confirmed violation fails; D8 keeps uncertainty from erasing history.
|
|
17
18
|
if (verdict === 'violation') {
|
|
18
|
-
return check(key, 'fail', `${observationSummary(observation)}; Claude quota was spent on this dispatcher; see Plan_67.`);
|
|
19
|
+
return check(key, 'fail', `${prefix}${observationSummary(observation)}; Claude quota was spent on this dispatcher; see Plan_67.`);
|
|
19
20
|
}
|
|
20
21
|
if (verdict === 'undetermined') {
|
|
21
22
|
const reason = data.pinReasons?.length
|
|
22
|
-
|
|
23
|
+
// Reasons are sentences ending in a period; the row adds its own (live doctor printed "record..").
|
|
24
|
+
? data.pinReasons.map((text) => text.replace(/\.$/, '')).join('; ') : 'no model family could be read from the transcript';
|
|
23
25
|
const unresolved = state === 'violation'
|
|
24
26
|
? `; earlier violation (${observationSummary(entry.lastViolation)}) is not yet disproved` : '';
|
|
25
|
-
return check(key, 'warn', `${data.agentType}: model observation undetermined at ${at}: ${reason}${unresolved}.`);
|
|
27
|
+
return check(key, 'warn', `${prefix}${data.agentType}: model observation undetermined at ${at}: ${reason}${unresolved}.`);
|
|
26
28
|
}
|
|
27
29
|
const history = state === 'recovered'
|
|
28
30
|
? `; recovered from earlier violation (${observationSummary(entry.lastViolation)})` : '';
|
|
29
|
-
return check(key, 'ok', `${observationSummary(observation)}; matches the installed contract${history}.`);
|
|
31
|
+
return check(key, 'ok', `${prefix}${observationSummary(observation)}; matches the installed contract${history}.`);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
export function dispatcherModelChecks({ record,
|
|
34
|
+
export function dispatcherModelChecks({ record, activeHosts, stateDir }) {
|
|
33
35
|
if (record?.corrupt) {
|
|
34
36
|
return [check('dispatcherModel', 'warn',
|
|
35
37
|
`The dispatcher model record is unreadable; delete ${path.join(stateDir, DISPATCHER_MODEL_FILE)} to reset it.`)];
|
|
36
38
|
}
|
|
37
39
|
const entries = Object.values(record?.entries || {});
|
|
38
|
-
const current = entries.filter((entry) => entry.lastObservation.data.hostVersion
|
|
40
|
+
const current = entries.filter((entry) => activeHosts.includes(entry.lastObservation.data.hostVersion));
|
|
39
41
|
const rows = current.map(entryCheck);
|
|
40
42
|
if (!rows.length) {
|
|
41
|
-
rows.push(check('dispatcherModel', 'ok', 'Not observed
|
|
43
|
+
rows.push(check('dispatcherModel', 'ok', 'Not observed in the last 24 hours — recorded when a dispatcher stops.'));
|
|
42
44
|
}
|
|
43
45
|
const hosts = new Map();
|
|
44
46
|
for (const entry of entries) {
|
|
45
47
|
const version = entry.lastObservation.data.hostVersion;
|
|
46
|
-
if (version
|
|
48
|
+
if (activeHosts.includes(version)) continue;
|
|
47
49
|
const counts = hosts.get(version) || { entries: 0, violations: 0 };
|
|
48
50
|
counts.entries += 1;
|
|
49
51
|
if (entryState(entry) === 'violation') counts.violations += 1;
|
|
50
52
|
hosts.set(version, counts);
|
|
51
53
|
}
|
|
52
|
-
//
|
|
54
|
+
// D12: inactive hosts are one history sentence, never additional active warning rows.
|
|
53
55
|
if (hosts.size) {
|
|
54
56
|
const history = [...hosts].map(([version, counts]) =>
|
|
55
57
|
`${version ?? 'unknown host'}: ${counts.entries} entries, ${counts.violations} unresolved violations`);
|
|
56
|
-
rows[0].value += ` History on
|
|
58
|
+
rows[0].value += ` History on hosts without dispatcher activity in the last 24 hours (historical, not current): ${history.join('; ')}.`;
|
|
57
59
|
}
|
|
58
60
|
return rows;
|
|
59
|
-
}
|
|
61
|
+
}
|
package/cli/doctor-host-side.mjs
CHANGED
|
@@ -1,111 +1,117 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
|
|
3
|
-
* comes from its marks, while WHO uses the home comes only from its owner rows. A single
|
|
4
|
-
* "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
|
|
5
|
-
* never said which hosts the home record names or whether its inventory is complete.
|
|
6
|
-
* Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
|
|
7
|
-
*/
|
|
8
|
-
import fs from 'node:fs/promises';
|
|
9
|
-
import path from 'node:path';
|
|
10
|
-
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
-
import { check } from './doctor-format.mjs';
|
|
12
|
-
import { hasPackageMarks } from './host-inspection.mjs';
|
|
13
|
-
import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
|
|
14
|
-
import { imageFingerprint } from './install-owners.mjs';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
export function
|
|
23
|
-
if (
|
|
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
|
-
if (
|
|
56
|
-
return
|
|
57
|
-
'home owners',
|
|
58
|
-
'
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (!
|
|
85
|
-
return check('owners in sync', '
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
|
|
3
|
+
* comes from its marks, while WHO uses the home comes only from its owner rows. A single
|
|
4
|
+
* "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
|
|
5
|
+
* never said which hosts the home record names or whether its inventory is complete.
|
|
6
|
+
* Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
+
import { check } from './doctor-format.mjs';
|
|
12
|
+
import { hasPackageMarks } from './host-inspection.mjs';
|
|
13
|
+
import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
|
|
14
|
+
import { imageFingerprint } from './install-owners.mjs';
|
|
15
|
+
import { INVENTORY_CONFIRM_COMMAND } from './inventory-confirm.mjs';
|
|
16
|
+
|
|
17
|
+
const inventoryRepair = `; run ${INVENTORY_CONFIRM_COMMAND} if the recorded hosts are all of them`;
|
|
18
|
+
|
|
19
|
+
const ownerRepair = '; install into each host first: run codex-bridge install --host "<path>"';
|
|
20
|
+
|
|
21
|
+
// Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
|
|
22
|
+
export function ownerEntry(rawRecord, host) {
|
|
23
|
+
if (rawRecord === null || !isFormat2(rawRecord)) return null;
|
|
24
|
+
return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hostSideCheck(host, inspection) {
|
|
28
|
+
if (inspection.settingsError) {
|
|
29
|
+
return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
|
|
30
|
+
}
|
|
31
|
+
if (!hasPackageMarks(inspection)) {
|
|
32
|
+
return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const kept = inspection.files.filter((file) => file.disposition === 'keep'
|
|
36
|
+
&& ['changed', 'unknown'].includes(file.reason));
|
|
37
|
+
const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
|
|
38
|
+
const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
|
|
39
|
+
return kept.length
|
|
40
|
+
? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
|
|
41
|
+
: check('host files', 'ok', counts);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function homeImageExists(host) {
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(path.join(host.brandRoot, 'lib'));
|
|
47
|
+
return true;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return false;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function homeOwnersCheck(host, rawRecord, inspection) {
|
|
55
|
+
if (rawRecord === null) {
|
|
56
|
+
return await homeImageExists(host)
|
|
57
|
+
? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
|
|
58
|
+
: check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
|
|
59
|
+
}
|
|
60
|
+
if (!isFormat2(rawRecord)) {
|
|
61
|
+
return check(
|
|
62
|
+
'home owners',
|
|
63
|
+
'warn',
|
|
64
|
+
'old record format; the hosts using this home are unknown until the next install or update',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
|
|
69
|
+
const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
|
|
70
|
+
const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
|
|
71
|
+
const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
|
|
72
|
+
const parts = [
|
|
73
|
+
`recorded: ${roots.length ? roots.join(', ') : 'none'}`,
|
|
74
|
+
inventoryComplete ? 'inventory complete'
|
|
75
|
+
: `inventory incomplete: an old record did not name every host${roots.length ? inventoryRepair : ownerRepair}`,
|
|
76
|
+
...(unrecordedMarks
|
|
77
|
+
? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
|
|
78
|
+
: []),
|
|
79
|
+
];
|
|
80
|
+
return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ownersInSyncCheck(rawRecord) {
|
|
84
|
+
if (rawRecord === null || !isFormat2(rawRecord)) {
|
|
85
|
+
return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const owners = Object.values(rawRecord.owners);
|
|
89
|
+
const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
|
|
90
|
+
if (!owners.length) {
|
|
91
|
+
return check('owners in sync', 'warn',
|
|
92
|
+
`no recorded owners${inventoryComplete ? '' : `; inventory incomplete${ownerRepair}`}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fingerprint = imageFingerprint(rawRecord.image);
|
|
96
|
+
const parts = [];
|
|
97
|
+
const unstamped = [];
|
|
98
|
+
for (const owner of owners) {
|
|
99
|
+
if (owner.imageFingerprint != null) {
|
|
100
|
+
if (owner.imageFingerprint === fingerprint) continue;
|
|
101
|
+
} else if (owner.version === rawRecord.image.version) {
|
|
102
|
+
unstamped.push(owner.root);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// The same version on both sides is the clone case the stamp exists for; say so instead of "0.6.9, image 0.6.9".
|
|
106
|
+
const why = owner.version === rawRecord.image.version
|
|
107
|
+
? `verified against an earlier image of ${owner.version}`
|
|
108
|
+
: `recorded ${owner.version}, image ${rawRecord.image.version}`;
|
|
109
|
+
parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
|
|
110
|
+
}
|
|
111
|
+
if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
|
|
112
|
+
if (!inventoryComplete) parts.push(`inventory incomplete${inventoryRepair}`);
|
|
113
|
+
const warning = parts.length > 0;
|
|
114
|
+
if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
|
|
115
|
+
return check('owners in sync', warning ? 'warn' : 'ok',
|
|
116
|
+
`${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
|
|
117
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -19,15 +19,16 @@ import {
|
|
|
19
19
|
} from './doctor-installation.mjs';
|
|
20
20
|
import { hookChecks } from './doctor-hooks.mjs';
|
|
21
21
|
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
22
|
-
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
22
|
+
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
23
23
|
import { dispatcherModelChecks } from './dispatcher-model-check.mjs';
|
|
24
24
|
import { dispatcherContractStatus } from './dispatcher-contract.mjs';
|
|
25
25
|
import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
|
|
26
26
|
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
27
|
-
import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
|
|
27
|
+
import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
|
|
28
28
|
import { readDispatcherModel } from '../src/home/lib/dispatcher-model.mjs';
|
|
29
29
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
30
30
|
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
31
|
+
import { activeHostVersions } from './active-hosts.mjs';
|
|
31
32
|
import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
|
|
32
33
|
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
33
34
|
|
|
@@ -84,10 +85,12 @@ export async function diagnose({
|
|
|
84
85
|
launcherProbe,
|
|
85
86
|
currentPackage,
|
|
86
87
|
contractRecord,
|
|
87
|
-
handbackWitnessRecord,
|
|
88
|
+
handbackWitnessRecord,
|
|
88
89
|
dispatcherModelRecord,
|
|
89
90
|
dispatcherContractRecord,
|
|
90
91
|
hostVersion,
|
|
92
|
+
activeHosts: injectedActiveHosts,
|
|
93
|
+
now,
|
|
91
94
|
observations,
|
|
92
95
|
} = {}) {
|
|
93
96
|
const checks = [sourceCheck()];
|
|
@@ -150,6 +153,10 @@ export async function diagnose({
|
|
|
150
153
|
const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
|
|
151
154
|
const observedVersions = sessionHostVersions(hostObservations);
|
|
152
155
|
const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
|
|
156
|
+
const witnessRecord = handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord;
|
|
157
|
+
const modelRecord = dispatcherModelRecord === undefined ? readDispatcherModel({ stateDir }) : dispatcherModelRecord;
|
|
158
|
+
const activeHosts = injectedActiveHosts ?? (hostVersion !== undefined ? [hostVersion].filter(Boolean)
|
|
159
|
+
: activeHostVersions({ observations: hostObservations, witnessRecord, modelRecord, now }));
|
|
153
160
|
checks.push(sessionHostCheck(observedVersions, hostObservations));
|
|
154
161
|
const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
|
|
155
162
|
const hostContract = contractStatus({
|
|
@@ -161,15 +168,15 @@ export async function diagnose({
|
|
|
161
168
|
: hostContract.state === 'ignored' ? 'fail' : 'warn';
|
|
162
169
|
checks.push(check('hostContract', hostContractStatus, hostContract.message));
|
|
163
170
|
const witness = handbackWitnessStatus({
|
|
164
|
-
record:
|
|
165
|
-
|
|
171
|
+
record: witnessRecord,
|
|
172
|
+
activeHosts,
|
|
166
173
|
stateDir,
|
|
167
174
|
});
|
|
168
|
-
checks.push(check('handbackWitness', ['violation', 'unreadable'].includes(witness.state) ? 'warn' : 'ok', witness.message));
|
|
169
|
-
checks.push(...dispatcherModelChecks({
|
|
170
|
-
record:
|
|
171
|
-
|
|
172
|
-
stateDir,
|
|
175
|
+
checks.push(check('handbackWitness', ['violation', 'unreadable'].includes(witness.state) ? 'warn' : 'ok', witness.message));
|
|
176
|
+
checks.push(...dispatcherModelChecks({
|
|
177
|
+
record: modelRecord,
|
|
178
|
+
activeHosts,
|
|
179
|
+
stateDir,
|
|
173
180
|
}));
|
|
174
181
|
const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
|
|
175
182
|
checks.push(...dispatcherContractChecks({
|
|
@@ -1,37 +1,41 @@
|
|
|
1
|
-
/** Surfaces handback drift;
|
|
1
|
+
/** Surfaces handback drift; Plan_67 D12 judges every host with recent dispatcher activity. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
|
|
4
4
|
import { entryState } from '../src/home/lib/observation-ledger.mjs';
|
|
5
5
|
|
|
6
|
-
export function handbackWitnessStatus({ record,
|
|
6
|
+
export function handbackWitnessStatus({ record, activeHosts, stateDir }) {
|
|
7
7
|
if (record?.corrupt) {
|
|
8
8
|
return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
|
|
9
9
|
}
|
|
10
10
|
const entries = Object.values(record?.ledger?.entries || {});
|
|
11
|
+
let history = '';
|
|
12
|
+
const recovered = entries.filter((entry) => entryState(entry) === 'recovered').length;
|
|
13
|
+
const historical = entries.filter((entry) => entryState(entry) === 'violation'
|
|
14
|
+
&& !activeHosts.includes(entry.lastObservation.data.hostVersion)).length;
|
|
15
|
+
const legacy = record?.legacy || [];
|
|
16
|
+
if (recovered || historical || legacy.length) {
|
|
17
|
+
const untyped = legacy.filter((entry) => entry.disposition === 'legacy-untyped-unverified').length;
|
|
18
|
+
const unclassified = legacy.filter((entry) => entry.disposition === 'unclassified').length;
|
|
19
|
+
history = ` History: ${recovered} recovered entries; ${historical} unresolved entries on hosts without dispatcher activity in the last 24 hours (history); ${untyped} older alarms without dispatcher evidence, kept for history (legacy-untyped-unverified); ${unclassified} unclassified legacy entries.`;
|
|
20
|
+
}
|
|
11
21
|
const violations = entries.filter((entry) => entryState(entry) === 'violation'
|
|
12
|
-
&& entry.lastObservation.data.hostVersion
|
|
22
|
+
&& activeHosts.includes(entry.lastObservation.data.hostVersion));
|
|
13
23
|
if (violations.length > 0) {
|
|
14
24
|
const details = violations.map((entry) => {
|
|
15
25
|
const { data, detail, at } = entry.lastViolation;
|
|
16
|
-
return
|
|
26
|
+
return `host ${data.hostVersion}: ${data.cause}${data.agentType ? ` / ${data.agentType}` : ''}: ${detail} at ${at}`;
|
|
17
27
|
});
|
|
18
28
|
return {
|
|
19
29
|
state: 'violation',
|
|
20
|
-
message: `Dispatcher violation(s)
|
|
30
|
+
message: `Dispatcher violation(s): ${details.join('; ')}; do not trust a dispatcher answer from that time; see Plan_62 D15.${history}`,
|
|
21
31
|
};
|
|
22
32
|
}
|
|
23
|
-
const intercepted = record?.intercepted?.[
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (recovered || historical || legacy.length) {
|
|
32
|
-
const untyped = legacy.filter((entry) => entry.disposition === 'legacy-untyped-unverified').length;
|
|
33
|
-
const unclassified = legacy.filter((entry) => entry.disposition === 'unclassified').length;
|
|
34
|
-
message += ` History: ${recovered} recovered entries; ${historical} unresolved entries on other hosts (historical, not current); ${untyped} older alarms without dispatcher evidence, kept for history (legacy-untyped-unverified); ${unclassified} unclassified legacy entries.`;
|
|
35
|
-
}
|
|
36
|
-
return { state: 'ok', message };
|
|
33
|
+
const intercepted = activeHosts.filter((host) => record?.intercepted?.[host])
|
|
34
|
+
.map((host) => `host ${host} at ${record.intercepted[host]}`);
|
|
35
|
+
const message = !activeHosts.length
|
|
36
|
+
? 'No dispatcher activity in the last 24 hours.'
|
|
37
|
+
: intercepted.length
|
|
38
|
+
? `Newest intercepted handback on ${intercepted.join('; ')}.`
|
|
39
|
+
: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.';
|
|
40
|
+
return { state: 'ok', message: message + history };
|
|
37
41
|
}
|
package/cli/install-record.mjs
CHANGED
|
@@ -307,6 +307,11 @@ async function publishInstallRecord(host, next) {
|
|
|
307
307
|
await writer.rename('install-record', temporary, target);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/** Plan_67 D11 / A4: changing inventory must not rebuild owners or hide fingerprint lag. */
|
|
311
|
+
export function publishInstallRecordOnly(host, next) {
|
|
312
|
+
return publishInstallRecord(host, next);
|
|
313
|
+
}
|
|
314
|
+
|
|
310
315
|
/**
|
|
311
316
|
* Publishes the record whole through a temporary and a rename: a direct write left a half-written
|
|
312
317
|
* record for every host sharing the home if the process died mid-write (advice A3, risk r3).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Lets the operator declare a shared home's installation inventory complete, the one statement the package cannot prove.
|
|
2
|
+
* Plan_67 D11, OW-047: only the operator can account for unrecorded hosts using this home.
|
|
3
|
+
*/
|
|
4
|
+
import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
|
|
5
|
+
import { publishInstallRecordOnly, readInstallRecordFile } from './install-record.mjs';
|
|
6
|
+
import { validateFormat2 } from './install-owners.mjs';
|
|
7
|
+
import { registryHintLines } from './inventory-transition.mjs';
|
|
8
|
+
import { withLifecycle } from './lifecycle-transaction.mjs';
|
|
9
|
+
import { readRulesRegistry } from './rules-owners.mjs';
|
|
10
|
+
import { askYesNo, isInteractive } from './terminal-question.mjs';
|
|
11
|
+
|
|
12
|
+
export const INVENTORY_CONFIRM_COMMAND = 'codex-bridge inventory confirm';
|
|
13
|
+
|
|
14
|
+
const question = 'Are these all the hosts using this home?';
|
|
15
|
+
const updateHint = 'Run codex-bridge update to migrate the installation record.';
|
|
16
|
+
|
|
17
|
+
async function confirmInRun(host, dryRun, questionOptions) {
|
|
18
|
+
let record;
|
|
19
|
+
try {
|
|
20
|
+
record = await readInstallRecordFile(host);
|
|
21
|
+
if (!isFormat2(record)) {
|
|
22
|
+
return { exitCode: 1, output: `No format-2 installation record was found. ${updateHint}` };
|
|
23
|
+
}
|
|
24
|
+
validateFormat2(record);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return { exitCode: 1, output: `Invalid installation record: ${error.message} ${updateHint}` };
|
|
27
|
+
}
|
|
28
|
+
const owners = Object.values(record.owners);
|
|
29
|
+
if (owners.length === 0) {
|
|
30
|
+
return { exitCode: 1, output: 'no recorded owners; install into each host first' };
|
|
31
|
+
}
|
|
32
|
+
if (record.inventory === 'complete' && !Object.hasOwn(record, 'legacy')) {
|
|
33
|
+
return { exitCode: 0, output: 'The inventory is already complete.' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const screen = [
|
|
37
|
+
`Home: ${host.brandRoot}`,
|
|
38
|
+
'Recorded hosts:',
|
|
39
|
+
...owners.map((owner) => ` ${owner.root}`),
|
|
40
|
+
...registryHintLines(host, (await readRulesRegistry(host))?.owners ?? []),
|
|
41
|
+
"Confirming lets the last recorded host's uninstall remove the shared image. "
|
|
42
|
+
+ 'A host that uses this home but is not listed must be enrolled first: '
|
|
43
|
+
+ 'codex-bridge install --host "<path>".',
|
|
44
|
+
].join('\n');
|
|
45
|
+
if (dryRun) return { exitCode: 0, output: `${screen}\nDry run: nothing changed.` };
|
|
46
|
+
const prompt = `${screen}\n${question}`;
|
|
47
|
+
if (!isInteractive(questionOptions)) {
|
|
48
|
+
return { exitCode: 1, output: `${prompt}\nRun this command in a terminal to answer.` };
|
|
49
|
+
}
|
|
50
|
+
const answer = await askYesNo(prompt, questionOptions);
|
|
51
|
+
if (answer === 'cancel') {
|
|
52
|
+
return { exitCode: 130, output: `${prompt}\nCancelled; nothing was changed.` };
|
|
53
|
+
}
|
|
54
|
+
if (answer !== 'yes') {
|
|
55
|
+
return { exitCode: 1, output: `${prompt}\nNothing changed: the inventory stays incomplete.` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A4 pre-mortem: the raw fields are evidence of lag, not material to regenerate on confirmation.
|
|
59
|
+
const next = { ...record, inventory: 'complete' };
|
|
60
|
+
delete next.legacy;
|
|
61
|
+
await publishInstallRecordOnly(host, next);
|
|
62
|
+
return { exitCode: 0, output: `${prompt}\nRecorded the inventory as complete.` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function inventoryConfirm({ host, dryRun = false, ...questionOptions }) {
|
|
66
|
+
const action = (ticket) => {
|
|
67
|
+
// Plan_67 R2: a first install may create the home after the unlocked missing-home check.
|
|
68
|
+
if (!dryRun && ticket === undefined) {
|
|
69
|
+
return { exitCode: 1, output: `No format-2 installation record was found. ${updateHint}` };
|
|
70
|
+
}
|
|
71
|
+
return confirmInRun(host, dryRun, questionOptions);
|
|
72
|
+
};
|
|
73
|
+
// Plan_67 D11: dry-run only reads; a real declaration holds the lifecycle lock before reading.
|
|
74
|
+
return dryRun ? action() : withLifecycle(host, 'inventory', action);
|
|
75
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* store nothing about a refusal.
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { askYesNo, isInteractive } from './terminal-question.mjs';
|
|
8
9
|
import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
|
|
@@ -36,7 +37,8 @@ export function registryHintLines(host, candidates) {
|
|
|
36
37
|
const otherCandidates = candidates.filter((root) => root !== owner);
|
|
37
38
|
return [
|
|
38
39
|
'Codex rules registry host roots are a hint only; they may be stale or belong to another home:',
|
|
39
|
-
...(otherCandidates.length ? otherCandidates.map((root) =>
|
|
40
|
+
...(otherCandidates.length ? otherCandidates.map((root) =>
|
|
41
|
+
` ${root}${existsSync(root) ? '' : ' (folder does not exist)'}`) : [' none found']),
|
|
40
42
|
];
|
|
41
43
|
}
|
|
42
44
|
|
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.12",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 7 guards, 3267 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -1,53 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reads which host roots own this home from the installation record, read-only, for hooks.
|
|
3
|
-
* Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
|
|
4
|
-
*/
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { homeArtifact } from './home-registry.mjs';
|
|
8
|
-
import { parseJsonText } from './json-file.mjs';
|
|
9
|
-
|
|
10
|
-
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
-
|
|
12
|
-
export function isFormat2(parsed) {
|
|
13
|
-
return isObject(parsed) && parsed.format === 2;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function readOwnerRoots({ brandRoot }) {
|
|
17
|
-
const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
|
|
18
|
-
let raw;
|
|
19
|
-
try {
|
|
20
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
21
|
-
} catch (error) {
|
|
22
|
-
return error.code === 'ENOENT'
|
|
23
|
-
? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
|
|
24
|
-
: { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
let parsed;
|
|
28
|
-
try {
|
|
29
|
-
parsed = parseJsonText(file, raw);
|
|
30
|
-
} catch {
|
|
31
|
-
return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
|
|
32
|
-
}
|
|
33
|
-
if (!isFormat2(parsed)) {
|
|
34
|
-
return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reads which host roots own this home from the installation record, read-only, for hooks.
|
|
3
|
+
* Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { homeArtifact } from './home-registry.mjs';
|
|
8
|
+
import { parseJsonText } from './json-file.mjs';
|
|
9
|
+
|
|
10
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
|
|
12
|
+
export function isFormat2(parsed) {
|
|
13
|
+
return isObject(parsed) && parsed.format === 2;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readOwnerRoots({ brandRoot }) {
|
|
17
|
+
const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return error.code === 'ENOENT'
|
|
23
|
+
? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
|
|
24
|
+
: { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseJsonText(file, raw);
|
|
30
|
+
} catch {
|
|
31
|
+
return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
|
|
32
|
+
}
|
|
33
|
+
if (!isFormat2(parsed)) {
|
|
34
|
+
return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
|
|
35
|
+
}
|
|
36
|
+
// Plan_67 R2: confirmation refuses ownerless records; enrollment must come first.
|
|
37
|
+
if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
|
|
38
|
+
return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
|
|
39
|
+
}
|
|
40
|
+
if (parsed.inventory !== 'complete') {
|
|
41
|
+
// Plan_67 D11: match cli/inventory-confirm.mjs INVENTORY_CONFIRM_COMMAND; hooks cannot import cli/.
|
|
42
|
+
return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory; if the recorded hosts are all the hosts using this home, run codex-bridge inventory confirm.` };
|
|
43
|
+
}
|
|
44
|
+
if (Object.hasOwn(parsed, 'legacy')) {
|
|
45
|
+
return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
|
|
46
|
+
}
|
|
47
|
+
const roots = [];
|
|
48
|
+
for (const owner of Object.values(parsed.owners)) {
|
|
49
|
+
if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
|
|
50
|
+
return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
|
|
51
|
+
}
|
|
52
|
+
roots.push(owner.root);
|
|
53
|
+
}
|
|
54
|
+
return { roots: roots.sort() };
|
|
55
|
+
}
|