@echopath-labs/forgerail 0.1.0-alpha.3 → 0.1.0-alpha.4
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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +34 -0
- package/CONTRIBUTING.md +68 -4
- package/README.md +126 -49
- package/README.zh-CN.md +131 -28
- package/SECURITY.md +48 -4
- package/SUPPORT.md +37 -0
- package/adapters/claude-code.json +6 -1
- package/adapters/codex.json +6 -0
- package/adapters/cursor.json +5 -0
- package/contracts/adoption-plan.schema.json +39 -18
- package/contracts/effective-profile.schema.json +4 -4
- package/contracts/host-adapter.schema.json +66 -4
- package/contracts/host-binding-receipt.schema.json +1 -1
- package/contracts/launch-contract.schema.json +38 -2
- package/contracts/profile-change-candidate.schema.json +1 -1
- package/contracts/return-receipt.schema.json +1 -1
- package/contracts/task-envelope.schema.json +1 -1
- package/directory/README.md +1 -1
- package/directory/release-notes-alpha4.md +9 -0
- package/directory/submission-candidate.json +4 -4
- package/docs/adoption.md +63 -26
- package/docs/adoption.zh-CN.md +62 -25
- package/docs/architecture-acceptance.md +1 -1
- package/docs/composable-autonomy.zh-CN.md +16 -22
- package/docs/installation.md +71 -40
- package/docs/installation.zh-CN.md +90 -31
- package/docs/release-alpha4.md +33 -0
- package/docs/release-alpha4.zh-CN.md +33 -0
- package/package.json +7 -3
- package/scripts/adoption-closeout-regressions.mjs +100 -0
- package/scripts/disposable-consumer.mjs +11 -18
- package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
- package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
- package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
- package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
- package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
- package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
- package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
- package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
- package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
- package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
- package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
- package/scripts/fixtures/open-source-docs/cases.json +65 -0
- package/scripts/forgerail.mjs +61 -16
- package/scripts/integrity-regressions.mjs +1261 -0
- package/scripts/lib/adoption.mjs +666 -51
- package/scripts/lib/bounded-read.mjs +80 -0
- package/scripts/lib/composition.mjs +77 -7
- package/scripts/lib/contracts.mjs +126 -40
- package/scripts/lib/diagnosis.mjs +146 -39
- package/scripts/shadow-comparison.mjs +52 -34
- package/scripts/validate-open-source-docs.mjs +132 -0
- package/scripts/validate-release.mjs +77 -13
- package/scripts/validate-universal-directory.mjs +5 -5
- package/skills/forgerail/references/adoption.md +2 -2
- package/skills/forgerail/references/contracts.md +2 -2
- package/scripts/lib/bundle.mjs +0 -77
package/scripts/lib/adoption.mjs
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
fsyncSync,
|
|
9
|
+
linkSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
openSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmdirSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
23
|
import { validateContract } from "./contracts.mjs";
|
|
24
|
+
import { inspectBoundedPath } from "./bounded-read.mjs";
|
|
5
25
|
|
|
6
26
|
const levels = ["plugin-only", "lightweight-adoption", "persisted-governance"];
|
|
27
|
+
const adoptionOperations = new Set(["create", "append-managed-block", "replace-managed-block"]);
|
|
28
|
+
const hostSelectionModes = new Set(["explicit", "all-detected", "all-available"]);
|
|
29
|
+
const portableRelativePath = /^(?![\\/])(?![a-zA-Z]:)(?!.*\/\/)(?!.*(?:^|\/)\.(?:\/|$))(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*(?:^|\/)[^/]*\.(?:\/|$))(?!.*(?:^|\/)(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Mm][1-9]|[Ll][Pp][Tt][1-9])(?:\.|\/|$))(?!.*\/$)[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
|
|
7
30
|
|
|
8
31
|
function sha256(value) {
|
|
9
32
|
return createHash("sha256").update(value).digest("hex");
|
|
@@ -19,55 +42,462 @@ function adapterFiles(pluginRoot) {
|
|
|
19
42
|
.sort();
|
|
20
43
|
}
|
|
21
44
|
|
|
45
|
+
function confined(root, target) {
|
|
46
|
+
const value = relative(root, target);
|
|
47
|
+
return value === "" || (
|
|
48
|
+
!isAbsolute(value)
|
|
49
|
+
&& !/^[a-zA-Z]:/.test(value)
|
|
50
|
+
&& value !== ".."
|
|
51
|
+
&& !value.startsWith(`..${sep}`)
|
|
52
|
+
&& !value.startsWith("/")
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function portableTargetIdentity(path) {
|
|
57
|
+
return path.normalize("NFC").toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function targetIdentitiesConflict(left, right) {
|
|
61
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function linkAwareStat(path) {
|
|
65
|
+
try { return lstatSync(path); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function adoptionTarget(workspace, path) {
|
|
73
|
+
if (typeof path !== "string" || !portableRelativePath.test(path)) throw new Error(`adoption target path is unsafe: ${path}`);
|
|
74
|
+
const root = realpathSync(resolve(workspace));
|
|
75
|
+
let cursor = root;
|
|
76
|
+
const segments = path.split("/");
|
|
77
|
+
for (const [index, segment] of segments.entries()) {
|
|
78
|
+
const candidate = resolve(cursor, segment);
|
|
79
|
+
if (!confined(root, candidate)) throw new Error(`adoption target escapes workspace: ${path}`);
|
|
80
|
+
const metadata = linkAwareStat(candidate);
|
|
81
|
+
if (metadata !== null) {
|
|
82
|
+
if (metadata.isSymbolicLink()) throw new Error(`adoption target cannot traverse a symbolic link: ${path}`);
|
|
83
|
+
const final = index === segments.length - 1;
|
|
84
|
+
if (final && !metadata.isFile()) throw new Error(`adoption target is not a regular file: ${path}`);
|
|
85
|
+
if (!final && !metadata.isDirectory()) throw new Error(`adoption target ancestor is not a regular directory: ${path}`);
|
|
86
|
+
const observed = realpathSync(candidate);
|
|
87
|
+
if (!confined(root, observed)) throw new Error(`adoption target escapes workspace: ${path}`);
|
|
88
|
+
cursor = observed;
|
|
89
|
+
} else cursor = candidate;
|
|
90
|
+
}
|
|
91
|
+
return cursor;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readAdoptionTarget(path, label) {
|
|
95
|
+
let descriptor;
|
|
96
|
+
try {
|
|
97
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
98
|
+
const metadata = fstatSync(descriptor);
|
|
99
|
+
if (!metadata.isFile()) throw new Error(`adoption target is not a regular file: ${label}`);
|
|
100
|
+
return readFileSync(descriptor, "utf8");
|
|
101
|
+
} finally {
|
|
102
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveAdoptionWriteTarget(workspace, path) {
|
|
107
|
+
return adoptionTarget(workspace, path);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sameFile(left, right) {
|
|
111
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function workspaceIdentitySha256(root, metadata) {
|
|
115
|
+
return sha256(JSON.stringify({
|
|
116
|
+
schemaVersion: "1.0",
|
|
117
|
+
canonicalPath: root,
|
|
118
|
+
device: String(metadata.dev),
|
|
119
|
+
inode: String(metadata.ino),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function openBoundWorkspace(workspace) {
|
|
124
|
+
const root = realpathSync(resolve(workspace));
|
|
125
|
+
let descriptor;
|
|
126
|
+
try {
|
|
127
|
+
descriptor = openSync(root, constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_DIRECTORY ?? 0));
|
|
128
|
+
const metadata = fstatSync(descriptor, { bigint: true });
|
|
129
|
+
const pathMetadata = lstatSync(root, { bigint: true });
|
|
130
|
+
if (!metadata.isDirectory() || pathMetadata.isSymbolicLink() || !sameFile(metadata, pathMetadata)) {
|
|
131
|
+
throw new Error("workspace directory identity changed while binding approval");
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
root,
|
|
135
|
+
descriptor,
|
|
136
|
+
metadata,
|
|
137
|
+
workspaceSha256: workspaceIdentitySha256(root, metadata),
|
|
138
|
+
};
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function verifyBoundWorkspacePath(binding) {
|
|
146
|
+
const descriptorMetadata = fstatSync(binding.descriptor, { bigint: true });
|
|
147
|
+
let pathMetadata;
|
|
148
|
+
try {
|
|
149
|
+
pathMetadata = lstatSync(binding.root, { bigint: true });
|
|
150
|
+
} catch {
|
|
151
|
+
throw new Error("approved workspace directory identity changed before write");
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
!descriptorMetadata.isDirectory()
|
|
155
|
+
|| pathMetadata.isSymbolicLink()
|
|
156
|
+
|| !sameFile(binding.metadata, descriptorMetadata)
|
|
157
|
+
|| !sameFile(binding.metadata, pathMetadata)
|
|
158
|
+
|| workspaceIdentitySha256(binding.root, descriptorMetadata) !== binding.workspaceSha256
|
|
159
|
+
) {
|
|
160
|
+
throw new Error("approved workspace directory identity changed before write");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function snapshotAdoptionWrite(write) {
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
workspaceSha256: write.workspaceSha256,
|
|
167
|
+
path: write.path,
|
|
168
|
+
operation: write.operation,
|
|
169
|
+
baseSha256: write.baseSha256,
|
|
170
|
+
contentSha256: write.contentSha256,
|
|
171
|
+
content: write.content,
|
|
172
|
+
managedMarker: write.managedMarker,
|
|
173
|
+
approvalSha256: write.approvalSha256,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function approvalBoundWrite(write) {
|
|
178
|
+
return {
|
|
179
|
+
workspaceSha256: write.workspaceSha256,
|
|
180
|
+
path: write.path,
|
|
181
|
+
operation: write.operation,
|
|
182
|
+
baseSha256: write.baseSha256,
|
|
183
|
+
contentSha256: write.contentSha256,
|
|
184
|
+
content: write.content,
|
|
185
|
+
managedMarker: write.managedMarker,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function adoptionWriteApprovalDigest(write) {
|
|
190
|
+
return sha256(JSON.stringify(approvalBoundWrite(snapshotAdoptionWrite(write))));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function verifyApprovedWrite(write, approvedWriteDigest, workspaceSha256) {
|
|
194
|
+
const snapshot = snapshotAdoptionWrite(write);
|
|
195
|
+
const currentDigest = sha256(JSON.stringify(approvalBoundWrite(snapshot)));
|
|
196
|
+
if (
|
|
197
|
+
typeof approvedWriteDigest !== "string"
|
|
198
|
+
|| approvedWriteDigest !== snapshot.approvalSha256
|
|
199
|
+
|| approvedWriteDigest !== currentDigest
|
|
200
|
+
|| snapshot.workspaceSha256 !== workspaceSha256
|
|
201
|
+
) {
|
|
202
|
+
throw new Error("approved write digest does not match the proposed write");
|
|
203
|
+
}
|
|
204
|
+
if (!adoptionOperations.has(snapshot.operation)) {
|
|
205
|
+
throw new Error(`approved adoption operation is unsupported: ${snapshot.operation}`);
|
|
206
|
+
}
|
|
207
|
+
return snapshot;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function approvedContent(write) {
|
|
211
|
+
if (typeof write.content !== "string" || sha256(write.content) !== write.contentSha256) {
|
|
212
|
+
throw new Error(`approved content digest does not match for ${write.path}`);
|
|
213
|
+
}
|
|
214
|
+
return write.content;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function removeCreatedParents(root, created) {
|
|
218
|
+
for (const directory of created.reverse()) {
|
|
219
|
+
try {
|
|
220
|
+
const metadata = lstatSync(directory.path);
|
|
221
|
+
if (confined(root, directory.path) && sameFile(metadata, directory.metadata)) rmdirSync(directory.path);
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function verifyBoundAdoptionParentPath(workspaceBinding, parentBinding, path) {
|
|
227
|
+
verifyBoundWorkspacePath(workspaceBinding);
|
|
228
|
+
const retained = fstatSync(parentBinding.descriptor, { bigint: true });
|
|
229
|
+
let current;
|
|
230
|
+
try {
|
|
231
|
+
current = lstatSync(parentBinding.path, { bigint: true });
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error(`approved adoption target parent identity changed during write: ${path}`);
|
|
234
|
+
}
|
|
235
|
+
const entered = lstatSync(".", { bigint: true });
|
|
236
|
+
if (
|
|
237
|
+
!retained.isDirectory()
|
|
238
|
+
|| current.isSymbolicLink()
|
|
239
|
+
|| !current.isDirectory()
|
|
240
|
+
|| !entered.isDirectory()
|
|
241
|
+
|| !sameFile(parentBinding.metadata, retained)
|
|
242
|
+
|| !sameFile(parentBinding.metadata, current)
|
|
243
|
+
|| !sameFile(parentBinding.metadata, entered)
|
|
244
|
+
|| !confined(workspaceBinding.root, parentBinding.path)
|
|
245
|
+
) {
|
|
246
|
+
throw new Error(`approved adoption target parent identity changed during write: ${path}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function withBoundAdoptionParent(root, path, workspaceMetadata, operation) {
|
|
251
|
+
const parentPath = dirname(path);
|
|
252
|
+
const segments = parentPath === "." ? [] : parentPath.split("/");
|
|
253
|
+
const originalDirectory = process.cwd();
|
|
254
|
+
const created = [];
|
|
255
|
+
let operationError;
|
|
256
|
+
let parentDescriptor;
|
|
257
|
+
try {
|
|
258
|
+
process.chdir(root);
|
|
259
|
+
const enteredWorkspace = lstatSync(".", { bigint: true });
|
|
260
|
+
if (!enteredWorkspace.isDirectory() || !sameFile(workspaceMetadata, enteredWorkspace)) {
|
|
261
|
+
throw new Error("approved workspace directory identity changed before write");
|
|
262
|
+
}
|
|
263
|
+
for (const segment of segments) {
|
|
264
|
+
let metadata = linkAwareStat(segment);
|
|
265
|
+
let directoryCreated = false;
|
|
266
|
+
if (metadata === null) {
|
|
267
|
+
try {
|
|
268
|
+
mkdirSync(segment, { mode: 0o755 });
|
|
269
|
+
directoryCreated = true;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
|
|
272
|
+
}
|
|
273
|
+
metadata = lstatSync(segment);
|
|
274
|
+
}
|
|
275
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
276
|
+
throw new Error(`adoption target parent is not a regular directory: ${path}`);
|
|
277
|
+
}
|
|
278
|
+
process.chdir(segment);
|
|
279
|
+
const observed = realpathSync(".");
|
|
280
|
+
if (!confined(root, observed)) throw new Error(`adoption target parent escapes workspace: ${path}`);
|
|
281
|
+
if (directoryCreated) created.push({ path: observed, metadata: lstatSync(".") });
|
|
282
|
+
}
|
|
283
|
+
const boundParent = realpathSync(".");
|
|
284
|
+
const observedParent = lstatSync(".", { bigint: true });
|
|
285
|
+
parentDescriptor = openSync(
|
|
286
|
+
".",
|
|
287
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_DIRECTORY ?? 0),
|
|
288
|
+
);
|
|
289
|
+
const openedParent = fstatSync(parentDescriptor, { bigint: true });
|
|
290
|
+
if (
|
|
291
|
+
observedParent.isSymbolicLink()
|
|
292
|
+
|| !observedParent.isDirectory()
|
|
293
|
+
|| !openedParent.isDirectory()
|
|
294
|
+
|| !sameFile(observedParent, openedParent)
|
|
295
|
+
) {
|
|
296
|
+
throw new Error(`approved adoption target parent identity changed before write: ${path}`);
|
|
297
|
+
}
|
|
298
|
+
return operation(basename(path), {
|
|
299
|
+
path: boundParent,
|
|
300
|
+
descriptor: parentDescriptor,
|
|
301
|
+
metadata: openedParent,
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
operationError = error;
|
|
305
|
+
throw error;
|
|
306
|
+
} finally {
|
|
307
|
+
if (parentDescriptor !== undefined) closeSync(parentDescriptor);
|
|
308
|
+
let restoreError;
|
|
309
|
+
try { process.chdir(originalDirectory); }
|
|
310
|
+
catch (error) { restoreError = error; }
|
|
311
|
+
if (operationError !== undefined) removeCreatedParents(root, created);
|
|
312
|
+
if (operationError === undefined && restoreError !== undefined) throw restoreError;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function writeAll(descriptor, content) {
|
|
317
|
+
const buffer = Buffer.from(content, "utf8");
|
|
318
|
+
let offset = 0;
|
|
319
|
+
while (offset < buffer.length) {
|
|
320
|
+
const written = writeSync(descriptor, buffer, offset, buffer.length - offset, null);
|
|
321
|
+
if (written <= 0) throw new Error("adoption target write made no progress");
|
|
322
|
+
offset += written;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
22
326
|
export function loadHostAdapters(pluginRoot) {
|
|
23
|
-
const
|
|
327
|
+
const entries = [];
|
|
24
328
|
const errors = [];
|
|
329
|
+
for (const name of adapterFiles(pluginRoot)) {
|
|
330
|
+
try { entries.push({ name, adapter: JSON.parse(read(resolve(pluginRoot, "adapters", name))) }); }
|
|
331
|
+
catch { errors.push(`${name}: host adapter is not valid JSON`); }
|
|
332
|
+
}
|
|
25
333
|
const ids = new Set();
|
|
26
|
-
|
|
334
|
+
const bindingTargets = new Map();
|
|
335
|
+
for (const { name, adapter } of entries) {
|
|
27
336
|
const validation = validateContract("host-adapter", adapter);
|
|
28
|
-
if (!validation.valid)
|
|
29
|
-
|
|
30
|
-
|
|
337
|
+
if (!validation.valid) {
|
|
338
|
+
errors.push(...validation.errors.map((error) => `${name}: ${error}`));
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (typeof adapter.id === "string") {
|
|
342
|
+
if (ids.has(adapter.id)) errors.push(`duplicate host adapter: ${adapter.id}`);
|
|
343
|
+
ids.add(adapter.id);
|
|
344
|
+
}
|
|
345
|
+
if (typeof adapter.bindingTarget === "string") {
|
|
346
|
+
const targetIdentity = portableTargetIdentity(adapter.bindingTarget);
|
|
347
|
+
const reservedIdentity = portableTargetIdentity("FORGERAIL.md");
|
|
348
|
+
if (targetIdentitiesConflict(targetIdentity, reservedIdentity)) errors.push(`${adapter.id ?? name}: binding target conflicts with the reserved shared contract: ${adapter.bindingTarget}`);
|
|
349
|
+
const collision = [...bindingTargets.entries()].find(([existing]) => targetIdentitiesConflict(targetIdentity, existing));
|
|
350
|
+
if (collision) errors.push(`${adapter.id ?? name}: binding target conflicts with ${collision[1]}: ${adapter.bindingTarget}`);
|
|
351
|
+
else bindingTargets.set(targetIdentity, adapter.id ?? name);
|
|
352
|
+
}
|
|
353
|
+
if (validation.valid) {
|
|
354
|
+
for (const mode of adapter.bindingModes) {
|
|
355
|
+
try {
|
|
356
|
+
const content = readBindingTemplate(pluginRoot, adapter, mode);
|
|
357
|
+
validateBindingTemplateMarkers(adapter, mode, content);
|
|
358
|
+
}
|
|
359
|
+
catch (error) { errors.push(`${adapter.id}: ${error.message}`); }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { valid: errors.length === 0, errors, adapters: entries.map(({ adapter }) => adapter) };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function validateBindingTemplateMarkers(adapter, mode, content) {
|
|
367
|
+
const start = `<!-- ${adapter.managedMarker}:start -->`;
|
|
368
|
+
const end = `<!-- ${adapter.managedMarker}:end -->`;
|
|
369
|
+
const startCount = countLiteralOccurrences(content, start);
|
|
370
|
+
const endCount = countLiteralOccurrences(content, end);
|
|
371
|
+
if (startCount !== 1 || endCount !== 1 || content.indexOf(start) > content.indexOf(end)) {
|
|
372
|
+
throw new Error(`binding template for ${mode} must contain exactly one ordered ${adapter.managedMarker} boundary`);
|
|
373
|
+
}
|
|
374
|
+
const managed = content.slice(content.indexOf(start) + start.length, content.indexOf(end));
|
|
375
|
+
if (mode === "thin-reference" && !/(?<![A-Za-z0-9_./\\-])FORGERAIL\.md(?![A-Za-z0-9_./\\-])/.test(managed)) {
|
|
376
|
+
throw new Error("thin-reference template must contain the shared contract reference FORGERAIL.md inside its managed block");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function readBindingTemplate(pluginRoot, adapter, mode) {
|
|
381
|
+
const path = adapter.bindingTemplates?.[mode];
|
|
382
|
+
if (typeof path !== "string" || !portableRelativePath.test(path)) {
|
|
383
|
+
throw new Error(`binding template for ${mode} is missing or unsafe`);
|
|
384
|
+
}
|
|
385
|
+
const templateRoot = realpathSync(resolve(pluginRoot, "templates"));
|
|
386
|
+
let cursor = templateRoot;
|
|
387
|
+
const segments = path.split("/");
|
|
388
|
+
for (const [index, segment] of segments.entries()) {
|
|
389
|
+
const candidate = resolve(cursor, segment);
|
|
390
|
+
if (!confined(templateRoot, candidate)) throw new Error(`binding template escapes template root: ${path}`);
|
|
391
|
+
const metadata = linkAwareStat(candidate);
|
|
392
|
+
if (metadata === null) throw new Error(`binding template does not exist: ${path}`);
|
|
393
|
+
if (metadata.isSymbolicLink()) throw new Error(`binding template cannot traverse a symbolic link: ${path}`);
|
|
394
|
+
const final = index === segments.length - 1;
|
|
395
|
+
if (final && !metadata.isFile()) throw new Error(`binding template is not a regular file: ${path}`);
|
|
396
|
+
if (!final && !metadata.isDirectory()) throw new Error(`binding template ancestor is not a directory: ${path}`);
|
|
397
|
+
cursor = candidate;
|
|
398
|
+
}
|
|
399
|
+
let descriptor;
|
|
400
|
+
try {
|
|
401
|
+
const before = lstatSync(cursor);
|
|
402
|
+
descriptor = openSync(cursor, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
403
|
+
const opened = fstatSync(descriptor);
|
|
404
|
+
const observed = realpathSync(cursor);
|
|
405
|
+
const after = lstatSync(observed);
|
|
406
|
+
if (!confined(templateRoot, observed) || after.isSymbolicLink() || !sameFile(after, opened)) throw new Error(`binding template escaped or changed before read: ${path}`);
|
|
407
|
+
if (!opened.isFile() || !sameFile(before, opened)) throw new Error(`binding template identity changed before read: ${path}`);
|
|
408
|
+
return readFileSync(descriptor, "utf8");
|
|
409
|
+
} finally {
|
|
410
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function detectionTargetPresent(root, path) {
|
|
415
|
+
let cursor = root;
|
|
416
|
+
for (const segment of path.split("/")) {
|
|
417
|
+
const candidate = resolve(cursor, segment);
|
|
418
|
+
if (!confined(root, candidate)) throw new Error(`host detection target escapes workspace: ${path}`);
|
|
419
|
+
const metadata = linkAwareStat(candidate);
|
|
420
|
+
if (metadata === null) return false;
|
|
421
|
+
if (metadata.isSymbolicLink()) return true;
|
|
422
|
+
cursor = candidate;
|
|
31
423
|
}
|
|
32
|
-
return
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function resolveHostSelection(root, adapters, hostIds, selectionMode) {
|
|
428
|
+
if (!Array.isArray(hostIds)) throw new Error("host selection must be an array");
|
|
429
|
+
if (new Set(hostIds).size !== hostIds.length) throw new Error("host selection contains duplicates");
|
|
430
|
+
const mode = selectionMode ?? (hostIds.length > 0 ? "explicit" : "all-detected");
|
|
431
|
+
if (!hostSelectionModes.has(mode)) throw new Error(`unknown host selection mode: ${mode}`);
|
|
432
|
+
if (mode === "explicit" && hostIds.length === 0) throw new Error("explicit host selection requires at least one --host");
|
|
433
|
+
if (mode !== "explicit" && hostIds.length > 0) throw new Error(`${mode} host selection cannot be combined with --host`);
|
|
434
|
+
|
|
435
|
+
const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
436
|
+
let selected;
|
|
437
|
+
if (mode === "explicit") {
|
|
438
|
+
selected = hostIds.map((id) => {
|
|
439
|
+
const adapter = byId.get(id);
|
|
440
|
+
if (!adapter) throw new Error(`unknown host adapter: ${id}`);
|
|
441
|
+
return adapter;
|
|
442
|
+
});
|
|
443
|
+
} else if (mode === "all-available") {
|
|
444
|
+
selected = [...adapters];
|
|
445
|
+
} else {
|
|
446
|
+
selected = adapters.filter((adapter) => adapter.detectionTargets.some((path) => detectionTargetPresent(root, path)));
|
|
447
|
+
if (selected.length === 0) {
|
|
448
|
+
throw new Error("no registered host was detected; select an explicit --host or use --selection all-available");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
mode,
|
|
453
|
+
selected,
|
|
454
|
+
};
|
|
33
455
|
}
|
|
34
456
|
|
|
35
457
|
export function observeAdoptionLevel(workspace, adapters = []) {
|
|
36
|
-
const root = resolve(workspace);
|
|
458
|
+
const root = realpathSync(resolve(workspace));
|
|
37
459
|
if (existsSync(resolve(root, ".forgerail"))) return "persisted-governance";
|
|
38
460
|
if (existsSync(resolve(root, "FORGERAIL.md"))) return "lightweight-adoption";
|
|
39
461
|
for (const adapter of adapters) {
|
|
40
|
-
const target =
|
|
41
|
-
if (existsSync(target) &&
|
|
462
|
+
const target = adoptionTarget(root, adapter.bindingTarget);
|
|
463
|
+
if (existsSync(target) && readAdoptionTarget(target, adapter.bindingTarget).includes(`<!-- ${adapter.managedMarker}:start -->`)) return "lightweight-adoption";
|
|
42
464
|
}
|
|
43
465
|
return "plugin-only";
|
|
44
466
|
}
|
|
45
467
|
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
468
|
+
function countLiteralOccurrences(content, marker) {
|
|
469
|
+
let count = 0;
|
|
470
|
+
let offset = 0;
|
|
471
|
+
while ((offset = content.indexOf(marker, offset)) >= 0) {
|
|
472
|
+
count += 1;
|
|
473
|
+
offset += marker.length;
|
|
474
|
+
}
|
|
475
|
+
return count;
|
|
51
476
|
}
|
|
52
477
|
|
|
53
|
-
function proposedWrite(workspace, path, content, managedMarker) {
|
|
54
|
-
const target =
|
|
478
|
+
function proposedWrite(workspace, workspaceSha256, path, content, managedMarker, unmanagedBindingPolicy = "append-managed-block") {
|
|
479
|
+
const target = adoptionTarget(workspace, path);
|
|
55
480
|
const exists = existsSync(target);
|
|
56
481
|
if (exists && !statSync(target).isFile()) throw new Error(`adoption target is not a file: ${path}`);
|
|
57
|
-
const prior = exists ?
|
|
482
|
+
const prior = exists ? readAdoptionTarget(target, path) : null;
|
|
58
483
|
const start = `<!-- ${managedMarker}:start -->`;
|
|
59
484
|
const end = `<!-- ${managedMarker}:end -->`;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
485
|
+
const startCount = prior === null ? 0 : countLiteralOccurrences(prior, start);
|
|
486
|
+
const endCount = prior === null ? 0 : countLiteralOccurrences(prior, end);
|
|
487
|
+
const hasStart = startCount > 0;
|
|
488
|
+
const hasEnd = endCount > 0;
|
|
62
489
|
if (hasStart !== hasEnd) throw new Error(`adoption target has an incomplete managed marker: ${path}`);
|
|
63
490
|
if (hasStart && prior.indexOf(start) > prior.indexOf(end)) throw new Error(`adoption target has reversed managed markers: ${path}`);
|
|
64
|
-
if (
|
|
65
|
-
if (exists &&
|
|
491
|
+
if (startCount > 1 || endCount > 1) throw new Error(`adoption target has duplicate managed markers: ${path}`);
|
|
492
|
+
if (exists && !hasStart && unmanagedBindingPolicy === "reject") {
|
|
493
|
+
throw new Error(`Host binding target already exists without a ForgeRail managed marker: ${path}`);
|
|
494
|
+
}
|
|
66
495
|
const operation = exists ? (hasStart ? "replace-managed-block" : "append-managed-block") : "create";
|
|
67
496
|
const approvedContent = operation === "replace-managed-block" && content.indexOf(start) > 0
|
|
68
497
|
? `${content.slice(content.indexOf(start), content.indexOf(end) + end.length)}\n`
|
|
69
498
|
: content;
|
|
70
|
-
|
|
499
|
+
const write = {
|
|
500
|
+
workspaceSha256,
|
|
71
501
|
path,
|
|
72
502
|
operation,
|
|
73
503
|
baseSha256: prior === null ? null : sha256(prior),
|
|
@@ -75,57 +505,235 @@ function proposedWrite(workspace, path, content, managedMarker) {
|
|
|
75
505
|
content: approvedContent,
|
|
76
506
|
managedMarker,
|
|
77
507
|
};
|
|
508
|
+
return { ...write, approvalSha256: adoptionWriteApprovalDigest(write) };
|
|
78
509
|
}
|
|
79
510
|
|
|
80
511
|
export function renderProposedWrite(workspace, write) {
|
|
81
|
-
const target =
|
|
82
|
-
const prior = existsSync(target) ?
|
|
83
|
-
|
|
512
|
+
const target = adoptionTarget(workspace, write.path);
|
|
513
|
+
const prior = existsSync(target) ? readAdoptionTarget(target, write.path) : "";
|
|
514
|
+
return renderApprovedWriteContent(write, prior);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function renderApprovedWriteContent(write, prior) {
|
|
518
|
+
const content = approvedContent(write);
|
|
519
|
+
if (write.operation === "create") return content;
|
|
84
520
|
if (sha256(prior) !== write.baseSha256) throw new Error(`base digest drifted for ${write.path}`);
|
|
85
|
-
if (write.operation === "append-managed-block") return `${prior.replace(/\s*$/, "")}\n\n${
|
|
521
|
+
if (write.operation === "append-managed-block") return `${prior.replace(/\s*$/, "")}\n\n${content}`;
|
|
86
522
|
const start = `<!-- ${write.managedMarker}:start -->`;
|
|
87
523
|
const end = `<!-- ${write.managedMarker}:end -->`;
|
|
88
524
|
const startIndex = prior.indexOf(start);
|
|
89
525
|
const endIndex = prior.indexOf(end, startIndex);
|
|
90
526
|
if (startIndex < 0 || endIndex < 0) throw new Error(`managed block is missing for ${write.path}`);
|
|
91
|
-
return `${prior.slice(0, startIndex)}${
|
|
527
|
+
return `${prior.slice(0, startIndex)}${content}${prior.slice(endIndex + end.length)}`;
|
|
92
528
|
}
|
|
93
529
|
|
|
94
|
-
export function
|
|
530
|
+
export function applyApprovedAdoptionWrite(workspace, write, approvedWriteDigest, testHooks = {}) {
|
|
531
|
+
const binding = openBoundWorkspace(workspace);
|
|
532
|
+
try {
|
|
533
|
+
const { root } = binding;
|
|
534
|
+
const approvedWrite = verifyApprovedWrite(write, approvedWriteDigest, binding.workspaceSha256);
|
|
535
|
+
verifyBoundWorkspacePath(binding);
|
|
536
|
+
const creating = approvedWrite.operation === "create";
|
|
537
|
+
return withBoundAdoptionParent(root, approvedWrite.path, binding.metadata, (leaf, parentBinding) => {
|
|
538
|
+
const boundParent = parentBinding.path;
|
|
539
|
+
if (!confined(root, boundParent)) throw new Error(`adoption target parent moved outside workspace: ${approvedWrite.path}`);
|
|
540
|
+
const identity = randomBytes(12).toString("hex");
|
|
541
|
+
const temporary = `.forgerail-${identity}.tmp`;
|
|
542
|
+
let backup;
|
|
543
|
+
let sourceDescriptor;
|
|
544
|
+
let temporaryDescriptor;
|
|
545
|
+
let directoryDescriptor;
|
|
546
|
+
let temporaryExists = false;
|
|
547
|
+
let backupExists = false;
|
|
548
|
+
let createdTarget = false;
|
|
549
|
+
let replacementInstalled = false;
|
|
550
|
+
let preserveBackup = false;
|
|
551
|
+
let temporaryStat;
|
|
552
|
+
let sourceStat;
|
|
553
|
+
let content;
|
|
554
|
+
try {
|
|
555
|
+
const pathStat = linkAwareStat(leaf);
|
|
556
|
+
if (creating) {
|
|
557
|
+
if (pathStat !== null) throw new Error(`adoption target changed before write: ${approvedWrite.path}`);
|
|
558
|
+
content = renderApprovedWriteContent(approvedWrite, "");
|
|
559
|
+
} else {
|
|
560
|
+
if (pathStat === null || !pathStat.isFile()) {
|
|
561
|
+
throw new Error(`adoption target is not a regular file: ${approvedWrite.path}`);
|
|
562
|
+
}
|
|
563
|
+
sourceDescriptor = openSync(leaf, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
564
|
+
sourceStat = fstatSync(sourceDescriptor);
|
|
565
|
+
if (!sourceStat.isFile() || pathStat.isSymbolicLink() || !sameFile(sourceStat, pathStat)) {
|
|
566
|
+
throw new Error(`adoption target changed before write: ${approvedWrite.path}`);
|
|
567
|
+
}
|
|
568
|
+
const observed = realpathSync(leaf);
|
|
569
|
+
if (!confined(root, observed)) throw new Error(`adoption target escapes workspace before write: ${approvedWrite.path}`);
|
|
570
|
+
const current = readFileSync(sourceDescriptor, "utf8");
|
|
571
|
+
content = renderApprovedWriteContent(approvedWrite, current);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const mode = creating ? 0o644 : sourceStat.mode & 0o777;
|
|
575
|
+
temporaryDescriptor = openSync(
|
|
576
|
+
temporary,
|
|
577
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
578
|
+
mode,
|
|
579
|
+
);
|
|
580
|
+
temporaryExists = true;
|
|
581
|
+
fchmodSync(temporaryDescriptor, mode);
|
|
582
|
+
writeAll(temporaryDescriptor, content);
|
|
583
|
+
fsyncSync(temporaryDescriptor);
|
|
584
|
+
temporaryStat = fstatSync(temporaryDescriptor);
|
|
585
|
+
closeSync(temporaryDescriptor);
|
|
586
|
+
temporaryDescriptor = undefined;
|
|
587
|
+
|
|
588
|
+
directoryDescriptor = openSync(".", constants.O_RDONLY);
|
|
589
|
+
if (typeof testHooks.beforeInstall === "function") testHooks.beforeInstall();
|
|
590
|
+
verifyBoundAdoptionParentPath(binding, parentBinding, approvedWrite.path);
|
|
591
|
+
if (creating) {
|
|
592
|
+
linkSync(temporary, leaf);
|
|
593
|
+
createdTarget = true;
|
|
594
|
+
} else {
|
|
595
|
+
const finalPathStat = lstatSync(leaf);
|
|
596
|
+
if (finalPathStat.isSymbolicLink() || !sameFile(sourceStat, finalPathStat)) {
|
|
597
|
+
throw new Error(`adoption target changed before replace: ${approvedWrite.path}`);
|
|
598
|
+
}
|
|
599
|
+
backup = `.forgerail-${randomBytes(12).toString("hex")}.bak`;
|
|
600
|
+
if (linkAwareStat(backup) !== null) throw new Error(`adoption recovery path already exists: ${approvedWrite.path}`);
|
|
601
|
+
linkSync(leaf, backup);
|
|
602
|
+
backupExists = true;
|
|
603
|
+
const detached = lstatSync(backup);
|
|
604
|
+
if (detached.isSymbolicLink() || !sameFile(sourceStat, detached)) {
|
|
605
|
+
throw new Error(`adoption target changed while preparing replacement: ${approvedWrite.path}`);
|
|
606
|
+
}
|
|
607
|
+
const beforeReplace = lstatSync(leaf);
|
|
608
|
+
if (beforeReplace.isSymbolicLink() || !sameFile(sourceStat, beforeReplace)) {
|
|
609
|
+
throw new Error(`adoption target changed before atomic replace: ${approvedWrite.path}`);
|
|
610
|
+
}
|
|
611
|
+
if (typeof testHooks.beforeReplace === "function") testHooks.beforeReplace();
|
|
612
|
+
const installPathStat = lstatSync(leaf);
|
|
613
|
+
if (installPathStat.isSymbolicLink() || !sameFile(sourceStat, installPathStat)) {
|
|
614
|
+
throw new Error(`adoption target changed before atomic replace: ${approvedWrite.path}`);
|
|
615
|
+
}
|
|
616
|
+
renameSync(temporary, leaf);
|
|
617
|
+
temporaryExists = false;
|
|
618
|
+
replacementInstalled = true;
|
|
619
|
+
}
|
|
620
|
+
if (typeof testHooks.afterInstall === "function") testHooks.afterInstall();
|
|
621
|
+
verifyBoundAdoptionParentPath(binding, parentBinding, approvedWrite.path);
|
|
622
|
+
const installed = linkAwareStat(leaf);
|
|
623
|
+
if (installed === null || installed.isSymbolicLink() || !sameFile(temporaryStat, installed)) {
|
|
624
|
+
throw new Error(`adoption target identity mismatch after write: ${approvedWrite.path}`);
|
|
625
|
+
}
|
|
626
|
+
fsyncSync(directoryDescriptor);
|
|
627
|
+
if (creating) {
|
|
628
|
+
unlinkSync(temporary);
|
|
629
|
+
temporaryExists = false;
|
|
630
|
+
} else {
|
|
631
|
+
unlinkSync(backup);
|
|
632
|
+
backupExists = false;
|
|
633
|
+
fsyncSync(directoryDescriptor);
|
|
634
|
+
}
|
|
635
|
+
verifyBoundAdoptionParentPath(binding, parentBinding, approvedWrite.path);
|
|
636
|
+
return { path: approvedWrite.path, contentSha256: sha256(content) };
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (replacementInstalled && backupExists) {
|
|
639
|
+
try {
|
|
640
|
+
const installed = linkAwareStat(leaf);
|
|
641
|
+
if (installed === null) {
|
|
642
|
+
renameSync(backup, leaf);
|
|
643
|
+
backupExists = false;
|
|
644
|
+
replacementInstalled = false;
|
|
645
|
+
} else if (temporaryStat !== undefined && !installed.isSymbolicLink() && sameFile(temporaryStat, installed)) {
|
|
646
|
+
renameSync(backup, leaf);
|
|
647
|
+
backupExists = false;
|
|
648
|
+
replacementInstalled = false;
|
|
649
|
+
} else {
|
|
650
|
+
preserveBackup = true;
|
|
651
|
+
}
|
|
652
|
+
if (directoryDescriptor !== undefined) fsyncSync(directoryDescriptor);
|
|
653
|
+
} catch {
|
|
654
|
+
preserveBackup = true;
|
|
655
|
+
}
|
|
656
|
+
} else if (createdTarget) {
|
|
657
|
+
try {
|
|
658
|
+
const installed = lstatSync(leaf);
|
|
659
|
+
if (temporaryStat !== undefined && sameFile(temporaryStat, installed)) unlinkSync(leaf);
|
|
660
|
+
} catch {}
|
|
661
|
+
}
|
|
662
|
+
if (preserveBackup && backup !== undefined && error instanceof Error) {
|
|
663
|
+
const parent = dirname(approvedWrite.path);
|
|
664
|
+
const recoveryPath = parent === "." ? backup : `${parent}/${backup}`;
|
|
665
|
+
error.message = `${error.message}; recovery evidence retained at ${recoveryPath}`;
|
|
666
|
+
}
|
|
667
|
+
throw error;
|
|
668
|
+
} finally {
|
|
669
|
+
if (sourceDescriptor !== undefined) closeSync(sourceDescriptor);
|
|
670
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
671
|
+
if (directoryDescriptor !== undefined) closeSync(directoryDescriptor);
|
|
672
|
+
if (temporaryExists) {
|
|
673
|
+
try { unlinkSync(temporary); } catch {}
|
|
674
|
+
}
|
|
675
|
+
if (backupExists && !preserveBackup) {
|
|
676
|
+
try { unlinkSync(backup); } catch {}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
} finally {
|
|
681
|
+
closeSync(binding.descriptor);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function planAdoption(pluginRoot, workspace, hostIds = [], proposedLevel = "lightweight-adoption", selectionMode) {
|
|
95
686
|
const root = resolve(workspace);
|
|
96
687
|
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error("workspace must be an existing directory");
|
|
688
|
+
const binding = openBoundWorkspace(root);
|
|
689
|
+
const realRoot = binding.root;
|
|
690
|
+
try {
|
|
97
691
|
if (!levels.includes(proposedLevel)) throw new Error(`unknown adoption level: ${proposedLevel}`);
|
|
98
692
|
if (proposedLevel === "persisted-governance") throw new Error("persisted-governance is evidence-gated and deferred in ForgeRail alpha.1");
|
|
99
|
-
if (!Array.isArray(hostIds) || hostIds.length === 0) throw new Error("at least one explicit --host is required");
|
|
100
|
-
if (new Set(hostIds).size !== hostIds.length) throw new Error("host selection contains duplicates");
|
|
101
693
|
const registry = loadHostAdapters(pluginRoot);
|
|
102
694
|
if (!registry.valid) throw new Error(`host adapter registry is invalid: ${registry.errors.join("; ")}`);
|
|
103
|
-
const
|
|
104
|
-
const selected =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
});
|
|
109
|
-
const
|
|
110
|
-
|
|
695
|
+
const selection = resolveHostSelection(realRoot, registry.adapters, hostIds, selectionMode);
|
|
696
|
+
const selected = selection.selected;
|
|
697
|
+
const selectedLevel = observeAdoptionLevel(realRoot, selected);
|
|
698
|
+
let currentLevel = selectedLevel;
|
|
699
|
+
const unselectedEvidence = [];
|
|
700
|
+
const selectedIds = new Set(selected.map(({ id }) => id));
|
|
701
|
+
for (const adapter of registry.adapters.filter(({ id }) => !selectedIds.has(id))) {
|
|
702
|
+
const inspected = inspectBoundedPath(realRoot, adapter.bindingTarget, { finalKind: "file", read: true });
|
|
703
|
+
if (inspected.state === "available" && inspected.content.includes(`<!-- ${adapter.managedMarker}:start -->`)) {
|
|
704
|
+
if (currentLevel === "plugin-only") currentLevel = "lightweight-adoption";
|
|
705
|
+
unselectedEvidence.push(`Unselected managed binding retained: ${adapter.bindingTarget}. This plan does not migrate or consolidate its rules into the selected hosts' contract; review coexistence before approval.`);
|
|
706
|
+
} else if (inspected.present && inspected.state !== "available") {
|
|
707
|
+
unselectedEvidence.push(`Unselected binding unavailable: ${adapter.bindingTarget} (${inspected.state}). Adoption level reflects only readable evidence; this entry is not followed, changed or a selected-host blocker.`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (selectedLevel !== "plugin-only" && proposedLevel === "plugin-only") throw new Error("adoption removal or downgrade requires a separate reviewed plan and is not generated by alpha.1");
|
|
111
711
|
if (currentLevel === "persisted-governance") throw new Error("persisted-governance was observed; alpha.1 will diagnose it but will not generate replacement or downgrade writes");
|
|
112
|
-
const strategy = proposedLevel === "plugin-only"
|
|
712
|
+
const strategy = proposedLevel === "plugin-only"
|
|
713
|
+
? "no-change"
|
|
714
|
+
: selected.length === 1 && selected[0].bindingModes.includes("managed-block")
|
|
715
|
+
? "single-host-managed-block"
|
|
716
|
+
: "shared-contract-with-thin-bindings";
|
|
113
717
|
const writes = [];
|
|
114
718
|
if (strategy === "single-host-managed-block") {
|
|
115
719
|
const adapter = selected[0];
|
|
116
720
|
if (!adapter.bindingModes.includes("managed-block")) throw new Error(`${adapter.id} does not support a managed-block binding`);
|
|
117
|
-
const content =
|
|
118
|
-
writes.push(proposedWrite(
|
|
721
|
+
const content = readBindingTemplate(pluginRoot, adapter, "managed-block");
|
|
722
|
+
writes.push(proposedWrite(realRoot, binding.workspaceSha256, adapter.bindingTarget, content, adapter.managedMarker, adapter.unmanagedBindingPolicy));
|
|
119
723
|
} else if (strategy === "shared-contract-with-thin-bindings") {
|
|
120
724
|
const contract = read(resolve(pluginRoot, "templates/FORGERAIL.md")).replace("{{HOSTS}}", selected.map((adapter) => adapter.displayName).join(", "));
|
|
121
|
-
writes.push(proposedWrite(
|
|
725
|
+
writes.push(proposedWrite(realRoot, binding.workspaceSha256, "FORGERAIL.md", contract, "forgerail:adoption-contract:v1"));
|
|
122
726
|
for (const adapter of selected) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
writes.push(proposedWrite(root, adapter.bindingTarget, content, adapter.managedMarker));
|
|
727
|
+
const content = readBindingTemplate(pluginRoot, adapter, "thin-reference");
|
|
728
|
+
writes.push(proposedWrite(realRoot, binding.workspaceSha256, adapter.bindingTarget, content, adapter.managedMarker, adapter.unmanagedBindingPolicy));
|
|
126
729
|
}
|
|
127
730
|
}
|
|
128
|
-
const
|
|
731
|
+
const selectedHosts = Object.fromEntries(selected.map((adapter) => [adapter.id, {
|
|
732
|
+
status: adapter.status,
|
|
733
|
+
bindingTarget: adapter.bindingTarget,
|
|
734
|
+
verificationMode: adapter.verification.mode,
|
|
735
|
+
}]));
|
|
736
|
+
const identity = sha256(JSON.stringify({ workspace: basename(root), currentLevel, proposedLevel, strategy, hostSelection: { mode: selection.mode, hosts: selectedHosts }, writes: writes.map(({ approvalSha256 }) => approvalSha256) })).slice(0, 20);
|
|
129
737
|
const plan = {
|
|
130
738
|
schemaVersion: "1.0",
|
|
131
739
|
planId: `adoption:${identity}`,
|
|
@@ -133,12 +741,16 @@ export function planAdoption(pluginRoot, workspace, hostIds, proposedLevel = "li
|
|
|
133
741
|
currentLevel,
|
|
134
742
|
proposedLevel,
|
|
135
743
|
strategy,
|
|
744
|
+
hostSelection: {
|
|
745
|
+
mode: selection.mode,
|
|
746
|
+
hosts: selectedHosts,
|
|
747
|
+
},
|
|
136
748
|
evidence: [
|
|
137
749
|
`Observed current adoption level: ${currentLevel}.`,
|
|
138
|
-
`
|
|
750
|
+
`Host selection mode ${selection.mode} resolved adapters: ${selected.map((adapter) => adapter.id).join(", ")}.`,
|
|
751
|
+
...unselectedEvidence,
|
|
139
752
|
"ForgeRail alpha.1 does not generate persisted .forgerail state.",
|
|
140
753
|
],
|
|
141
|
-
hosts: selected.map((adapter) => ({ adapterId: adapter.id, status: adapter.status, bindingTarget: adapter.bindingTarget, verificationMode: adapter.verification.mode })),
|
|
142
754
|
proposedWrites: writes,
|
|
143
755
|
requiredConfirmation: true,
|
|
144
756
|
verification: selected.map((adapter) => adapter.status === "supported"
|
|
@@ -155,4 +767,7 @@ export function planAdoption(pluginRoot, workspace, hostIds, proposedLevel = "li
|
|
|
155
767
|
const validation = validateContract("adoption-plan", plan);
|
|
156
768
|
if (!validation.valid) throw new Error(`generated adoption plan is invalid: ${validation.errors.join("; ")}`);
|
|
157
769
|
return plan;
|
|
770
|
+
} finally {
|
|
771
|
+
closeSync(binding.descriptor);
|
|
772
|
+
}
|
|
158
773
|
}
|