@lyupro/codex-bridge 0.6.8 → 0.6.10
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 +160 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +92 -0
- package/cli/lifecycle-lock.mjs +222 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +22 -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/settings-merge.mjs +3 -29
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +59 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +23 -10
- package/src/agents/codex-build.md +24 -14
- package/src/agents/codex-review.md +17 -10
- package/src/agents/codex-scout.md +22 -12
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +27 -19
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/reply-parser.mjs +1 -1
- 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/command-path.mjs +32 -0
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-command.mjs +4 -2
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/kernel-lock.mjs +171 -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/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +16 -3
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/reply.mjs +37 -3
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +30 -24
- package/src/home/lib/required-inputs.mjs +48 -8
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +16 -5
- package/src/home/lib/runner/attach.mjs +21 -14
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +60 -8
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +37 -66
- package/src/home/lib/runner/launcher.mjs +233 -273
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-owner.mjs +18 -0
- package/src/home/lib/runner/pass-gate.mjs +109 -0
- package/src/home/lib/runner/preflight.mjs +22 -18
- package/src/home/lib/runner/prompts.mjs +42 -4
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +55 -28
- package/src/home/lib/runner/task-input.mjs +11 -2
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/task-header.mjs +127 -0
- package/src/home/lib/write-meta.mjs +17 -5
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps one installation record for a package home that several hosts share: one image part, and
|
|
3
|
+
* one owner per host root (format 2, Plan_65 D5).
|
|
4
|
+
*
|
|
5
|
+
* The format-1 record was written whole by whichever host installed last and named no host at all:
|
|
6
|
+
* `install --host A`, then `install --host B` into one home left B's record alone (reproduced
|
|
7
|
+
* 2026-09-25), and uninstalling either would have removed the image under the other. Owners are
|
|
8
|
+
* keyed by the lexically normalized host root, never a resolved one. An old record migrates with
|
|
9
|
+
* only the migrating host as owner and the inventory marked incomplete (D6): it never recorded who
|
|
10
|
+
* else used the home, and the last writer is not proof of being the only one.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
14
|
+
import {
|
|
15
|
+
fileEntry,
|
|
16
|
+
INSTALL_METHOD_COPY,
|
|
17
|
+
INSTALL_METHOD_KEY,
|
|
18
|
+
validateInstallRecord,
|
|
19
|
+
} from './install-record.mjs';
|
|
20
|
+
|
|
21
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
|
|
23
|
+
export function isFormat2(parsed) {
|
|
24
|
+
return isObject(parsed) && parsed.format === 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function imageFingerprint(image) {
|
|
28
|
+
const brand = image.fingerprints?.brand;
|
|
29
|
+
if (brand === undefined) return null;
|
|
30
|
+
const canonical = Object.keys(brand).sort()
|
|
31
|
+
.map((path) => `${path}\0${brand[path]}\n`).join('');
|
|
32
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ownImageStampCurrent(rawRecord, host) {
|
|
36
|
+
if (!isFormat2(rawRecord)) return false;
|
|
37
|
+
const owner = rawRecord.owners?.[normalizeRepoPath(host.root)];
|
|
38
|
+
if (!owner) return false;
|
|
39
|
+
const fingerprint = imageFingerprint(rawRecord.image);
|
|
40
|
+
return fingerprint !== null && owner.imageFingerprint === fingerprint;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function filesFor(record, root) {
|
|
44
|
+
return record.files.map(fileEntry).filter((file) => file.root === root)
|
|
45
|
+
.map((file) => ({ root, path: file.path }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fingerprintsFor(record, root, files) {
|
|
49
|
+
if (record.fingerprints === undefined) return undefined;
|
|
50
|
+
const values = record.fingerprints[root]
|
|
51
|
+
?? (root === 'claude' ? record.fingerprints : undefined)
|
|
52
|
+
?? {};
|
|
53
|
+
const selected = {};
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
if (values[file.path] !== undefined) selected[file.path] = values[file.path];
|
|
56
|
+
}
|
|
57
|
+
return { [root]: selected };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ownerFrom(record, host) {
|
|
61
|
+
const files = filesFor(record, 'claude');
|
|
62
|
+
const fingerprints = fingerprintsFor(record, 'claude', files);
|
|
63
|
+
return {
|
|
64
|
+
root: host.root,
|
|
65
|
+
scope: host.scope,
|
|
66
|
+
version: record.version,
|
|
67
|
+
installedAt: record.installedAt,
|
|
68
|
+
files,
|
|
69
|
+
...(fingerprints === undefined ? {} : { fingerprints }),
|
|
70
|
+
...(record.rules === undefined ? {} : { rules: record.rules }),
|
|
71
|
+
hooks: (record.hooks ?? (record.hook === undefined ? [] : [record.hook])).map((hook) => (
|
|
72
|
+
hook.root ? { ...hook } : { ...hook, root: 'claude' }
|
|
73
|
+
)),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function imageFrom(record) {
|
|
78
|
+
const files = filesFor(record, 'brand');
|
|
79
|
+
const fingerprints = fingerprintsFor(record, 'brand', files);
|
|
80
|
+
return {
|
|
81
|
+
version: record.version,
|
|
82
|
+
installedAt: record.installedAt,
|
|
83
|
+
files,
|
|
84
|
+
...(fingerprints === undefined ? {} : { fingerprints }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mergeFingerprints(image, owner) {
|
|
89
|
+
if (image.fingerprints === undefined && owner.fingerprints === undefined) return undefined;
|
|
90
|
+
return { ...(image.fingerprints ?? {}), ...(owner.fingerprints ?? {}) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ownerView(record2, host) {
|
|
94
|
+
if (!isFormat2(record2)) return null;
|
|
95
|
+
const key = normalizeRepoPath(host.root);
|
|
96
|
+
const owner = record2.owners?.[key];
|
|
97
|
+
if (!owner) return null;
|
|
98
|
+
const view = {
|
|
99
|
+
name: record2.name,
|
|
100
|
+
[INSTALL_METHOD_KEY]: record2[INSTALL_METHOD_KEY],
|
|
101
|
+
version: owner.version,
|
|
102
|
+
installedAt: owner.installedAt,
|
|
103
|
+
files: [...record2.image.files, ...owner.files],
|
|
104
|
+
hooks: owner.hooks,
|
|
105
|
+
};
|
|
106
|
+
const fingerprints = mergeFingerprints(record2.image, owner);
|
|
107
|
+
if (fingerprints !== undefined) view.fingerprints = fingerprints;
|
|
108
|
+
if (owner.rules !== undefined) view.rules = owner.rules;
|
|
109
|
+
return view;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Decides whether the departing owner may remove the shared image using only inventory facts. */
|
|
113
|
+
export function imageRemoval(record2, host) {
|
|
114
|
+
validateFormat2(record2);
|
|
115
|
+
const key = normalizeRepoPath(host.root);
|
|
116
|
+
const isOwner = Object.hasOwn(record2.owners, key);
|
|
117
|
+
const remaining = Object.keys(record2.owners).length - Number(isOwner);
|
|
118
|
+
if (remaining > 0) return { removeImage: false, reason: 'other-owners', remaining };
|
|
119
|
+
if (record2.legacy !== undefined || record2.inventory !== 'complete') {
|
|
120
|
+
return { removeImage: false, reason: 'incomplete-inventory' };
|
|
121
|
+
}
|
|
122
|
+
if (isOwner) return { removeImage: true };
|
|
123
|
+
return { removeImage: false, reason: 'incomplete-inventory' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Removes one host from the shared inventory while preserving its image and inventory facts. */
|
|
127
|
+
export function withoutOwner(record2, host) {
|
|
128
|
+
validateFormat2(record2);
|
|
129
|
+
const key = normalizeRepoPath(host.root);
|
|
130
|
+
if (!Object.hasOwn(record2.owners, key)) return record2;
|
|
131
|
+
const owners = { ...record2.owners };
|
|
132
|
+
delete owners[key];
|
|
133
|
+
return validateFormat2({ ...record2, owners });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function withOwner(existing, host, record1, options) {
|
|
137
|
+
validateInstallRecord(record1);
|
|
138
|
+
const key = normalizeRepoPath(host.root);
|
|
139
|
+
const image = imageFrom(record1);
|
|
140
|
+
const owner = ownerFrom(record1, host);
|
|
141
|
+
const fingerprint = imageFingerprint(image);
|
|
142
|
+
if (fingerprint !== null) owner.imageFingerprint = fingerprint;
|
|
143
|
+
|
|
144
|
+
if (existing === null) {
|
|
145
|
+
// Only an explicit false proves no earlier installation used this home; unknown is incomplete.
|
|
146
|
+
const confirmedNoPriorImage = options?.homeHadImage === false;
|
|
147
|
+
const created = {
|
|
148
|
+
format: 2,
|
|
149
|
+
name: record1.name,
|
|
150
|
+
[INSTALL_METHOD_KEY]: record1[INSTALL_METHOD_KEY],
|
|
151
|
+
image,
|
|
152
|
+
inventory: options?.inventory === 'complete' || confirmedNoPriorImage ? 'complete' : 'incomplete',
|
|
153
|
+
owners: { [key]: owner },
|
|
154
|
+
};
|
|
155
|
+
return validateFormat2(created);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let previous;
|
|
159
|
+
if (isFormat2(existing)) {
|
|
160
|
+
previous = validateFormat2(existing);
|
|
161
|
+
} else {
|
|
162
|
+
if (Object.hasOwn(existing ?? {}, 'format')) {
|
|
163
|
+
throw new Error(`unknown installation record format: ${existing.format}`);
|
|
164
|
+
}
|
|
165
|
+
validateInstallRecord(existing);
|
|
166
|
+
previous = {
|
|
167
|
+
format: 2,
|
|
168
|
+
name: record1.name,
|
|
169
|
+
[INSTALL_METHOD_KEY]: record1[INSTALL_METHOD_KEY],
|
|
170
|
+
image,
|
|
171
|
+
inventory: 'incomplete',
|
|
172
|
+
owners: {},
|
|
173
|
+
legacy: existing,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// D9: the operator's "this host is the only one" answers what the legacy record could not tell,
|
|
178
|
+
// so the old record goes in the same publish instead of lingering as a second opinion.
|
|
179
|
+
const { legacy: _answered, ...withoutLegacy } = previous;
|
|
180
|
+
const base = options?.inventory === 'complete' ? { ...withoutLegacy, inventory: 'complete' } : previous;
|
|
181
|
+
const next = {
|
|
182
|
+
...base,
|
|
183
|
+
name: record1.name,
|
|
184
|
+
[INSTALL_METHOD_KEY]: record1[INSTALL_METHOD_KEY],
|
|
185
|
+
image,
|
|
186
|
+
owners: { ...previous.owners, [key]: owner },
|
|
187
|
+
};
|
|
188
|
+
return validateFormat2(next);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function validatePartition(part, root, label) {
|
|
192
|
+
if (!isObject(part)) throw new Error(`installation record ${label} must be an object`);
|
|
193
|
+
if (!Array.isArray(part.files)) throw new Error(`installation record ${label} files must be a list`);
|
|
194
|
+
if (part.files.some((file) => fileEntry(file)?.root !== root)) {
|
|
195
|
+
throw new Error(`installation record ${label} files must use the ${root} root`);
|
|
196
|
+
}
|
|
197
|
+
if (part.fingerprints !== undefined) {
|
|
198
|
+
if (!isObject(part.fingerprints)
|
|
199
|
+
|| Object.keys(part.fingerprints).some((key) => key !== root)) {
|
|
200
|
+
throw new Error(`installation record ${label} fingerprints must use the ${root} root`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function validateFormat2(parsed) {
|
|
206
|
+
if (!isObject(parsed) || parsed.format !== 2) {
|
|
207
|
+
throw new Error('installation record format must be 2');
|
|
208
|
+
}
|
|
209
|
+
if (parsed.inventory !== 'complete' && parsed.inventory !== 'incomplete') {
|
|
210
|
+
throw new Error('installation record inventory must be complete or incomplete');
|
|
211
|
+
}
|
|
212
|
+
if (!isObject(parsed.owners)) throw new Error('installation record owners must be an object');
|
|
213
|
+
if (Object.keys(parsed.owners).length === 0 && parsed.inventory !== 'incomplete') {
|
|
214
|
+
throw new Error('installation record with no owners must have an incomplete inventory');
|
|
215
|
+
}
|
|
216
|
+
validatePartition(parsed.image, 'brand', 'image');
|
|
217
|
+
|
|
218
|
+
for (const [key, owner] of Object.entries(parsed.owners)) {
|
|
219
|
+
if (!isObject(owner) || typeof owner.root !== 'string' || !owner.root) {
|
|
220
|
+
throw new Error(`installation record owner ${key} must have a root`);
|
|
221
|
+
}
|
|
222
|
+
if (normalizeRepoPath(owner.root) !== key) {
|
|
223
|
+
throw new Error(`installation record owner root must normalize to its key: ${key}`);
|
|
224
|
+
}
|
|
225
|
+
if (typeof owner.scope !== 'string' || !owner.scope) {
|
|
226
|
+
throw new Error(`installation record owner ${key} has invalid scope`);
|
|
227
|
+
}
|
|
228
|
+
if (Object.hasOwn(owner, 'imageFingerprint')
|
|
229
|
+
&& (typeof owner.imageFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(owner.imageFingerprint))) {
|
|
230
|
+
throw new Error(`installation record owner ${key} has invalid imageFingerprint`);
|
|
231
|
+
}
|
|
232
|
+
validatePartition(owner, 'claude', `owner ${key}`);
|
|
233
|
+
const view = ownerView(parsed, { root: owner.root });
|
|
234
|
+
validateInstallRecord(view);
|
|
235
|
+
validateInstallRecord({ ...view, version: parsed.image.version, installedAt: parsed.image.installedAt });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (typeof parsed.name !== 'string' || !parsed.name) throw new Error('installation record has invalid name');
|
|
239
|
+
if (parsed.legacy !== undefined) validateInstallRecord(parsed.legacy);
|
|
240
|
+
if (parsed[INSTALL_METHOD_KEY] !== INSTALL_METHOD_COPY) {
|
|
241
|
+
throw new Error(`installation record ${INSTALL_METHOD_KEY} must be ${INSTALL_METHOD_COPY}`);
|
|
242
|
+
}
|
|
243
|
+
return parsed;
|
|
244
|
+
}
|
package/cli/install-record.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** Reads, validates, and writes the installation record shared by installer commands. */
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createHomeWriter } from '../src/home/lib/home-write.mjs';
|
|
4
5
|
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
5
6
|
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
7
|
+
import { isFormat2, ownerView, validateFormat2, withOwner, withoutOwner } from './install-owners.mjs';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The record's own spelling of the field saying how the package was installed, and its one value.
|
|
@@ -244,6 +246,12 @@ function withoutSeededFiles(host, record) {
|
|
|
244
246
|
return record;
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
// Plan_65 H4 removes image files while preserving the seeded operator data through the shared filter.
|
|
250
|
+
export function imageMembers(record2, host) {
|
|
251
|
+
const image = withoutSeededFiles(host, { files: [...record2.image.files] });
|
|
252
|
+
return image.files;
|
|
253
|
+
}
|
|
254
|
+
|
|
247
255
|
async function readAt(recordPath) {
|
|
248
256
|
try {
|
|
249
257
|
return await readJsonFile(recordPath);
|
|
@@ -264,18 +272,72 @@ export function legacyInstallRecordPath(host) {
|
|
|
264
272
|
return path.join(host.legacyAgentsDir, LEGACY_INSTALL_RECORD_NAME);
|
|
265
273
|
}
|
|
266
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Reads the view owned by this host. Plan_65 D10 item 5: format-2 legacy only marks an
|
|
277
|
+
* incomplete inventory; it is never another host's view, enrollment template, fingerprint base,
|
|
278
|
+
* or source of createdGroup. Format-1 and older per-host records remain readable for migration.
|
|
279
|
+
*/
|
|
267
280
|
export async function readInstallRecord(host) {
|
|
268
|
-
const parsed = await
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
281
|
+
const parsed = await readInstallRecordFile(host);
|
|
282
|
+
if (parsed !== null) {
|
|
283
|
+
if (isFormat2(parsed)) {
|
|
284
|
+
validateFormat2(parsed);
|
|
285
|
+
const owned = ownerView(parsed, host);
|
|
286
|
+
return owned === null ? null : withoutSeededFiles(host, normalizeInstallRecord(owned));
|
|
287
|
+
}
|
|
288
|
+
return withoutSeededFiles(host, normalizeInstallRecord(parsed));
|
|
289
|
+
}
|
|
290
|
+
const legacy = await readAt(legacyInstallRecordPath(host));
|
|
291
|
+
return legacy === null ? null : withoutSeededFiles(host, normalizeInstallRecord(legacy));
|
|
272
292
|
}
|
|
273
293
|
|
|
274
|
-
export async function
|
|
275
|
-
|
|
294
|
+
export async function readInstallRecordFile(host) {
|
|
295
|
+
return readAt(installRecordPath(host));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function publishInstallRecord(host, next) {
|
|
276
299
|
const target = installRecordPath(host);
|
|
277
|
-
|
|
278
|
-
|
|
300
|
+
const writer = createHomeWriter({ root: host.brandRoot });
|
|
301
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
302
|
+
writer.assertArtifact('install-record', target);
|
|
303
|
+
writer.assertArtifact('install-record', temporary);
|
|
304
|
+
await writer.mkdir('install-record', path.dirname(target), { recursive: true });
|
|
305
|
+
await writer.writeFile('install-record', temporary, `${JSON.stringify(next, null, 2)}\n`);
|
|
306
|
+
await writer.rename('install-record', temporary, target);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Publishes the record whole through a temporary and a rename: a direct write left a half-written
|
|
311
|
+
* record for every host sharing the home if the process died mid-write (advice A3, risk r3).
|
|
312
|
+
* `homeHadImage: false` is the caller's proof that no earlier installation used this home; without
|
|
313
|
+
* it a new record starts with an incomplete inventory (D6).
|
|
314
|
+
* inventory: 'complete' records the operator's D9 confirmation and drops a migrated legacy record
|
|
315
|
+
* in the same publish.
|
|
316
|
+
*/
|
|
317
|
+
export async function writeInstallRecord(host, record, { homeHadImage, inventory } = {}) {
|
|
318
|
+
const normalized = normalizeInstallRecord(record);
|
|
319
|
+
const next = withOwner(await readAt(installRecordPath(host)), host, normalized, { homeHadImage, inventory });
|
|
320
|
+
await publishInstallRecord(host, next);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Reads a parsed record as format 2, migrating a format-1 one in memory with `host` as its only
|
|
325
|
+
* owner and an incomplete inventory (D6). Uninstall decides and records from this one view, so the
|
|
326
|
+
* dry run and the real run cannot migrate differently; an unknown format throws.
|
|
327
|
+
*/
|
|
328
|
+
export function asFormat2(parsed, host) {
|
|
329
|
+
return isFormat2(parsed) ? parsed : withOwner(parsed, host, normalizeInstallRecord(parsed));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Removes one host from the shared record so uninstall can decide what the inventory permits. */
|
|
333
|
+
export async function removeInstallOwner(host) {
|
|
334
|
+
const parsed = await readAt(installRecordPath(host));
|
|
335
|
+
if (parsed === null) return null;
|
|
336
|
+
const record2 = asFormat2(parsed, host);
|
|
337
|
+
const next = withoutOwner(record2, host);
|
|
338
|
+
if (next === record2) return next;
|
|
339
|
+
await publishInstallRecord(host, next);
|
|
340
|
+
return next;
|
|
279
341
|
}
|
|
280
342
|
|
|
281
343
|
export function fingerprintFor(record, file) {
|
package/cli/install.mjs
CHANGED
|
@@ -14,15 +14,20 @@ import {
|
|
|
14
14
|
seedPlan,
|
|
15
15
|
writeInstallRecord,
|
|
16
16
|
} from './manifest.mjs';
|
|
17
|
-
import { copyPlannedFile, targetMatches } from './copy.mjs';
|
|
17
|
+
import { copyPlannedFile, planHomeWriter, targetMatches } from './copy.mjs';
|
|
18
|
+
import { removeOutside } from './record-removal.mjs';
|
|
18
19
|
import { hookTargets, recordHasHooks } from './hook-targets.mjs';
|
|
19
|
-
import { fingerprintFor,
|
|
20
|
+
import { fingerprintFor, readInstallRecordFile } from './install-record.mjs';
|
|
21
|
+
import { ownImageStampCurrent } from './install-owners.mjs';
|
|
22
|
+
import { syncOtherOwners } from './owner-sync-apply.mjs';
|
|
23
|
+
import { ownerRecord } from './owner-record.mjs';
|
|
20
24
|
import {
|
|
21
25
|
commandFor,
|
|
22
26
|
inspectHook,
|
|
23
27
|
mergeHook,
|
|
24
28
|
withSettingsRun,
|
|
25
29
|
} from './settings-merge.mjs';
|
|
30
|
+
import { withLifecycle } from './lifecycle-transaction.mjs';
|
|
26
31
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
27
32
|
import { addPermissionRules, inspectPermissions } from './permissions.mjs';
|
|
28
33
|
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
@@ -30,6 +35,7 @@ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
|
30
35
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
31
36
|
import { sessionHostVersions } from './session-hosts.mjs';
|
|
32
37
|
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
38
|
+
import { askTransition, detectTransition, transitionOutcome } from './inventory-transition.mjs';
|
|
33
39
|
|
|
34
40
|
const WARNING = '\u001b[33m';
|
|
35
41
|
const RESET = '\u001b[0m';
|
|
@@ -87,7 +93,7 @@ function permissionOutput(result, settingsPath) {
|
|
|
87
93
|
return `All ${result.total} permission rules are already in place in ${settingsPath}, so the host runs the package command without asking.`;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
async function migrateLegacySeed(host, seed) {
|
|
96
|
+
async function migrateLegacySeed(host, seed, writer, id) {
|
|
91
97
|
if (await targetExists(seed.target)) return;
|
|
92
98
|
const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
|
|
93
99
|
const legacy = path.join(host.legacyAgentsDir, legacyName);
|
|
@@ -98,22 +104,40 @@ async function migrateLegacySeed(host, seed) {
|
|
|
98
104
|
// so the previous layout is never taken down and the operator goes on editing a file nothing
|
|
99
105
|
// reads. If the comparison fails the old file stays: a half-copied config is the one case where
|
|
100
106
|
// having two is better than having none.
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
// Plan_65 B10: the seed lands in the home only by its registry id, and the old copy under the
|
|
108
|
+
// host agents directory is removed only after the adapter proves it lies outside the home.
|
|
109
|
+
await writer.mkdir(id, path.dirname(seed.target));
|
|
110
|
+
await writer.copyFile(id, legacy, seed.target);
|
|
103
111
|
if (await fileFingerprint(legacy) !== await fileFingerprint(seed.target)) return;
|
|
104
|
-
await
|
|
112
|
+
await removeOutside(writer, legacy);
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
async function
|
|
108
|
-
host,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
async function ownerSyncOutput(host, { packageRoot, env, dryRun }, output) {
|
|
116
|
+
const ownerSync = await syncOtherOwners(host, { packageRoot, env, dryRun });
|
|
117
|
+
// Update reads ownerSync to tell "another owner lags" from "this host failed" (Plan_65 B13e2d).
|
|
118
|
+
return {
|
|
119
|
+
exitCode: ownerSync.complete ? 0 : 1,
|
|
120
|
+
output: ownerSync.lines.length ? `${output}\n${ownerSync.lines.join('\n')}` : output,
|
|
121
|
+
ownerSync,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function installInRun(options = {}) {
|
|
126
|
+
const {
|
|
127
|
+
host,
|
|
128
|
+
dryRun = false,
|
|
129
|
+
force = false,
|
|
130
|
+
packageRoot,
|
|
131
|
+
env = process.env,
|
|
132
|
+
contractRecord,
|
|
133
|
+
hostVersion,
|
|
134
|
+
observations,
|
|
135
|
+
inventoryTransition,
|
|
136
|
+
} = options;
|
|
137
|
+
const transitionState = inventoryTransition === undefined
|
|
138
|
+
? await detectTransition(host) : inventoryTransition;
|
|
139
|
+
const { transition, homeHadImage, inventory: suppliedInventory } = transitionState;
|
|
140
|
+
let inventory = suppliedInventory;
|
|
117
141
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
118
142
|
await readRulesRegistry(host);
|
|
119
143
|
const configuredRetentionLine = retentionLine(host);
|
|
@@ -129,9 +153,11 @@ async function installInRun({
|
|
|
129
153
|
? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
|
|
130
154
|
if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
|
|
131
155
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
156
|
+
const { writer, idFor: copyId } = planHomeWriter(host.brandRoot, plan);
|
|
132
157
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
133
158
|
const currentPackage = await packageInfo(packageRoot);
|
|
134
159
|
const record = await readInstallRecord(host);
|
|
160
|
+
const rawRecord = await readInstallRecordFile(host);
|
|
135
161
|
const targets = hookTargets(host, env);
|
|
136
162
|
for (const target of targets) {
|
|
137
163
|
if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
|
|
@@ -167,33 +193,36 @@ async function installInRun({
|
|
|
167
193
|
};
|
|
168
194
|
}
|
|
169
195
|
|
|
196
|
+
if (inventoryTransition === undefined && transition && !dryRun) {
|
|
197
|
+
inventory = await askTransition(host, options);
|
|
198
|
+
if (inventory === 'cancel') {
|
|
199
|
+
return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
170
203
|
const changedFiles = states.filter((state) => !state.matches);
|
|
171
204
|
const changedRule = !ruleState.matches;
|
|
172
205
|
const sameRecord = recordMatchesPackage(record, plan, currentPackage,
|
|
173
206
|
new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
|
|
174
207
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
175
|
-
|
|
176
|
-
|
|
208
|
+
// D11: the command doctor recommends must clear its warning about this host's image stamp.
|
|
209
|
+
if (!transition && !changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
|
|
210
|
+
&& recordHasHooks(record, targets) && sameRecord && ownImageStampCurrent(rawRecord, host)) {
|
|
177
211
|
if (!dryRun) {
|
|
178
212
|
await addRulesOwner(host);
|
|
179
213
|
// Read, never write: this branch's whole claim is that there is nothing to do, and a
|
|
180
214
|
// sentence about permission rules must not be paid for by quietly restoring one.
|
|
181
215
|
const permissionResult = await inspectPermissions(host.settingsPath);
|
|
182
|
-
return {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
configuredRetentionLine,
|
|
188
|
-
`codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
189
|
-
),
|
|
216
|
+
return ownerSyncOutput(host, { packageRoot, env, dryRun }, contractOutput(
|
|
217
|
+
hostContract,
|
|
218
|
+
retentionOutput(
|
|
219
|
+
configuredRetentionLine,
|
|
220
|
+
`codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
190
221
|
),
|
|
191
|
-
|
|
222
|
+
));
|
|
192
223
|
}
|
|
193
|
-
return {
|
|
194
|
-
|
|
195
|
-
output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
|
|
196
|
-
};
|
|
224
|
+
return ownerSyncOutput(host, { packageRoot, env, dryRun },
|
|
225
|
+
contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')));
|
|
197
226
|
}
|
|
198
227
|
|
|
199
228
|
if (dryRun) {
|
|
@@ -210,22 +239,28 @@ async function installInRun({
|
|
|
210
239
|
? `${definition.event} hook is already registered (${registration.form} command).`
|
|
211
240
|
: `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
|
|
212
241
|
});
|
|
242
|
+
if (transition) lines.push(`A real run would ask whether this host is the only one using ${host.brandRoot}.`);
|
|
213
243
|
lines.push('Would write installation record in the brand root.');
|
|
214
|
-
return
|
|
244
|
+
return ownerSyncOutput(host, { packageRoot, env, dryRun },
|
|
245
|
+
contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))));
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
// Claim ownership of the shared rules file before writing anything. Claiming it last meant a
|
|
218
249
|
// failure at that step left a fully installed host absent from the registry, and the next
|
|
219
250
|
// uninstall elsewhere would then delete the rules out from under it.
|
|
220
251
|
await addRulesOwner(host);
|
|
221
|
-
for (const { item } of changedFiles)
|
|
222
|
-
|
|
252
|
+
for (const { item } of changedFiles) {
|
|
253
|
+
await copyPlannedFile(item, host.brandRoot, { writer, id: copyId(item) });
|
|
254
|
+
}
|
|
255
|
+
if (changedRule) await copyPlannedFile(rule, host.brandRoot, { writer, id: copyId(rule) });
|
|
223
256
|
// Seeded files are written once and then belong to the operator: an existing one is left
|
|
224
257
|
// exactly as it is, including under --force, because --force is about our files, not theirs.
|
|
225
258
|
for (const seed of seedPlan(host, packageRoot)) {
|
|
226
259
|
if (!(await targetExists(seed.target))) {
|
|
227
|
-
await migrateLegacySeed(host, seed);
|
|
228
|
-
if (!(await targetExists(seed.target)))
|
|
260
|
+
await migrateLegacySeed(host, seed, writer, copyId(seed));
|
|
261
|
+
if (!(await targetExists(seed.target))) {
|
|
262
|
+
await copyPlannedFile(seed, host.brandRoot, { writer, id: copyId(seed) });
|
|
263
|
+
}
|
|
229
264
|
}
|
|
230
265
|
}
|
|
231
266
|
const hookResults = [];
|
|
@@ -239,49 +274,27 @@ async function installInRun({
|
|
|
239
274
|
const permissionResult = dryRun
|
|
240
275
|
? { dryRun: true, added: 0, present: 0, total: 0 }
|
|
241
276
|
: await addPermissionRules(host.settingsPath);
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const command = registration.command;
|
|
252
|
-
return {
|
|
253
|
-
event: definition.event,
|
|
254
|
-
root: 'brand',
|
|
255
|
-
path: relative,
|
|
256
|
-
command,
|
|
257
|
-
form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
|
|
258
|
-
...(createdGroup ? { createdGroup: true } : {}),
|
|
259
|
-
};
|
|
260
|
-
});
|
|
261
|
-
await writeInstallRecord(host, {
|
|
262
|
-
...currentPackage,
|
|
263
|
-
installedAt: new Date().toISOString(),
|
|
264
|
-
[INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
|
|
265
|
-
files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
|
|
266
|
-
fingerprints,
|
|
267
|
-
rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
|
|
268
|
-
hooks,
|
|
269
|
-
});
|
|
270
|
-
return {
|
|
271
|
-
exitCode: 0,
|
|
272
|
-
output: contractOutput(
|
|
273
|
-
hostContract,
|
|
274
|
-
retentionOutput(
|
|
275
|
-
configuredRetentionLine,
|
|
276
|
-
`Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
|
|
277
|
-
+ `\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
278
|
-
),
|
|
277
|
+
const row = await ownerRecord({ plan, rule, targets, hookResults, prior: record, currentPackage });
|
|
278
|
+
await writeInstallRecord(host, row, { homeHadImage, inventory });
|
|
279
|
+
const transitionLine = transition ? `\n${transitionOutcome(host, inventory)}` : '';
|
|
280
|
+
return ownerSyncOutput(host, { packageRoot, env, dryRun }, contractOutput(
|
|
281
|
+
hostContract,
|
|
282
|
+
retentionOutput(
|
|
283
|
+
configuredRetentionLine,
|
|
284
|
+
`Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
|
|
285
|
+
+ `\n${permissionOutput(permissionResult, host.settingsPath)}${transitionLine}`,
|
|
279
286
|
),
|
|
280
|
-
|
|
287
|
+
));
|
|
281
288
|
}
|
|
282
289
|
|
|
283
290
|
export async function install(options = {}) {
|
|
284
291
|
const host = options?.host;
|
|
285
292
|
if (!host?.settingsPath) return installInRun(options);
|
|
286
|
-
return withSettingsRun(host.settingsPath, () => installInRun(options));
|
|
293
|
+
if (options.dryRun === true) return withSettingsRun(host.settingsPath, () => installInRun(options));
|
|
294
|
+
return withLifecycle(
|
|
295
|
+
host,
|
|
296
|
+
'install',
|
|
297
|
+
() => withSettingsRun(host.settingsPath, () => installInRun(options)),
|
|
298
|
+
{ ticket: options.lifecycleTicket, waitMs: options.lifecycleWaitMs, createHome: true },
|
|
299
|
+
);
|
|
287
300
|
}
|