@ikon85/agent-workflow-kit 0.34.4 → 0.34.6
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/.agents/skills/kit-release/SKILL.md +29 -21
- package/.agents/skills/kit-update/SKILL.md +6 -3
- package/.agents/skills/setup-workflow/SKILL.md +13 -5
- package/.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
- package/.claude/skills/kit-release/SKILL.md +29 -21
- package/.claude/skills/kit-update/SKILL.md +6 -3
- package/.claude/skills/setup-workflow/SKILL.md +13 -5
- package/.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
- package/README.md +15 -0
- package/agent-workflow-kit.package.json +10 -10
- package/docs/adr/0001-consumer-divergence-policy.md +4 -0
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +63 -0
- package/docs/adr/0004-release-intent-is-a-version-tag.md +18 -5
- package/docs/agents/board-sync.md +1 -1
- package/docs/agents/workflow-capabilities.json +17 -0
- package/docs/research/benchlm-routing-source.md +198 -0
- package/docs/research/consumer-owned-protocol-files.md +238 -0
- package/docs/research/frontend-agent-benchmarks.md +282 -0
- package/docs/research/model-effort-routing-benchmarks.md +261 -0
- package/docs/research/provider-neutral-agent-routing.md +207 -0
- package/package.json +1 -1
- package/scripts/kit-release.test.mjs +10 -4
- package/scripts/kit-update-pr.mjs +1 -1
- package/scripts/kit-update-pr.test.mjs +2 -0
- package/scripts/test_release_authorization_contract.py +101 -0
- package/scripts/test_skill_readiness_contract.py +6 -1
- package/scripts/test_skill_setup_workflow_seeds.py +18 -17
- package/src/commands/update.mjs +52 -20
- package/src/lib/bundle.mjs +1 -1
- package/src/lib/updateCandidate.mjs +278 -50
- package/src/lib/verifyUpdateCandidate.mjs +220 -0
- package/src/lib/verifyUpdateCandidateArtifacts.mjs +78 -0
- package/src/lib/verifyUpdateCandidateProtocol.mjs +221 -0
- package/src/lib/verifyUpdateCandidateTransaction.mjs +152 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
access, lstat, mkdtemp, open, readFile, realpath, rm, stat,
|
|
4
|
+
} from 'node:fs/promises';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
+
import {
|
|
7
|
+
isAbsolute, join, normalize, posix, relative, sep,
|
|
8
|
+
} from 'node:path';
|
|
6
9
|
import { writeAtomic } from './atomicWrite.mjs';
|
|
7
10
|
import { validateConsumerFile } from './consumerPath.mjs';
|
|
8
11
|
import { sha256File } from './hash.mjs';
|
|
@@ -10,38 +13,195 @@ import { stubSentinel } from './sentinel.mjs';
|
|
|
10
13
|
import { STUB_TARGETS } from './bundle.mjs';
|
|
11
14
|
import {
|
|
12
15
|
CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, READINESS_MANIFEST_PATH,
|
|
13
|
-
indexByPath, readManifest, writeManifest,
|
|
16
|
+
filesForInstallRole, indexByPath, readManifest, writeManifest,
|
|
14
17
|
} from './manifest.mjs';
|
|
15
18
|
import { checkSkill, evaluateCapability, inspectProdSections } from '../../scripts/readiness.mjs';
|
|
16
19
|
|
|
17
|
-
const run = promisify(execFile);
|
|
18
20
|
const exists = (path) => access(path).then(() => true, () => false);
|
|
19
21
|
const pathEntryExists = (path) => lstat(path).then(() => true, (error) => {
|
|
20
22
|
if (error.code === 'ENOENT') return false;
|
|
21
23
|
throw error;
|
|
22
24
|
});
|
|
23
25
|
const MIGRATABLE_INSTRUCTION_PATHS = new Set(['CLAUDE.md', 'AGENTS.md']);
|
|
26
|
+
const INTEGRATION_INPUTS = [
|
|
27
|
+
'package.json', '.claude/settings.json', '.claude/settings.local.json',
|
|
28
|
+
];
|
|
29
|
+
const FORBIDDEN_CANDIDATE_ROOTS = new Set(['.git', '.worktrees', 'node_modules']);
|
|
30
|
+
const PLATFORM_PATH_SEMANTICS = { isAbsolute, normalize, sep };
|
|
24
31
|
|
|
25
|
-
/**
|
|
26
|
-
export async function
|
|
32
|
+
/** Materialize only manifest state and declared Consumer inputs for verification. */
|
|
33
|
+
export async function materializeUpdateCandidate({
|
|
34
|
+
consumerRoot, pkg, priorReadinessManifest, nextReadinessManifest,
|
|
35
|
+
afterInputValidation = async () => {},
|
|
36
|
+
}) {
|
|
27
37
|
const candidateRoot = await mkdtemp(join(tmpdir(), 'agent-workflow-kit-stage-'));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
try {
|
|
39
|
+
const paths = candidateInputPaths({
|
|
40
|
+
pkg, manifests: [priorReadinessManifest, nextReadinessManifest],
|
|
41
|
+
});
|
|
42
|
+
for (const path of paths) {
|
|
43
|
+
await copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation);
|
|
44
|
+
}
|
|
45
|
+
await copyDeclaredRunbooks({
|
|
46
|
+
consumerRoot, candidateRoot, manifests: [priorReadinessManifest, nextReadinessManifest],
|
|
47
|
+
afterInputValidation,
|
|
48
|
+
});
|
|
49
|
+
return candidateRoot;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await rm(candidateRoot, { recursive: true, force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function candidateInputPaths({ pkg, manifests }) {
|
|
57
|
+
const candidates = [
|
|
58
|
+
CONSUMER_MANIFEST_NAME,
|
|
59
|
+
...INTEGRATION_INPUTS,
|
|
60
|
+
...filesForInstallRole(pkg).map(({ path }) => path),
|
|
61
|
+
];
|
|
62
|
+
for (const manifest of manifests) {
|
|
63
|
+
for (const capability of Object.values(manifest?.readiness?.capabilities ?? {})) {
|
|
64
|
+
candidates.push(...(capability.evidence?.paths ?? []));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const paths = new Set(candidates.filter((path) => !isForbiddenCandidatePath(path)));
|
|
68
|
+
return [...paths].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function copyDeclaredRunbooks({
|
|
72
|
+
consumerRoot, candidateRoot, manifests, afterInputValidation,
|
|
73
|
+
}) {
|
|
74
|
+
const runbooks = await declaredCandidateRunbookPaths({ candidateRoot, manifests });
|
|
75
|
+
for (const path of runbooks) {
|
|
76
|
+
await copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function declaredCandidateRunbookPaths({ candidateRoot, manifests }) {
|
|
81
|
+
const runbooks = new Set();
|
|
82
|
+
for (const manifest of manifests) {
|
|
83
|
+
for (const capability of Object.values(manifest?.readiness?.capabilities ?? {})) {
|
|
84
|
+
const evidence = capability.evidence;
|
|
85
|
+
if (evidence?.type !== 'runbook-reference') continue;
|
|
86
|
+
const declaration = await readCandidateText(candidateRoot, evidence.paths?.[0]);
|
|
87
|
+
for (const match of declaration?.matchAll(/`([^`\n]+\.md)`/g) ?? []) {
|
|
88
|
+
if (!match[1].includes('template')) runbooks.add(match[1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...runbooks].sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readCandidateText(candidateRoot, path) {
|
|
96
|
+
if (!path) return null;
|
|
97
|
+
try {
|
|
98
|
+
return await readFile(join(candidateRoot, path), 'utf8');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT') return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function copyCandidateInput(consumerRoot, candidateRoot, path, afterInputValidation) {
|
|
106
|
+
if (isForbiddenCandidatePath(path)) return;
|
|
107
|
+
const consumerPath = validateCandidateManifestPath(path);
|
|
108
|
+
let source;
|
|
109
|
+
try {
|
|
110
|
+
source = await validateConsumerFile(consumerRoot, consumerPath);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!error.message.startsWith('unsafe consumer path (not a regular file):')) throw error;
|
|
113
|
+
if (!await pathEntryExists(join(consumerRoot, path))) return;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const root = await realpath(consumerRoot);
|
|
117
|
+
const resolved = await realpath(source);
|
|
118
|
+
assertResolvedConsumerPath(root, resolved, path);
|
|
119
|
+
const validated = await stat(resolved, { bigint: true });
|
|
120
|
+
const pathname = await lstat(source, { bigint: true });
|
|
121
|
+
if (!sameFile(validated, pathname)) {
|
|
122
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
123
|
+
}
|
|
124
|
+
await afterInputValidation(path);
|
|
125
|
+
|
|
126
|
+
let handle;
|
|
127
|
+
try {
|
|
128
|
+
handle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
129
|
+
const opened = await handle.stat({ bigint: true });
|
|
130
|
+
if (!opened.isFile() || !sameFile(validated, opened)) {
|
|
131
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
132
|
+
}
|
|
133
|
+
const bytes = await handle.readFile();
|
|
134
|
+
const finished = await handle.stat({ bigint: true });
|
|
135
|
+
if (!sameFileSnapshot(opened, finished)) {
|
|
136
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
137
|
+
}
|
|
138
|
+
const resolvedAfter = await realpath(source);
|
|
139
|
+
assertResolvedConsumerPath(root, resolvedAfter, path);
|
|
140
|
+
const current = await stat(resolvedAfter, { bigint: true });
|
|
141
|
+
if (!sameFile(opened, current)) {
|
|
142
|
+
throw new Error(`consumer input changed while staging: ${path}`);
|
|
143
|
+
}
|
|
144
|
+
await writeAtomic(join(candidateRoot, path), bytes, Number(opened.mode));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error.code === 'ELOOP') {
|
|
147
|
+
throw new Error(`unsafe consumer path (not a regular file): ${path}`, { cause: error });
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
} finally {
|
|
151
|
+
await handle?.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Accept the package manifest's canonical slash-separated path and translate it
|
|
157
|
+
* only after platform-independent lexical validation.
|
|
158
|
+
*/
|
|
159
|
+
export function validateCandidateManifestPath(path, pathSemantics = PLATFORM_PATH_SEMANTICS) {
|
|
160
|
+
if (typeof path !== 'string' || !path || path === '.' || path.includes('\\')
|
|
161
|
+
|| path.split('/').includes('..')
|
|
162
|
+
|| posix.isAbsolute(path) || posix.normalize(path) !== path) {
|
|
163
|
+
throw new Error(`unsafe candidate manifest path: ${path}`);
|
|
164
|
+
}
|
|
165
|
+
const platformPath = path.split('/').join(pathSemantics.sep);
|
|
166
|
+
if (pathSemantics.isAbsolute(platformPath)
|
|
167
|
+
|| pathSemantics.normalize(platformPath) !== platformPath) {
|
|
168
|
+
throw new Error(`unsafe candidate manifest path: ${path}`);
|
|
169
|
+
}
|
|
170
|
+
return platformPath;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isForbiddenCandidatePath(path) {
|
|
174
|
+
if (typeof path !== 'string') return false;
|
|
175
|
+
const [root] = path.split(/[\\/]/);
|
|
176
|
+
return FORBIDDEN_CANDIDATE_ROOTS.has(root);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function assertResolvedConsumerPath(root, resolved, path) {
|
|
180
|
+
const fromRoot = relative(root, resolved);
|
|
181
|
+
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${pathSeparator()}`)
|
|
182
|
+
|| isAbsolute(fromRoot)) {
|
|
183
|
+
throw new Error(`unsafe consumer path (resolved outside root): ${path}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pathSeparator() {
|
|
188
|
+
return process.platform === 'win32' ? '\\' : '/';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sameFile(left, right) {
|
|
192
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sameFileSnapshot(left, right) {
|
|
196
|
+
return sameFile(left, right) && left.size === right.size &&
|
|
197
|
+
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
39
198
|
}
|
|
40
199
|
|
|
41
200
|
/** Activate only verified kit-owned deltas, rolling every touched path back on failure. */
|
|
42
201
|
export async function activateCandidate({
|
|
43
202
|
candidateRoot, consumerRoot, pkg, preview, consumerManifestBefore,
|
|
44
|
-
afterGenerated = async () => {},
|
|
203
|
+
afterSnapshot = async () => {}, afterGenerated = async () => {},
|
|
204
|
+
beforeTargetRevalidation = async () => {},
|
|
45
205
|
}) {
|
|
46
206
|
const changed = [...preview.added, ...preview.updated];
|
|
47
207
|
const generated = preview.generated ?? [];
|
|
@@ -50,10 +210,6 @@ export async function activateCandidate({
|
|
|
50
210
|
...changed, ...generated, ...migrations.map(({ path }) => path),
|
|
51
211
|
...preview.deleted, CONSUMER_MANIFEST_NAME,
|
|
52
212
|
];
|
|
53
|
-
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
54
|
-
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
55
|
-
throw new Error('consumer manifest changed during verification');
|
|
56
|
-
}
|
|
57
213
|
const pkgIdx = indexByPath(pkg, 'files');
|
|
58
214
|
for (const path of changed) {
|
|
59
215
|
if (await sha256File(join(candidateRoot, path)) !== pkgIdx.get(path)?.sha256) {
|
|
@@ -72,28 +228,77 @@ export async function activateCandidate({
|
|
|
72
228
|
throw new Error(`migrated candidate hash mismatch: ${migration.path}`);
|
|
73
229
|
}
|
|
74
230
|
}
|
|
231
|
+
const manifestBeforeSnapshot = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
232
|
+
if (!manifestBeforeSnapshot.equals(consumerManifestBefore)) {
|
|
233
|
+
throw new Error('consumer manifest changed during verification');
|
|
234
|
+
}
|
|
75
235
|
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
76
236
|
const rollback = new Map();
|
|
77
|
-
for (const path of touched)
|
|
237
|
+
for (const path of touched) {
|
|
238
|
+
rollback.set(path, await snapshot(join(consumerRoot, path), path));
|
|
239
|
+
}
|
|
240
|
+
await afterSnapshot();
|
|
241
|
+
const currentManifest = await readFile(join(consumerRoot, CONSUMER_MANIFEST_NAME));
|
|
242
|
+
if (!currentManifest.equals(consumerManifestBefore)) {
|
|
243
|
+
throw new Error('consumer manifest changed during verification');
|
|
244
|
+
}
|
|
245
|
+
await assertConsumerStillMatchesPreview(consumerRoot, preview);
|
|
246
|
+
const applied = [];
|
|
247
|
+
const applyTarget = async (path, action) => {
|
|
248
|
+
await beforeTargetRevalidation(path);
|
|
249
|
+
await assertTargetStillMatchesSnapshot(
|
|
250
|
+
join(consumerRoot, path), rollback.get(path), path,
|
|
251
|
+
);
|
|
252
|
+
// Optimistic revalidation cannot remove the filesystem check-to-rename
|
|
253
|
+
// micro-window; it does keep every later destination behind a fresh check.
|
|
254
|
+
await action();
|
|
255
|
+
const record = { path, snapshot: null, captured: false };
|
|
256
|
+
applied.push(record);
|
|
257
|
+
record.snapshot = await snapshot(join(consumerRoot, path), path);
|
|
258
|
+
record.captured = true;
|
|
259
|
+
};
|
|
78
260
|
try {
|
|
79
261
|
for (const path of changed) {
|
|
80
|
-
|
|
262
|
+
const bytes = await readFile(join(candidateRoot, path));
|
|
263
|
+
await applyTarget(path, () => writeAtomic(
|
|
264
|
+
join(consumerRoot, path), bytes, pkgIdx.get(path)?.mode,
|
|
265
|
+
));
|
|
81
266
|
}
|
|
82
267
|
for (const path of generated) {
|
|
83
|
-
|
|
268
|
+
const bytes = await readFile(join(candidateRoot, path));
|
|
269
|
+
await applyTarget(path, () => writeAtomic(join(consumerRoot, path), bytes));
|
|
84
270
|
}
|
|
85
271
|
for (const { path } of migrations) {
|
|
86
|
-
|
|
272
|
+
const bytes = await readFile(join(candidateRoot, path));
|
|
273
|
+
await applyTarget(path, () => writeAtomic(join(consumerRoot, path), bytes));
|
|
87
274
|
}
|
|
88
275
|
await afterGenerated();
|
|
89
|
-
for (const path of preview.deleted)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
276
|
+
for (const path of preview.deleted) {
|
|
277
|
+
await applyTarget(path, () => rm(join(consumerRoot, path), { force: true }));
|
|
278
|
+
}
|
|
279
|
+
const manifestBytes = await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME));
|
|
280
|
+
await applyTarget(CONSUMER_MANIFEST_NAME, () => writeAtomic(
|
|
281
|
+
join(consumerRoot, CONSUMER_MANIFEST_NAME), manifestBytes,
|
|
282
|
+
));
|
|
94
283
|
} catch (error) {
|
|
95
|
-
|
|
96
|
-
|
|
284
|
+
const rollbackConflicts = [];
|
|
285
|
+
for (const record of applied.reverse()) {
|
|
286
|
+
const target = join(consumerRoot, record.path);
|
|
287
|
+
if (!record.captured
|
|
288
|
+
|| !await targetStillMatchesSnapshot(target, record.snapshot, record.path)) {
|
|
289
|
+
rollbackConflicts.push(record.path);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
await restore(target, rollback.get(record.path));
|
|
293
|
+
}
|
|
294
|
+
if (rollbackConflicts.length) {
|
|
295
|
+
rollbackConflicts.sort();
|
|
296
|
+
error.message = `${error.message}; rollback preserved concurrent edits: ` +
|
|
297
|
+
rollbackConflicts.join(', ');
|
|
298
|
+
}
|
|
299
|
+
error.consumerState = rollbackConflicts.length
|
|
300
|
+
? 'rollback-conflicted'
|
|
301
|
+
: (applied.length ? 'rolled-back' : 'unchanged');
|
|
97
302
|
throw error;
|
|
98
303
|
}
|
|
99
304
|
}
|
|
@@ -278,26 +483,49 @@ export async function readReadinessManifest(root) {
|
|
|
278
483
|
return readManifest(join(root, READINESS_MANIFEST_PATH));
|
|
279
484
|
}
|
|
280
485
|
|
|
281
|
-
async function snapshot(path) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
486
|
+
async function snapshot(path, displayPath = path) {
|
|
487
|
+
let before;
|
|
488
|
+
try {
|
|
489
|
+
before = await lstat(path, { bigint: true });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error.code === 'ENOENT') return null;
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
if (!before.isFile()) {
|
|
495
|
+
throw new Error(`unsafe consumer activation path: ${displayPath}`);
|
|
496
|
+
}
|
|
497
|
+
const bytes = await readFile(path);
|
|
498
|
+
const after = await lstat(path, { bigint: true });
|
|
499
|
+
if (!sameFileSnapshot(before, after)) {
|
|
500
|
+
throw new Error(`consumer changed during activation: ${displayPath}`);
|
|
501
|
+
}
|
|
502
|
+
return { bytes, mode: Number(before.mode), identity: before };
|
|
285
503
|
}
|
|
286
504
|
|
|
287
|
-
async function
|
|
288
|
-
if (!
|
|
289
|
-
|
|
505
|
+
async function assertTargetStillMatchesSnapshot(path, expected, displayPath) {
|
|
506
|
+
if (!await targetStillMatchesSnapshot(path, expected, displayPath)) {
|
|
507
|
+
throw new Error(`consumer changed during activation: ${displayPath}`);
|
|
508
|
+
}
|
|
290
509
|
}
|
|
291
510
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
let pkg;
|
|
511
|
+
async function targetStillMatchesSnapshot(path, expected, displayPath) {
|
|
512
|
+
let current;
|
|
295
513
|
try {
|
|
296
|
-
|
|
514
|
+
current = await snapshot(path, displayPath);
|
|
297
515
|
} catch (error) {
|
|
298
|
-
|
|
299
|
-
throw error;
|
|
516
|
+
return false;
|
|
300
517
|
}
|
|
301
|
-
|
|
302
|
-
|
|
518
|
+
return sameActivationSnapshot(expected, current);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function sameActivationSnapshot(expected, current) {
|
|
522
|
+
if (!expected || !current) return expected === current;
|
|
523
|
+
return expected.mode === current.mode
|
|
524
|
+
&& expected.bytes.equals(current.bytes)
|
|
525
|
+
&& sameFileSnapshot(expected.identity, current.identity);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function restore(path, saved) {
|
|
529
|
+
if (!saved) return rm(path, { force: true });
|
|
530
|
+
await writeAtomic(path, saved.bytes, saved.mode);
|
|
303
531
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { sha256File } from './hash.mjs';
|
|
5
|
+
import {
|
|
6
|
+
CONSUMER_INSTALL_ROLE, CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, KIT_ORIGIN,
|
|
7
|
+
emptyConsumerManifest, filesForInstallRole,
|
|
8
|
+
} from './manifest.mjs';
|
|
9
|
+
import { validateCandidateManifestPath } from './updateCandidate.mjs';
|
|
10
|
+
import {
|
|
11
|
+
verifyCandidateMembership, verifyChangedSyntax,
|
|
12
|
+
} from './verifyUpdateCandidateArtifacts.mjs';
|
|
13
|
+
import { verifyCandidateProtocol } from './verifyUpdateCandidateProtocol.mjs';
|
|
14
|
+
import {
|
|
15
|
+
verifyDeletionState, verifyDerivedArtifacts, verifyTransactionPreview,
|
|
16
|
+
} from './verifyUpdateCandidateTransaction.mjs';
|
|
17
|
+
|
|
18
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
19
|
+
const PACKAGE_KINDS = new Set(['skill', 'hook', 'doc', 'template', 'script']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Verify the Kit-owned staged end state without executing Consumer behavior.
|
|
23
|
+
*
|
|
24
|
+
* The trusted package manifest and transaction preview are supplied by the
|
|
25
|
+
* updater; candidate-owned metadata never establishes its own authority.
|
|
26
|
+
*/
|
|
27
|
+
export async function verifyUpdateCandidate(candidateRoot, context) {
|
|
28
|
+
await verifyCandidateMetadata(candidateRoot, context);
|
|
29
|
+
await verifyChangedSyntax(candidateRoot, context.preview);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function verifyCandidateMetadata(candidateRoot, context) {
|
|
33
|
+
if (!candidateRoot || !context?.pkg) {
|
|
34
|
+
throw new Error('candidate invariant transaction: trusted package manifest is required');
|
|
35
|
+
}
|
|
36
|
+
const { installable, installed, ledger } = await verifyCandidateSchema(candidateRoot, context);
|
|
37
|
+
if (!Array.isArray(context.priorConsumerManifest?.installed)) {
|
|
38
|
+
throw new Error('candidate invariant ownership: trusted prior Consumer ledger is required');
|
|
39
|
+
}
|
|
40
|
+
verifyLedgerMetadata(ledger, context);
|
|
41
|
+
const priorInstalled = uniqueEntries(context.priorConsumerManifest.installed, 'prior ledger');
|
|
42
|
+
verifyTransactionPreview(context.preview, installable, installed);
|
|
43
|
+
verifyDeletionState(installed, context.pkg, installable, context.preview);
|
|
44
|
+
await verifyCandidateMembership(candidateRoot, context);
|
|
45
|
+
for (const entry of installable) {
|
|
46
|
+
const tracked = installed.get(entry.path);
|
|
47
|
+
if (!tracked) {
|
|
48
|
+
throw new Error(`candidate invariant ownership: untracked package path ${entry.path}`);
|
|
49
|
+
}
|
|
50
|
+
validateInstalledEntry(tracked, entry, expectedOrigin(entry.path, priorInstalled, context.preview));
|
|
51
|
+
let state;
|
|
52
|
+
try {
|
|
53
|
+
state = await lstat(join(candidateRoot, entry.path));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') {
|
|
56
|
+
throw new Error(`candidate invariant artifact: missing ${entry.path}`);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (!state.isFile()) {
|
|
61
|
+
throw new Error(`candidate invariant artifact: not a regular file ${entry.path}`);
|
|
62
|
+
}
|
|
63
|
+
if (tracked.origin === KIT_ORIGIN && (state.mode & 0o777) !== entry.mode) {
|
|
64
|
+
throw new Error(`candidate invariant artifact: mode mismatch ${entry.path}`);
|
|
65
|
+
}
|
|
66
|
+
const expectedHash = tracked.origin === CONSUMER_ORIGIN
|
|
67
|
+
? tracked.installedSha256 : entry.sha256;
|
|
68
|
+
if (await sha256File(join(candidateRoot, entry.path)) !== expectedHash) {
|
|
69
|
+
throw new Error(`candidate invariant artifact: hash mismatch ${entry.path}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await verifyDerivedArtifacts(candidateRoot, installed, context.preview);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function verifyCandidateSchema(candidateRoot, context) {
|
|
76
|
+
if (!candidateRoot || !context?.pkg) {
|
|
77
|
+
throw new Error('candidate invariant transaction: trusted package manifest is required');
|
|
78
|
+
}
|
|
79
|
+
const installable = packageArtifacts(context.pkg);
|
|
80
|
+
const ledger = await candidateLedger(candidateRoot, context);
|
|
81
|
+
const installed = uniqueEntries(ledger.installed, 'ledger');
|
|
82
|
+
await verifyCandidateProtocol(candidateRoot, context.pkg, installed);
|
|
83
|
+
return { installable, installed, ledger };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { verifyUpdateCandidate as verifyCandidate };
|
|
87
|
+
|
|
88
|
+
function packageArtifacts(pkg) {
|
|
89
|
+
if (typeof pkg.kitVersion !== 'string' || !pkg.kitVersion) {
|
|
90
|
+
throw new Error('candidate invariant manifest: package kitVersion is required');
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(pkg.files)) {
|
|
93
|
+
throw new Error('candidate invariant manifest: package files must be an array');
|
|
94
|
+
}
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const entry of pkg.files) {
|
|
97
|
+
if (!entry || typeof entry.path !== 'string') {
|
|
98
|
+
throw new Error('candidate invariant manifest: package entry path is required');
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
validateCandidateManifestPath(entry.path);
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error(`candidate invariant manifest: unsafe path ${entry.path}`);
|
|
104
|
+
}
|
|
105
|
+
if (seen.has(entry.path)) {
|
|
106
|
+
throw new Error(`candidate invariant manifest: duplicate path ${entry.path}`);
|
|
107
|
+
}
|
|
108
|
+
if (!HASH.test(entry.sha256 ?? '')) {
|
|
109
|
+
throw new Error(`candidate invariant manifest: invalid hash ${entry.path}`);
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777) {
|
|
112
|
+
throw new Error(`candidate invariant manifest: invalid mode ${entry.path}`);
|
|
113
|
+
}
|
|
114
|
+
if (!PACKAGE_KINDS.has(entry.kind)) {
|
|
115
|
+
throw new Error(`candidate invariant manifest: invalid kind ${entry.path}`);
|
|
116
|
+
}
|
|
117
|
+
if ((entry.origin ?? KIT_ORIGIN) !== KIT_ORIGIN) {
|
|
118
|
+
throw new Error(`candidate invariant manifest: invalid origin ${entry.path}`);
|
|
119
|
+
}
|
|
120
|
+
if (![CONSUMER_INSTALL_ROLE, 'maintainer'].includes(
|
|
121
|
+
entry.installRole ?? CONSUMER_INSTALL_ROLE,
|
|
122
|
+
)) {
|
|
123
|
+
throw new Error(`candidate invariant manifest: invalid role ${entry.path}`);
|
|
124
|
+
}
|
|
125
|
+
seen.add(entry.path);
|
|
126
|
+
}
|
|
127
|
+
return filesForInstallRole(pkg);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function candidateLedger(candidateRoot, context) {
|
|
131
|
+
const { pkg, nextReadinessManifest } = context;
|
|
132
|
+
let ledger;
|
|
133
|
+
try {
|
|
134
|
+
ledger = JSON.parse(await readFile(join(candidateRoot, CONSUMER_MANIFEST_NAME), 'utf8'));
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error('candidate invariant schema: Consumer ledger is missing or invalid JSON');
|
|
137
|
+
}
|
|
138
|
+
if (!ledger || ledger.kitVersion !== pkg.kitVersion
|
|
139
|
+
|| ledger.installRole !== CONSUMER_INSTALL_ROLE
|
|
140
|
+
|| !Array.isArray(ledger.installed)
|
|
141
|
+
|| !Number.isInteger(ledger.readinessContractVersion)
|
|
142
|
+
|| !isRecord(ledger.readinessDecisions)
|
|
143
|
+
|| Object.values(ledger.readinessDecisions).some(
|
|
144
|
+
(value) => !['pending', 'not-applicable'].includes(value),
|
|
145
|
+
)) {
|
|
146
|
+
throw new Error('candidate invariant schema: Consumer ledger identity is invalid');
|
|
147
|
+
}
|
|
148
|
+
const readiness = nextReadinessManifest?.readiness;
|
|
149
|
+
if (readiness && (ledger.readinessContractVersion !== readiness.contractVersion
|
|
150
|
+
|| Object.entries(ledger.readinessDecisions).some(([name, value]) => (
|
|
151
|
+
!readiness.capabilities?.[name]
|
|
152
|
+
|| (value === 'not-applicable' && !readiness.capabilities[name].allowNotApplicable)
|
|
153
|
+
)))) {
|
|
154
|
+
throw new Error('candidate invariant schema: Consumer ledger readiness references are invalid');
|
|
155
|
+
}
|
|
156
|
+
return ledger;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function uniqueEntries(entries, source) {
|
|
160
|
+
const indexed = new Map();
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry || typeof entry.path !== 'string') {
|
|
163
|
+
throw new Error(`candidate invariant schema: ${source} entry path is required`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
validateCandidateManifestPath(entry.path);
|
|
167
|
+
} catch {
|
|
168
|
+
throw new Error(`candidate invariant schema: unsafe ${source} path ${entry.path}`);
|
|
169
|
+
}
|
|
170
|
+
if (indexed.has(entry.path)) {
|
|
171
|
+
throw new Error(`candidate invariant schema: duplicate ${source} path ${entry.path}`);
|
|
172
|
+
}
|
|
173
|
+
indexed.set(entry.path, entry);
|
|
174
|
+
}
|
|
175
|
+
return indexed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateInstalledEntry(tracked, desired, origin) {
|
|
179
|
+
if (tracked.origin !== origin
|
|
180
|
+
|| (tracked.installRole ?? CONSUMER_INSTALL_ROLE) !== CONSUMER_INSTALL_ROLE
|
|
181
|
+
|| !HASH.test(tracked.installedSha256 ?? '')) {
|
|
182
|
+
throw new Error(`candidate invariant ownership: invalid ledger entry ${tracked.path}`);
|
|
183
|
+
}
|
|
184
|
+
for (const key of ['kind', 'ownerSkill', 'surface']) {
|
|
185
|
+
if ((tracked[key] ?? null) !== (desired[key] ?? null)) {
|
|
186
|
+
throw new Error(`candidate invariant ownership: ${key} mismatch ${tracked.path}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (tracked.origin === KIT_ORIGIN && tracked.installedSha256 !== desired.sha256) {
|
|
190
|
+
throw new Error(`candidate invariant ownership: Kit hash identity mismatch ${tracked.path}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function verifyLedgerMetadata(ledger, context) {
|
|
195
|
+
const expected = emptyConsumerManifest(
|
|
196
|
+
context.pkg.kitVersion,
|
|
197
|
+
context.priorConsumerManifest,
|
|
198
|
+
context.nextReadinessManifest?.readiness ?? null,
|
|
199
|
+
);
|
|
200
|
+
const { installed: _actualInstalled, ...actualMetadata } = ledger;
|
|
201
|
+
const { installed: _expectedInstalled, ...expectedMetadata } = expected;
|
|
202
|
+
if (!isDeepStrictEqual(actualMetadata, expectedMetadata)) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'candidate invariant schema: Consumer ledger metadata differs from trusted prior state',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function expectedOrigin(path, priorInstalled, preview) {
|
|
210
|
+
const keptOwned = (preview.collisionResolutions ?? []).some(
|
|
211
|
+
(resolution) => resolution.path === path && resolution.outcome === 'keep-as-owned',
|
|
212
|
+
);
|
|
213
|
+
return keptOwned || (priorInstalled.get(path)?.origin ?? KIT_ORIGIN) === CONSUMER_ORIGIN
|
|
214
|
+
? CONSUMER_ORIGIN
|
|
215
|
+
: KIT_ORIGIN;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isRecord(value) {
|
|
219
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
220
|
+
}
|