@lyupro/codex-bridge 0.6.7 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/README.md +10 -6
- package/bin/codex-bridge.mjs +35 -172
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +21 -11
- package/cli/dispatcher-contract.mjs +6 -5
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +35 -7
- package/cli/handback-witness-check.mjs +10 -18
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +36 -21
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +101 -77
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/probe-contract.mjs +26 -10
- package/cli/probe-target.mjs +33 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/session-hosts.mjs +51 -0
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -126
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +33 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/dispatcher-gate.mjs +15 -2
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +24 -9
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +33 -25
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +60 -0
- package/src/home/lib/host-version.mjs +27 -0
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one install, update or uninstall of a shared package home as a single transaction under
|
|
3
|
+
* the home's lifecycle lock (Plan_65 D8).
|
|
4
|
+
*
|
|
5
|
+
* Two installs into one home used to interleave their image copies and their record writes, and
|
|
6
|
+
* uninstall removed permission rules from settings.json before it had read anything — so a
|
|
7
|
+
* refused uninstall would already have changed the host. The lock is therefore taken before the
|
|
8
|
+
* first read, outside the settings backup scope (backup bookkeeping is not exclusion). `update`
|
|
9
|
+
* nests `install`, and a second acquire inside one process is refused by design (the lock's own
|
|
10
|
+
* self-check), so the outer transaction hands the nested call a ticket instead: live only while
|
|
11
|
+
* the transaction runs, and bound to its home.
|
|
12
|
+
*
|
|
13
|
+
* Only install may bring a home into existence: the lock needs the directory, and an update or an
|
|
14
|
+
* uninstall that created an empty home folder just to lock it would leave debris behind a removal.
|
|
15
|
+
* A home that does not exist has nothing shared to protect, so those run without the lock.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { acquireLifecycleLock } from './lifecycle-lock.mjs';
|
|
20
|
+
|
|
21
|
+
const liveTickets = new WeakMap();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Throws unless `ticket` belongs to a transaction still running on this host's home. The purge authorization
|
|
25
|
+
* (Plan_65 D14) binds itself to the ticket through this check, so the two can never disagree about "live".
|
|
26
|
+
*/
|
|
27
|
+
export function assertLiveTicket(ticket, host) {
|
|
28
|
+
if (!ticket || typeof ticket !== 'object' || !liveTickets.has(ticket)) {
|
|
29
|
+
throw new Error('lifecycle ticket is no longer active');
|
|
30
|
+
}
|
|
31
|
+
if (typeof host?.brandRoot !== 'string' || path.resolve(host.brandRoot) !== liveTickets.get(ticket)) {
|
|
32
|
+
throw new Error('lifecycle ticket belongs to a different home');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function withLifecycle(host, command, action, { ticket, waitMs, createHome = false } = {}) {
|
|
37
|
+
if (typeof action !== 'function') throw new TypeError('lifecycle action must be a function');
|
|
38
|
+
|
|
39
|
+
if (ticket !== undefined) {
|
|
40
|
+
assertLiveTicket(ticket, host);
|
|
41
|
+
return action(ticket);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!createHome && !fs.existsSync(host.brandRoot)) return action(undefined);
|
|
45
|
+
|
|
46
|
+
const lockOptions = { command, hostRoot: host.root };
|
|
47
|
+
if (waitMs !== undefined) lockOptions.waitMs = waitMs;
|
|
48
|
+
const lock = await acquireLifecycleLock(host.brandRoot, lockOptions);
|
|
49
|
+
const transactionTicket = Object.freeze({});
|
|
50
|
+
liveTickets.set(transactionTicket, path.resolve(host.brandRoot));
|
|
51
|
+
try {
|
|
52
|
+
return await action(transactionTicket);
|
|
53
|
+
} finally {
|
|
54
|
+
liveTickets.delete(transactionTicket);
|
|
55
|
+
await lock.release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Says whether a delete target is reached only through real directories below a trusted root.
|
|
3
|
+
*
|
|
4
|
+
* Lexical containment alone is satisfiable: independent review replaced `<root>/<project>` with a
|
|
5
|
+
* junction to an outside directory, and a target that still spelled out as inside the root deleted
|
|
6
|
+
* what was outside it. Paths are deliberately never resolved (`realpath` returns `\\?\` and UNC
|
|
7
|
+
* forms on Windows and creates a new class of mismatch), so every segment is checked with lstat,
|
|
8
|
+
* top-down, right before deletion. Plan_65 D4 made this one module for prune and the coming purge
|
|
9
|
+
* after prune's own copy was found wrong twice: it walked from the leaf up, touching paths under a
|
|
10
|
+
* link before reaching the link, and it read every lstat error as a clear path. Only ENOENT means
|
|
11
|
+
* gone here; any other error is a finding, and the caller keeps the entry. The root itself is taken
|
|
12
|
+
* as written, because relocating the runs root or the home through a link is legitimate.
|
|
13
|
+
* Containment compares through normalizePath, which folds Windows case: a root spelled `C:\Users`
|
|
14
|
+
* against a target resolved as `c:\users` must not read as "outside".
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { normalizePath } from '../src/home/hooks/live-runs.mjs';
|
|
19
|
+
|
|
20
|
+
export function inspectSegments(target, root, { lstat = (file) => fs.lstatSync(file) } = {}) {
|
|
21
|
+
const normalizedRoot = normalizePath(root);
|
|
22
|
+
const normalizedTarget = normalizePath(target);
|
|
23
|
+
if (!normalizedRoot || !normalizedTarget) return { kind: 'outside' };
|
|
24
|
+
|
|
25
|
+
const rootPrefix = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
|
|
26
|
+
if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(rootPrefix)) {
|
|
27
|
+
return { kind: 'outside' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rootPath = path.resolve(root);
|
|
31
|
+
const relativeTarget = path.relative(rootPath, path.resolve(target));
|
|
32
|
+
let current = rootPath;
|
|
33
|
+
for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {
|
|
34
|
+
current = path.join(current, segment);
|
|
35
|
+
let entry;
|
|
36
|
+
try {
|
|
37
|
+
entry = lstat(current);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT') return { kind: 'missing', at: current };
|
|
40
|
+
return { kind: 'error', at: current, code: error.code };
|
|
41
|
+
}
|
|
42
|
+
if (entry.isSymbolicLink()) return { kind: 'link', at: current };
|
|
43
|
+
}
|
|
44
|
+
return { kind: 'clear' };
|
|
45
|
+
}
|
package/cli/model-set.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
|
|
|
5
5
|
import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
|
|
6
6
|
import { editRunConfig } from '../src/home/lib/config-edit.mjs';
|
|
7
7
|
import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
|
|
8
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
8
9
|
|
|
9
10
|
const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
|
|
10
11
|
const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
@@ -17,14 +18,14 @@ function parseChanges(args) {
|
|
|
17
18
|
let value = argument;
|
|
18
19
|
if (argument.startsWith('-')) {
|
|
19
20
|
if (argument !== '--model' && argument !== '--effort') {
|
|
20
|
-
throw new Error(`unexpected argument "${argument}".`);
|
|
21
|
+
throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
|
|
21
22
|
}
|
|
22
23
|
field = argument.slice(2);
|
|
23
24
|
value = args[++index];
|
|
24
25
|
if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${argument}.`);
|
|
25
26
|
} else {
|
|
26
27
|
field = ['model', 'effort'].find((key) => changes[key] === undefined);
|
|
27
|
-
if (!field) throw new Error(`unexpected argument "${argument}".`);
|
|
28
|
+
if (!field) throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
|
|
28
29
|
}
|
|
29
30
|
if (changes[field] !== undefined) throw new Error(`${field} was supplied more than once.`);
|
|
30
31
|
if (!value || /\s/.test(value)) {
|
|
@@ -65,7 +66,7 @@ export async function editModelProfile(action, argv, options) {
|
|
|
65
66
|
}
|
|
66
67
|
let changes;
|
|
67
68
|
try {
|
|
68
|
-
if (action === 'unset' && args.length) throw new Error(`unexpected argument "${args[0]}".`);
|
|
69
|
+
if (action === 'unset' && args.length) throw new Error(withCommandHelp('model unset', `unexpected argument "${args[0]}".`));
|
|
69
70
|
if (action === 'set') changes = parseChanges(args);
|
|
70
71
|
} catch (error) {
|
|
71
72
|
return failure(2, error.message);
|
package/cli/model-speed.mjs
CHANGED
|
@@ -5,12 +5,19 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
|
|
|
5
5
|
import { editRunConfig } from '../src/home/lib/config-edit.mjs';
|
|
6
6
|
import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
|
|
7
7
|
import { describe, readProfiles } from './model-set.mjs';
|
|
8
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
8
9
|
|
|
9
10
|
const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
|
|
10
11
|
const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
12
|
+
const unexpected = (argument) => {
|
|
13
|
+
const result = failure(2, `unexpected argument "${argument}".`);
|
|
14
|
+
return { ...result, output: withCommandHelp('model speed', result.output) };
|
|
15
|
+
};
|
|
11
16
|
|
|
12
17
|
export async function editModelSpeed(argv, options) {
|
|
13
18
|
const [role, speed, confirmation, ...extra] = argv;
|
|
19
|
+
const dashArgument = argv.find((arg) => typeof arg === 'string' && arg.startsWith('-'));
|
|
20
|
+
if (dashArgument !== undefined) return unexpected(dashArgument);
|
|
14
21
|
if (!ROLES.includes(role)) {
|
|
15
22
|
return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
|
|
16
23
|
}
|
|
@@ -18,9 +25,9 @@ export async function editModelSpeed(argv, options) {
|
|
|
18
25
|
return failure(2, 'speed requires a tier identifier: a non-empty single word with no whitespace, or unset.');
|
|
19
26
|
}
|
|
20
27
|
if (confirmation !== undefined && (speed === 'unset' || confirmation !== 'confirm')) {
|
|
21
|
-
return
|
|
28
|
+
return unexpected(confirmation);
|
|
22
29
|
}
|
|
23
|
-
if (extra.length) return
|
|
30
|
+
if (extra.length) return unexpected(extra[0]);
|
|
24
31
|
|
|
25
32
|
try {
|
|
26
33
|
const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
|
package/cli/model.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { editModelProfile } from './model-set.mjs';
|
|
|
8
8
|
import { editModelSpeed } from './model-speed.mjs';
|
|
9
9
|
import { readCodexUserTier } from './codex-user-config.mjs';
|
|
10
10
|
import { renderTable } from './table.mjs';
|
|
11
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
11
12
|
|
|
12
13
|
const PROFILE_COLUMNS = ['role', 'model', 'effort', 'speed', 'source']
|
|
13
14
|
.map((key) => ({ key, header: key, fixed: true }));
|
|
@@ -43,10 +44,10 @@ export async function model(argv = [], options = {}) {
|
|
|
43
44
|
}
|
|
44
45
|
if (action === 'speed') return editModelSpeed(optionArgs, options);
|
|
45
46
|
if (action && action !== 'list') {
|
|
46
|
-
return { exitCode: 2, output: `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.` };
|
|
47
|
+
return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.`) };
|
|
47
48
|
}
|
|
48
49
|
if (optionArgs.length) {
|
|
49
|
-
return { exitCode: 2, output: `codex-bridge model: unexpected argument "${optionArgs[0]}".` };
|
|
50
|
+
return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unexpected argument "${optionArgs[0]}".`) };
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
try {
|
|
@@ -75,7 +76,8 @@ export async function model(argv = [], options = {}) {
|
|
|
75
76
|
return {
|
|
76
77
|
exitCode: 0,
|
|
77
78
|
output: `${renderTable(PROFILE_COLUMNS, rows, options.terminalWidth)}\n\nConfig file: ${configPath}\n`
|
|
78
|
-
+ 'Machine-wide: shared by every project on this machine, not per-project
|
|
79
|
+
+ 'Machine-wide: shared by every project on this machine, not per-project.\n'
|
|
80
|
+
+ 'Set: codex-bridge model set <role> <model> [effort] · catalogue: codex-bridge model list',
|
|
79
81
|
};
|
|
80
82
|
} catch (error) {
|
|
81
83
|
const subject = action === 'list' ? 'live catalogue unavailable; refusing to list models' : 'cannot read profile';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds one owner's installation-record row from what is on disk after its files and hooks were written.
|
|
3
|
+
* Install and the owner sync (Plan_65 D7) both advance a row; two copies of this construction would let
|
|
4
|
+
* a synced owner carry fingerprints or `createdGroup` flags that its own install would never write.
|
|
5
|
+
*/
|
|
6
|
+
import { fileFingerprint } from './manifest.mjs';
|
|
7
|
+
import { INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
|
|
8
|
+
|
|
9
|
+
export async function ownerRecord({ plan, rule, targets, hookResults, prior, currentPackage }) {
|
|
10
|
+
const fingerprints = {};
|
|
11
|
+
for (const item of plan) {
|
|
12
|
+
fingerprints[item.root] ??= {};
|
|
13
|
+
fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
|
|
14
|
+
}
|
|
15
|
+
const hooks = targets.map(({ definition, relative, registration }, index) => {
|
|
16
|
+
const recorded = prior?.hooks?.find((hook) => hook.event === definition.event
|
|
17
|
+
&& hook.root === 'brand' && hook.path === relative);
|
|
18
|
+
// A group we created stays ours after a later run merely joins it; losing the flag would make
|
|
19
|
+
// uninstall leave behind an empty group that only this package ever put there.
|
|
20
|
+
const createdGroup = hookResults[index].createdGroup || recorded?.createdGroup === true;
|
|
21
|
+
const command = registration.command;
|
|
22
|
+
return {
|
|
23
|
+
event: definition.event,
|
|
24
|
+
root: 'brand',
|
|
25
|
+
path: relative,
|
|
26
|
+
command,
|
|
27
|
+
form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
|
|
28
|
+
...(createdGroup ? { createdGroup: true } : {}),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
...currentPackage,
|
|
33
|
+
installedAt: new Date().toISOString(),
|
|
34
|
+
[INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
|
|
35
|
+
files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
|
|
36
|
+
fingerprints,
|
|
37
|
+
rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
|
|
38
|
+
hooks,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies other owners' host updates under the initiator's lifecycle lock (Plan_65 D7, advice A6).
|
|
3
|
+
* Replacing the shared image must bring eligible owners' files and hooks along, without extending
|
|
4
|
+
* the initiator's force to their edits, permissions, seeds, or Codex rules registry. An owner row
|
|
5
|
+
* advances only after its whole host update verifies; failures must not stop the remaining owners.
|
|
6
|
+
* In-sync rows are stamped with the image fingerprint too (D11): clones of one version ship different
|
|
7
|
+
* images (A6), so only the stamp tells doctor which image a host was last verified against.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
+
import { buildInstallPlan, packageInfo, rulesPlan } from './manifest.mjs';
|
|
12
|
+
import { copyPlannedFile, planHomeWriter } from './copy.mjs';
|
|
13
|
+
import { readInstallRecordFile, writeInstallRecord } from './install-record.mjs';
|
|
14
|
+
import { imageFingerprint, ownerView } from './install-owners.mjs';
|
|
15
|
+
import { ownerRecord } from './owner-record.mjs';
|
|
16
|
+
import { ownerHost, ownerSyncLines, planOwner, planOwnerSync } from './owner-sync.mjs';
|
|
17
|
+
import { mergeHook } from './settings-merge.mjs';
|
|
18
|
+
|
|
19
|
+
async function applyOwner(owner, initiator, { packageRoot, env }) {
|
|
20
|
+
const host = ownerHost(owner, initiator);
|
|
21
|
+
// D7: a root lost after planning must not be recreated by the copy or settings writers.
|
|
22
|
+
if (!(await fs.stat(host.root)).isDirectory()) throw new Error('owner root is not a directory');
|
|
23
|
+
const record = await readInstallRecordFile(host);
|
|
24
|
+
const prior = ownerView(record, host);
|
|
25
|
+
if (!prior) throw new Error('owner is not recorded');
|
|
26
|
+
const plan = await buildInstallPlan(host, packageRoot);
|
|
27
|
+
const { writer } = planHomeWriter(host.brandRoot, plan);
|
|
28
|
+
let files = 0;
|
|
29
|
+
for (const { item, state } of owner.files) {
|
|
30
|
+
if (state !== 'replace' && state !== 'create') continue;
|
|
31
|
+
await copyPlannedFile(item, host.brandRoot, { writer });
|
|
32
|
+
files += 1;
|
|
33
|
+
}
|
|
34
|
+
const hookResults = [];
|
|
35
|
+
let hooks = 0;
|
|
36
|
+
for (const { item, state } of owner.hooks) {
|
|
37
|
+
if (state === 'register') {
|
|
38
|
+
hookResults.push(await mergeHook(host.settingsPath, item.spec));
|
|
39
|
+
hooks += 1;
|
|
40
|
+
} else {
|
|
41
|
+
hookResults.push({ createdGroup: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const verified = await planOwner(record, record.owners[normalizeRepoPath(host.root)], initiator,
|
|
45
|
+
{ packageRoot, env });
|
|
46
|
+
// A bare status word made the operator line read "Left <root> behind: conflict", naming no cause.
|
|
47
|
+
if (verified.status !== 'in-sync') throw new Error(`verification after the update found ${verified.status}`);
|
|
48
|
+
// D7: resolving an explicit root must not relabel a recorded user/project owner as host.
|
|
49
|
+
await writeInstallRecord({ ...host, scope: owner.scope }, await ownerRecord({
|
|
50
|
+
plan,
|
|
51
|
+
rule: rulesPlan(host, packageRoot),
|
|
52
|
+
targets: owner.hooks.map(({ item }) => item),
|
|
53
|
+
hookResults,
|
|
54
|
+
prior,
|
|
55
|
+
currentPackage: await packageInfo(packageRoot),
|
|
56
|
+
}), {});
|
|
57
|
+
return { root: owner.root, status: 'updated', files, hooks };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function applyOwnerSync(plan, initiator, { packageRoot, env } = {}) {
|
|
61
|
+
const record = await readInstallRecordFile(initiator);
|
|
62
|
+
const fingerprint = imageFingerprint(record.image);
|
|
63
|
+
const owners = [];
|
|
64
|
+
for (const owner of plan.owners) {
|
|
65
|
+
if (owner.status === 'in-sync'
|
|
66
|
+
// An image without fingerprints yields null; a row can then never carry a stamp to match.
|
|
67
|
+
&& (record.owners[normalizeRepoPath(owner.root)]?.imageFingerprint ?? null) === fingerprint) {
|
|
68
|
+
owners.push({ root: owner.root, status: 'in-sync' });
|
|
69
|
+
} else if (owner.status !== 'eligible' && owner.status !== 'in-sync') {
|
|
70
|
+
owners.push(owner);
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
const applied = await applyOwner(owner, initiator, { packageRoot, env });
|
|
74
|
+
owners.push(owner.status === 'in-sync' && applied.files === 0 && applied.hooks === 0
|
|
75
|
+
? { root: owner.root, status: 'in-sync' } : applied);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
owners.push({ root: owner.root, status: 'failed', reason: error.message });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { owners, complete: owners.every(({ status }) => status === 'in-sync' || status === 'updated') };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One entrance for every command that replaced the image: install and update both call it, so neither
|
|
86
|
+
* can sync the other owners differently. A dry run only plans; it never counts as incomplete.
|
|
87
|
+
*/
|
|
88
|
+
export async function syncOtherOwners(initiator, { packageRoot, env, dryRun = false } = {}) {
|
|
89
|
+
const record = await readInstallRecordFile(initiator);
|
|
90
|
+
if (record?.format !== 2) return { complete: true, lines: [] };
|
|
91
|
+
const plan = await planOwnerSync(record, initiator, { packageRoot, env });
|
|
92
|
+
if (dryRun) return { complete: true, lines: ownerSyncLines(plan) };
|
|
93
|
+
// Plan_65 D7: the initiator's force never authorizes another owner's edits.
|
|
94
|
+
const result = await applyOwnerSync(plan, initiator, { packageRoot, env });
|
|
95
|
+
return { complete: result.complete, lines: ownerApplyLines(result) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function ownerApplyLines(result) {
|
|
99
|
+
return result.owners.flatMap((owner) => {
|
|
100
|
+
if (owner.status === 'updated') {
|
|
101
|
+
return [`Updated ${owner.files} file(s) and ${owner.hooks} hook(s) of ${owner.root}.`];
|
|
102
|
+
}
|
|
103
|
+
if (owner.status === 'failed') {
|
|
104
|
+
return [`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}".`];
|
|
105
|
+
}
|
|
106
|
+
return ownerSyncLines({ owners: [owner] });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plans other owners' host updates without writes or locks (Plan_65 D7, advice A6).
|
|
3
|
+
* Replacing the shared image leaves their agents, commands, and guards behind. Only an owner's
|
|
4
|
+
* recorded, unedited files may catch up; the initiator's force never authorizes another owner's
|
|
5
|
+
* conflicts, permissions, seeds, or unrelated settings. B13e2 applies and verifies this plan.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
10
|
+
import { resolveHost } from './hosts.mjs';
|
|
11
|
+
import { buildInstallPlan, fileFingerprint } from './manifest.mjs';
|
|
12
|
+
import { targetMatches } from './copy.mjs';
|
|
13
|
+
import { fingerprintFor, recordFileKey } from './install-record.mjs';
|
|
14
|
+
import { ownerView, validateFormat2 } from './install-owners.mjs';
|
|
15
|
+
import { hookTargets } from './hook-targets.mjs';
|
|
16
|
+
import { inspectHook } from './settings-merge.mjs';
|
|
17
|
+
|
|
18
|
+
async function fileState(item, host, view) {
|
|
19
|
+
let target;
|
|
20
|
+
try {
|
|
21
|
+
target = await fs.lstat(item.target);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code !== 'ENOENT') throw error;
|
|
24
|
+
}
|
|
25
|
+
// D7: lstat must precede content reads, including for dangling links and Windows junctions.
|
|
26
|
+
if (target?.isSymbolicLink()) return { item, state: 'conflict', reason: 'link' };
|
|
27
|
+
if (!target) {
|
|
28
|
+
const recorded = view.files.some((file) => recordFileKey(file) === recordFileKey(item));
|
|
29
|
+
return recorded
|
|
30
|
+
? { item, state: 'conflict', reason: 'missing' }
|
|
31
|
+
: { item, state: 'create' };
|
|
32
|
+
}
|
|
33
|
+
if (target.isFile()) {
|
|
34
|
+
if (await targetMatches(item, host.brandRoot)) return { item, state: 'current' };
|
|
35
|
+
const recorded = fingerprintFor(view, item);
|
|
36
|
+
if (recorded !== undefined && await fileFingerprint(item.target) === recorded) {
|
|
37
|
+
return { item, state: 'replace' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { item, state: 'conflict', reason: 'changed' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The owner's host seen from the initiator: same home and Codex home, the owner's own root and scope. */
|
|
44
|
+
export function ownerHost(owner, initiator) {
|
|
45
|
+
return resolveHost({
|
|
46
|
+
host: owner.root,
|
|
47
|
+
scope: owner.scope,
|
|
48
|
+
brandRoot: initiator.brandRoot,
|
|
49
|
+
// A resolved host has no codexHome field; it keeps the Codex home only as `<home>/rules`.
|
|
50
|
+
codexHome: path.dirname(initiator.codexRulesDir),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function planOwner(record2, owner, initiator, { packageRoot, env }) {
|
|
55
|
+
const entry = { root: owner.root, scope: owner.scope, version: owner.version, files: [], hooks: [] };
|
|
56
|
+
const host = ownerHost(owner, initiator);
|
|
57
|
+
try {
|
|
58
|
+
if (!(await fs.stat(host.root)).isDirectory()) {
|
|
59
|
+
return { ...entry, status: 'unreachable', reason: 'ENOTDIR' };
|
|
60
|
+
}
|
|
61
|
+
await fs.readdir(host.root);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ...entry, status: 'unreachable', reason: error.code === 'ENOENT' ? 'absent' : error.code };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const view = ownerView(record2, host);
|
|
67
|
+
const items = (await buildInstallPlan(host, packageRoot)).filter((item) => item.root === 'claude');
|
|
68
|
+
try {
|
|
69
|
+
for (const item of items) entry.files.push(await fileState(item, host, view));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ...entry, status: 'unreachable', reason: error.code ?? error.message };
|
|
72
|
+
}
|
|
73
|
+
const targets = hookTargets(host, env);
|
|
74
|
+
try {
|
|
75
|
+
for (const item of targets) {
|
|
76
|
+
const inspected = await inspectHook(host.settingsPath, item.spec);
|
|
77
|
+
entry.hooks.push({ item, state: inspected.current ? 'current' : 'register' });
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { ...entry, status: 'unreachable', reason: `settings unreadable: ${error.message}` };
|
|
81
|
+
}
|
|
82
|
+
const conflict = entry.files.some(({ state }) => state === 'conflict');
|
|
83
|
+
const pending = entry.files.some(({ state }) => state === 'replace' || state === 'create')
|
|
84
|
+
|| entry.hooks.some(({ state }) => state === 'register');
|
|
85
|
+
return { ...entry, status: conflict ? 'conflict' : pending ? 'eligible' : 'in-sync' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function planOwnerSync(record2, initiator, { packageRoot, env } = {}) {
|
|
89
|
+
validateFormat2(record2);
|
|
90
|
+
const key = normalizeRepoPath(initiator.root);
|
|
91
|
+
const others = Object.values(record2.owners)
|
|
92
|
+
.filter((owner) => normalizeRepoPath(owner.root) !== key)
|
|
93
|
+
.sort((a, b) => a.root.localeCompare(b.root));
|
|
94
|
+
const owners = [];
|
|
95
|
+
for (const owner of others) owners.push(await planOwner(record2, owner, initiator, { packageRoot, env }));
|
|
96
|
+
return { owners };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ownerSyncLines(plan) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
for (const owner of plan.owners) {
|
|
102
|
+
if (owner.status === 'eligible') {
|
|
103
|
+
const files = owner.files.filter(({ state }) => state === 'replace' || state === 'create').length;
|
|
104
|
+
const hooks = owner.hooks.filter(({ state }) => state === 'register').length;
|
|
105
|
+
lines.push(`Would update ${files} file(s) and ${hooks} hook(s) of ${owner.root}.`);
|
|
106
|
+
} else if (owner.status === 'conflict') {
|
|
107
|
+
const conflicts = owner.files.filter(({ state }) => state === 'conflict')
|
|
108
|
+
.map(({ item, reason }) => `${item.relativeToRoot} (${reason})`).join(', ');
|
|
109
|
+
lines.push(`Left ${owner.root} behind: ${conflicts}; run codex-bridge update --host "${owner.root}" after resolving it.`);
|
|
110
|
+
} else if (owner.status === 'unreachable') {
|
|
111
|
+
lines.push(`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}" when it is available.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
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
|
+
}
|
package/cli/projects.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Implements the projects command. */
|
|
2
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
2
3
|
import { listProjectRuns, listProjects } from './runs-inventory.mjs';
|
|
3
4
|
import { renderTable } from './table.mjs';
|
|
4
5
|
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
@@ -69,11 +70,13 @@ function parseArgs(args) {
|
|
|
69
70
|
json = true;
|
|
70
71
|
continue;
|
|
71
72
|
}
|
|
72
|
-
if (arg.startsWith('-'))
|
|
73
|
+
if (arg.startsWith('-')) {
|
|
74
|
+
return { error: withCommandHelp('projects', `codex-bridge projects: unknown option "${arg}"`) };
|
|
75
|
+
}
|
|
73
76
|
positional.push(arg);
|
|
74
77
|
}
|
|
75
78
|
if (positional.length > 1) {
|
|
76
|
-
return { error: 'codex-bridge projects accepts at most one project name.' };
|
|
79
|
+
return { error: withCommandHelp('projects', 'codex-bridge projects accepts at most one project name.') };
|
|
77
80
|
}
|
|
78
81
|
return { json, projectName: positional[0] || null };
|
|
79
82
|
}
|