@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,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,19 +14,28 @@ 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
|
-
import { contractStatus,
|
|
33
|
+
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
34
|
+
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
35
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
36
|
+
import { sessionHostVersions } from './session-hosts.mjs';
|
|
29
37
|
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
38
|
+
import { askTransition, detectTransition, transitionOutcome } from './inventory-transition.mjs';
|
|
30
39
|
|
|
31
40
|
const WARNING = '\u001b[33m';
|
|
32
41
|
const RESET = '\u001b[0m';
|
|
@@ -84,7 +93,7 @@ function permissionOutput(result, settingsPath) {
|
|
|
84
93
|
return `All ${result.total} permission rules are already in place in ${settingsPath}, so the host runs the package command without asking.`;
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
async function migrateLegacySeed(host, seed) {
|
|
96
|
+
async function migrateLegacySeed(host, seed, writer, id) {
|
|
88
97
|
if (await targetExists(seed.target)) return;
|
|
89
98
|
const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
|
|
90
99
|
const legacy = path.join(host.legacyAgentsDir, legacyName);
|
|
@@ -95,32 +104,60 @@ async function migrateLegacySeed(host, seed) {
|
|
|
95
104
|
// so the previous layout is never taken down and the operator goes on editing a file nothing
|
|
96
105
|
// reads. If the comparison fails the old file stays: a half-copied config is the one case where
|
|
97
106
|
// having two is better than having none.
|
|
98
|
-
|
|
99
|
-
|
|
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);
|
|
100
111
|
if (await fileFingerprint(legacy) !== await fileFingerprint(seed.target)) return;
|
|
101
|
-
await
|
|
112
|
+
await removeOutside(writer, legacy);
|
|
102
113
|
}
|
|
103
114
|
|
|
104
|
-
async function
|
|
105
|
-
host,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
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;
|
|
113
141
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
114
142
|
await readRulesRegistry(host);
|
|
115
143
|
const configuredRetentionLine = retentionLine(host);
|
|
116
|
-
const
|
|
144
|
+
const hostObservations = observations === undefined
|
|
145
|
+
? readHostObservations({ stateDir: brandStateDir(host.brandRoot) }) : observations;
|
|
146
|
+
const observedVersion = sessionHostVersions(hostObservations)[0] ?? null;
|
|
147
|
+
const currentHostVersion = hostVersion === undefined ? observedVersion : hostVersion;
|
|
148
|
+
let hostContract = contractStatus({
|
|
117
149
|
record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
|
|
118
|
-
version:
|
|
150
|
+
version: currentHostVersion,
|
|
119
151
|
});
|
|
152
|
+
const noObservedHostMessage = currentHostVersion === null
|
|
153
|
+
? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
|
|
154
|
+
if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
|
|
120
155
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
156
|
+
const { writer, idFor: copyId } = planHomeWriter(host.brandRoot, plan);
|
|
121
157
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
122
158
|
const currentPackage = await packageInfo(packageRoot);
|
|
123
159
|
const record = await readInstallRecord(host);
|
|
160
|
+
const rawRecord = await readInstallRecordFile(host);
|
|
124
161
|
const targets = hookTargets(host, env);
|
|
125
162
|
for (const target of targets) {
|
|
126
163
|
if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
|
|
@@ -156,33 +193,36 @@ async function installInRun({
|
|
|
156
193
|
};
|
|
157
194
|
}
|
|
158
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
|
+
|
|
159
203
|
const changedFiles = states.filter((state) => !state.matches);
|
|
160
204
|
const changedRule = !ruleState.matches;
|
|
161
205
|
const sameRecord = recordMatchesPackage(record, plan, currentPackage,
|
|
162
206
|
new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
|
|
163
207
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
164
|
-
|
|
165
|
-
|
|
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)) {
|
|
166
211
|
if (!dryRun) {
|
|
167
212
|
await addRulesOwner(host);
|
|
168
213
|
// Read, never write: this branch's whole claim is that there is nothing to do, and a
|
|
169
214
|
// sentence about permission rules must not be paid for by quietly restoring one.
|
|
170
215
|
const permissionResult = await inspectPermissions(host.settingsPath);
|
|
171
|
-
return {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
configuredRetentionLine,
|
|
177
|
-
`codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
178
|
-
),
|
|
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)}`,
|
|
179
221
|
),
|
|
180
|
-
|
|
222
|
+
));
|
|
181
223
|
}
|
|
182
|
-
return {
|
|
183
|
-
|
|
184
|
-
output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
|
|
185
|
-
};
|
|
224
|
+
return ownerSyncOutput(host, { packageRoot, env, dryRun },
|
|
225
|
+
contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')));
|
|
186
226
|
}
|
|
187
227
|
|
|
188
228
|
if (dryRun) {
|
|
@@ -199,22 +239,28 @@ async function installInRun({
|
|
|
199
239
|
? `${definition.event} hook is already registered (${registration.form} command).`
|
|
200
240
|
: `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
|
|
201
241
|
});
|
|
242
|
+
if (transition) lines.push(`A real run would ask whether this host is the only one using ${host.brandRoot}.`);
|
|
202
243
|
lines.push('Would write installation record in the brand root.');
|
|
203
|
-
return
|
|
244
|
+
return ownerSyncOutput(host, { packageRoot, env, dryRun },
|
|
245
|
+
contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))));
|
|
204
246
|
}
|
|
205
247
|
|
|
206
248
|
// Claim ownership of the shared rules file before writing anything. Claiming it last meant a
|
|
207
249
|
// failure at that step left a fully installed host absent from the registry, and the next
|
|
208
250
|
// uninstall elsewhere would then delete the rules out from under it.
|
|
209
251
|
await addRulesOwner(host);
|
|
210
|
-
for (const { item } of changedFiles)
|
|
211
|
-
|
|
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) });
|
|
212
256
|
// Seeded files are written once and then belong to the operator: an existing one is left
|
|
213
257
|
// exactly as it is, including under --force, because --force is about our files, not theirs.
|
|
214
258
|
for (const seed of seedPlan(host, packageRoot)) {
|
|
215
259
|
if (!(await targetExists(seed.target))) {
|
|
216
|
-
await migrateLegacySeed(host, seed);
|
|
217
|
-
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
|
+
}
|
|
218
264
|
}
|
|
219
265
|
}
|
|
220
266
|
const hookResults = [];
|
|
@@ -228,49 +274,27 @@ async function installInRun({
|
|
|
228
274
|
const permissionResult = dryRun
|
|
229
275
|
? { dryRun: true, added: 0, present: 0, total: 0 }
|
|
230
276
|
: await addPermissionRules(host.settingsPath);
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const command = registration.command;
|
|
241
|
-
return {
|
|
242
|
-
event: definition.event,
|
|
243
|
-
root: 'brand',
|
|
244
|
-
path: relative,
|
|
245
|
-
command,
|
|
246
|
-
form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
|
|
247
|
-
...(createdGroup ? { createdGroup: true } : {}),
|
|
248
|
-
};
|
|
249
|
-
});
|
|
250
|
-
await writeInstallRecord(host, {
|
|
251
|
-
...currentPackage,
|
|
252
|
-
installedAt: new Date().toISOString(),
|
|
253
|
-
[INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
|
|
254
|
-
files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
|
|
255
|
-
fingerprints,
|
|
256
|
-
rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
|
|
257
|
-
hooks,
|
|
258
|
-
});
|
|
259
|
-
return {
|
|
260
|
-
exitCode: 0,
|
|
261
|
-
output: contractOutput(
|
|
262
|
-
hostContract,
|
|
263
|
-
retentionOutput(
|
|
264
|
-
configuredRetentionLine,
|
|
265
|
-
`Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
|
|
266
|
-
+ `\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
267
|
-
),
|
|
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}`,
|
|
268
286
|
),
|
|
269
|
-
|
|
287
|
+
));
|
|
270
288
|
}
|
|
271
289
|
|
|
272
290
|
export async function install(options = {}) {
|
|
273
291
|
const host = options?.host;
|
|
274
292
|
if (!host?.settingsPath) return installInRun(options);
|
|
275
|
-
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
|
+
);
|
|
276
300
|
}
|