@open-agent-toolkit/cli 0.2.66 → 0.2.67
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/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/configuration.md +14 -1
- package/assets/docs/cli-utilities/project-log.md +63 -4
- package/assets/docs/contributing/code.md +8 -4
- package/assets/docs/contributing/skills.md +29 -17
- package/assets/docs/docs-tooling/commands.md +3 -1
- package/assets/docs/provider-sync/manifest-and-drift.md +2 -18
- package/assets/docs/workflows/projects/lifecycle.md +17 -0
- package/assets/docs/workflows/projects/picking-up-projects.md +18 -0
- package/assets/docs/workflows/skills/repo-improve.md +7 -3
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/create-agnostic-skill/SKILL.md +19 -37
- package/assets/skills/create-agnostic-skill/references/docs/skills-guide.md +58 -15
- package/assets/skills/create-agnostic-skill/references/skill-template.md +17 -15
- package/assets/skills/oat-agent-instructions-apply/SKILL.md +1 -1
- package/assets/skills/oat-agent-instructions-apply/references/instruction-file-templates/glob-scoped-rule.md +1 -4
- package/assets/skills/oat-doctor/SKILL.md +1 -4
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
- package/assets/skills/oat-project-complete/SKILL.md +158 -30
- package/assets/skills/oat-project-complete/scripts/finalize-synced-archive.mjs +26 -7
- package/assets/skills/oat-project-complete/scripts/validate-durable-archive-receipt.mjs +183 -0
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-retro/SKILL.md +1 -1
- package/assets/skills/oat-project-retro/references/apply-procedure.md +11 -0
- package/assets/skills/oat-project-review-provide/SKILL.md +4 -4
- package/assets/skills/oat-project-summary/SKILL.md +24 -2
- package/assets/skills/oat-repo-improve/SKILL.md +9 -2
- package/assets/skills/oat-repo-improve/references/plan-template.md +4 -6
- package/assets/skills/oat-repo-knowledge-index/SKILL.md +11 -9
- package/assets/skills/oat-wave-execute/SKILL.md +20 -6
- package/assets/skills/oat-wave-program/SKILL.md +9 -4
- package/dist/commands/config/index.d.ts +2 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +133 -38
- package/dist/commands/docs/index-generate/index.d.ts.map +1 -1
- package/dist/commands/docs/index-generate/index.js +68 -12
- package/dist/commands/docs/init/index.d.ts +11 -0
- package/dist/commands/docs/init/index.d.ts.map +1 -1
- package/dist/commands/docs/init/index.js +19 -1
- package/dist/commands/gate/index.d.ts.map +1 -1
- package/dist/commands/gate/index.js +10 -2
- package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
- package/dist/commands/instructions/instructions.utils.js +42 -9
- package/dist/commands/internal/validate-skill-version-bumps.d.ts.map +1 -1
- package/dist/commands/internal/validate-skill-version-bumps.js +5 -5
- package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
- package/dist/commands/project/dispatch-ceiling/index.js +32 -18
- package/dist/commands/project/log/append.d.ts +48 -0
- package/dist/commands/project/log/append.d.ts.map +1 -1
- package/dist/commands/project/log/append.js +207 -30
- package/dist/commands/project/log/check.d.ts +87 -1
- package/dist/commands/project/log/check.d.ts.map +1 -1
- package/dist/commands/project/log/check.js +139 -5
- package/dist/commands/project/log/grammar.d.ts +165 -0
- package/dist/commands/project/log/grammar.d.ts.map +1 -1
- package/dist/commands/project/log/grammar.js +219 -1
- package/dist/commands/project/log/rollup.d.ts +16 -1
- package/dist/commands/project/log/rollup.d.ts.map +1 -1
- package/dist/commands/project/log/rollup.js +22 -2
- package/dist/commands/project/log/synthesize.d.ts +16 -0
- package/dist/commands/project/log/synthesize.d.ts.map +1 -1
- package/dist/commands/project/log/synthesize.js +85 -9
- package/dist/commands/status/index.d.ts.map +1 -1
- package/dist/commands/status/index.js +24 -2
- package/dist/commands/sync/apply.d.ts.map +1 -1
- package/dist/commands/sync/apply.js +6 -4
- package/dist/config/dispatch-matrix.d.ts.map +1 -1
- package/dist/config/dispatch-matrix.js +13 -5
- package/dist/config/oat-config.d.ts +15 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +61 -7
- package/dist/config/own-keys.d.ts +22 -0
- package/dist/config/own-keys.d.ts.map +1 -0
- package/dist/config/own-keys.js +32 -0
- package/dist/config/resolve.d.ts +11 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +35 -11
- package/dist/config/sync-config.d.ts.map +1 -1
- package/dist/config/sync-config.js +16 -0
- package/dist/drift/detector.d.ts.map +1 -1
- package/dist/drift/detector.js +52 -1
- package/dist/drift/skill-view-diagnostic.d.ts.map +1 -1
- package/dist/drift/skill-view-diagnostic.js +2 -23
- package/dist/engine/compute-plan.d.ts.map +1 -1
- package/dist/engine/compute-plan.js +22 -72
- package/dist/engine/managed-copy-hash.d.ts +69 -0
- package/dist/engine/managed-copy-hash.d.ts.map +1 -0
- package/dist/engine/managed-copy-hash.js +175 -0
- package/dist/fs/assets.d.ts +11 -0
- package/dist/fs/assets.d.ts.map +1 -1
- package/dist/fs/assets.js +1 -1
- package/dist/manifest/hash.d.ts +110 -0
- package/dist/manifest/hash.d.ts.map +1 -1
- package/dist/manifest/hash.js +111 -13
- package/dist/providers/ceiling/registry.d.ts.map +1 -1
- package/dist/providers/ceiling/registry.js +6 -1
- package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
- package/dist/providers/identity/dispatch-report.js +4 -1
- package/dist/release/public-package-contract.d.ts.map +1 -1
- package/dist/release/public-package-contract.js +21 -0
- package/dist/validation/skills.d.ts.map +1 -1
- package/dist/validation/skills.js +305 -40
- package/package.json +2 -2
package/dist/config/resolve.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isWorkflowDispatchCandidateLadder } from './dispatch-matrix.js';
|
|
2
2
|
import { BUILTIN_EXEC_TARGETS, isWorkflowPostImplementStructuredSequence, readOatConfig, readOatLocalConfig, readUserConfig, } from './oat-config.js';
|
|
3
|
+
import { getOwnKey, setOwnKey } from './own-keys.js';
|
|
3
4
|
const DEFAULT_DEPENDENCIES = {
|
|
4
5
|
readOatConfig,
|
|
5
6
|
readOatLocalConfig,
|
|
@@ -38,6 +39,11 @@ const DEFAULT_SHARED_CONFIG = {
|
|
|
38
39
|
tooling: null,
|
|
39
40
|
config: null,
|
|
40
41
|
index: null,
|
|
42
|
+
// `oat config dump` enumerates the resolved map, so a catalogued key that
|
|
43
|
+
// is absent here has no row at all while it is unset. `null` keeps meaning
|
|
44
|
+
// "not set at this layer", so a configured array still wins.
|
|
45
|
+
excludes: null,
|
|
46
|
+
instructionPointerExcludes: null,
|
|
41
47
|
requireForProjectCompletion: false,
|
|
42
48
|
},
|
|
43
49
|
tools: {
|
|
@@ -229,27 +235,33 @@ export function resolveExecTargetViews(effective) {
|
|
|
229
235
|
}
|
|
230
236
|
for (const [id, override] of Object.entries(layer)) {
|
|
231
237
|
if (override === null) {
|
|
232
|
-
|
|
238
|
+
// Exec-target ids come from user config, which can now carry a key
|
|
239
|
+
// named `__proto__` as an own key, so every lookup here is own-key
|
|
240
|
+
// guarded. `delete` needs no guard: it removes an own key and is a
|
|
241
|
+
// no-op otherwise.
|
|
242
|
+
const target = getOwnKey(targets, id) ?? getOwnKey(views, id)?.target;
|
|
233
243
|
delete targets[id];
|
|
234
244
|
if (target) {
|
|
235
|
-
views
|
|
245
|
+
setOwnKey(views, id, {
|
|
236
246
|
target: cloneExecTarget(target),
|
|
237
247
|
origin,
|
|
238
248
|
explicitlyConfigured: true,
|
|
239
249
|
enabled: false,
|
|
240
|
-
};
|
|
250
|
+
});
|
|
241
251
|
}
|
|
242
252
|
continue;
|
|
243
253
|
}
|
|
254
|
+
// The one-entry layer uses a computed key in an object literal, which
|
|
255
|
+
// is define semantics and already safe.
|
|
244
256
|
mergeExecTargetLayer(targets, { [id]: override });
|
|
245
|
-
const target = targets
|
|
257
|
+
const target = getOwnKey(targets, id);
|
|
246
258
|
if (target) {
|
|
247
|
-
views
|
|
259
|
+
setOwnKey(views, id, {
|
|
248
260
|
target: cloneExecTarget(target),
|
|
249
261
|
origin,
|
|
250
262
|
explicitlyConfigured: true,
|
|
251
263
|
enabled: true,
|
|
252
|
-
};
|
|
264
|
+
});
|
|
253
265
|
}
|
|
254
266
|
}
|
|
255
267
|
}
|
|
@@ -261,12 +273,14 @@ function mergeExecTargetLayer(targets, layer) {
|
|
|
261
273
|
}
|
|
262
274
|
for (const [id, override] of Object.entries(layer)) {
|
|
263
275
|
if (override === null) {
|
|
276
|
+
// `delete` removes an own key and is a no-op otherwise, so it is already
|
|
277
|
+
// correct for a user-supplied id such as `__proto__`.
|
|
264
278
|
delete targets[id];
|
|
265
279
|
continue;
|
|
266
280
|
}
|
|
267
|
-
const existing = targets
|
|
281
|
+
const existing = getOwnKey(targets, id);
|
|
268
282
|
if (existing) {
|
|
269
|
-
targets
|
|
283
|
+
setOwnKey(targets, id, cloneExecTarget({
|
|
270
284
|
runtime: override.runtime ?? existing.runtime,
|
|
271
285
|
baseCommand: override.baseCommand ?? existing.baseCommand,
|
|
272
286
|
invocation: mergeExecTargetInvocation(existing.invocation, override.invocation),
|
|
@@ -275,12 +289,12 @@ function mergeExecTargetLayer(targets, layer) {
|
|
|
275
289
|
availabilityCommand: override.availabilityCommand ?? existing.availabilityCommand,
|
|
276
290
|
priority: override.priority ?? existing.priority,
|
|
277
291
|
timeoutMs: override.timeoutMs ?? existing.timeoutMs,
|
|
278
|
-
});
|
|
292
|
+
}));
|
|
279
293
|
continue;
|
|
280
294
|
}
|
|
281
295
|
const completeTarget = toCompleteExecTarget(override);
|
|
282
296
|
if (completeTarget) {
|
|
283
|
-
targets
|
|
297
|
+
setOwnKey(targets, id, completeTarget);
|
|
284
298
|
}
|
|
285
299
|
}
|
|
286
300
|
}
|
|
@@ -388,7 +402,17 @@ function isAtomicConfigLeaf(key, value) {
|
|
|
388
402
|
(key === 'workflow.postImplementSequence' &&
|
|
389
403
|
isWorkflowPostImplementStructuredSequence(value)));
|
|
390
404
|
}
|
|
391
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Report the environment override for `key`, if one is live.
|
|
407
|
+
*
|
|
408
|
+
* Exported because `oat config unset` needs the answer to "is this key
|
|
409
|
+
* environment-overridden?" without performing a whole-config read, which a
|
|
410
|
+
* malformed stored value aborts -- and a malformed stored value is exactly what
|
|
411
|
+
* `unset` exists to remove. `ENV_OVERRIDE_MAP` plus the env-first branch of
|
|
412
|
+
* `resolveEffectiveConfig` is the single source of that answer, so callers that
|
|
413
|
+
* only need the boolean must not resolve the whole config to derive it.
|
|
414
|
+
*/
|
|
415
|
+
export function resolveEnvOverride(key, env) {
|
|
392
416
|
const envKey = ENV_OVERRIDE_MAP[key];
|
|
393
417
|
if (!envKey) {
|
|
394
418
|
return undefined;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-config.d.ts","sourceRoot":"","sources":["../../src/config/sync-config.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,QAAA,MAAM,oBAAoB;;;;;;;;;EAGxB,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK3B,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAEtE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG;IAC1D,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,UAKjC,CAAC;
|
|
1
|
+
{"version":3,"file":"sync-config.d.ts","sourceRoot":"","sources":["../../src/config/sync-config.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,QAAA,MAAM,oBAAoB;;;;;;;;;EAGxB,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK3B,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAEtE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG;IAC1D,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,UAKjC,CAAC;AAkCF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAW7E;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACnC,MAAM,EAAE,CAMV;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,QAAQ,EAAE,aAAa,CAAC,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC,GACxD,MAAM,EAAE,CAEV;AAqCD,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,QAAQ,GAAE,UAAgC,GACzC,OAAO,CAAC,UAAU,CAAC,CA6BrB;AAED,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,UAAU,CAAC,CAWrB;AAED,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,UAAU,CAAC,CAcrB;AAED,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAcrB"}
|
|
@@ -21,6 +21,22 @@ export const DEFAULT_SYNC_CONFIG = {
|
|
|
21
21
|
knownStrays: [],
|
|
22
22
|
providers: {},
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Provider names reaching this family are guarded twice, so no own-key access
|
|
26
|
+
* is needed here even though `config/json.ts` preserves a key literally named
|
|
27
|
+
* `__proto__` as an own data property.
|
|
28
|
+
*
|
|
29
|
+
* 1. `SyncConfigSchema.providers` is a `z.record`, which drops that key during
|
|
30
|
+
* parsing, so it never reaches `merged[name]` below.
|
|
31
|
+
* 2. `commands/providers/set/index.ts` rejects any provider name outside
|
|
32
|
+
* `knownProviders`, and the init and sync commands iterate adapter names
|
|
33
|
+
* and detection results rather than raw user strings.
|
|
34
|
+
*
|
|
35
|
+
* Guard 1 is pinned by the `drops a \`__proto__\` provider during zod record
|
|
36
|
+
* parsing` case in `sync-config.test.ts`. If a future zod upgrade stops
|
|
37
|
+
* stripping the key that test fails, and this classification must be re-opened
|
|
38
|
+
* rather than assumed.
|
|
39
|
+
*/
|
|
24
40
|
function mergeProviderConfigs(base, override) {
|
|
25
41
|
const merged = { ...base };
|
|
26
42
|
for (const [name, config] of Object.entries(override)) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../src/drift/detector.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../src/drift/detector.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,MAAM,WAAW,aAAa;IAC5B,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC;CACxE;AAcD,wBAAsB,WAAW,CAC/B,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,MAAM,EACjB,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,WAAW,CAAC,CAgKtB"}
|
package/dist/drift/detector.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { lstat, readFile, readlink, stat } from 'node:fs/promises';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import { proveCollectionIdentity } from '../engine/collection-sync.js';
|
|
4
|
-
import {
|
|
4
|
+
import { computeManagedDirectoryCopyHash } from '../engine/managed-copy-hash.js';
|
|
5
|
+
import { computeContentHash, computeDirectoryDigests, computeStringHash, } from '../manifest/hash.js';
|
|
5
6
|
function createReport(entry, state) {
|
|
6
7
|
return {
|
|
7
8
|
canonical: entry.canonicalPath,
|
|
@@ -80,6 +81,56 @@ export async function detectDrift(entry, scopeRoot, copyTransform) {
|
|
|
80
81
|
if (entry.contentHash === currentHash) {
|
|
81
82
|
return createReport(entry, { status: 'in_sync' });
|
|
82
83
|
}
|
|
84
|
+
// A directory copy is decorated by the writer: `applyCopyMarker` adds the
|
|
85
|
+
// `.oat-generated` sentinel and prepends the banner to SKILL.md/AGENT.md,
|
|
86
|
+
// while the manifest records the undecorated canonical hash. Re-ask the
|
|
87
|
+
// question with those two artifacts excluded, so a faithful copy reads
|
|
88
|
+
// `in_sync` instead of permanently `modified`.
|
|
89
|
+
//
|
|
90
|
+
// Deliberately *not* gated on `copyTransform`: the banner and sentinel are
|
|
91
|
+
// engine-owned rather than adapter-owned, and `commands/tools/info/index.ts`
|
|
92
|
+
// passes no transform. Gating here would leave `oat tools info` disagreeing
|
|
93
|
+
// with `oat status`. A `null` result — no sentinel, a sentinel naming a
|
|
94
|
+
// different canonical path, a marker file that is absent or does not carry
|
|
95
|
+
// the banner, a non-regular entry — falls through to the raw verdict below,
|
|
96
|
+
// as does any other digest, so a tampered or unverifiable copy is still
|
|
97
|
+
// `drifted`.
|
|
98
|
+
if (!entry.isFile && entry.contentHash !== null) {
|
|
99
|
+
const managedHash = await computeManagedDirectoryCopyHash(providerPath, canonicalPath, entry.contentType);
|
|
100
|
+
if (managedHash !== null) {
|
|
101
|
+
if (managedHash === entry.contentHash) {
|
|
102
|
+
return createReport(entry, { status: 'in_sync' });
|
|
103
|
+
}
|
|
104
|
+
// Pre-framing manifest bridge (wave-7 final review, Critical 4).
|
|
105
|
+
// Length framing changed every directory digest, and a no-op `oat sync`
|
|
106
|
+
// does not restamp an entry it already owns (`ensureSkipEntryManaged` in
|
|
107
|
+
// `engine/execute-plan.ts` returns the manifest untouched for `skip`), so
|
|
108
|
+
// a manifest written before the change keeps its legacy value
|
|
109
|
+
// indefinitely. Without this branch every pre-existing copy-strategy
|
|
110
|
+
// install would report drift that no command repairs.
|
|
111
|
+
//
|
|
112
|
+
// This cannot reopen the collision. It fires only when the recorded hash
|
|
113
|
+
// is the legacy digest of the canonical tree, and acceptance still rests
|
|
114
|
+
// entirely on the framed digests: framing is injective, so
|
|
115
|
+
// `managedHash === framed` means the copy's logical content is exactly
|
|
116
|
+
// the canonical file set, which the legacy digests therefore also agreed
|
|
117
|
+
// on. Every input accepted here was accepted before the change too, so
|
|
118
|
+
// the branch cannot widen the acceptance set. That argument needs both
|
|
119
|
+
// digests to describe one canonical state, which is why
|
|
120
|
+
// `computeDirectoryDigests` folds them from a single capture rather than
|
|
121
|
+
// from two traversals — pairing `framed` from one state with `legacy`
|
|
122
|
+
// from another would accept a view the pre-framing detector rejected.
|
|
123
|
+
//
|
|
124
|
+
// Any failure to read the canonical tree is treated as "cannot verify",
|
|
125
|
+
// which falls through to `drifted` below.
|
|
126
|
+
const canonicalDigests = await computeDirectoryDigests(canonicalPath).catch(() => null);
|
|
127
|
+
if (canonicalDigests !== null &&
|
|
128
|
+
entry.contentHash === canonicalDigests.legacy &&
|
|
129
|
+
managedHash === canonicalDigests.framed) {
|
|
130
|
+
return createReport(entry, { status: 'in_sync' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
83
134
|
// When the manifest hash is stale (e.g. frontmatter-only edits to the
|
|
84
135
|
// canonical source that don't change the rendered output), re-derive the
|
|
85
136
|
// expected provider content from the current canonical + transform and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skill-view-diagnostic.d.ts","sourceRoot":"","sources":["../../src/drift/skill-view-diagnostic.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,OAAO,KAAK,EACV,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEhE,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,MAAM,cAAc,GACtB,SAAS,GACT,kBAAkB,GAClB,SAAS,GACT,UAAU,GACV,WAAW,GACX,YAAY,GACZ,UAAU,GACV,aAAa,GACb,UAAU,CAAC;AAEf;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,WAAW,CAAC;IACzB;;;OAGG;IACH,UAAU,EAAE,OAAO,CAAC;IACpB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,UAAU,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,WAAW,CAAC;IACrE,QAAQ,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AAED,mEAAmE;AACnE,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,kBAAkB,CAAC;IAC/B,2EAA2E;IAC3E,aAAa,EAAE,eAAe,GAAG,IAAI,CAAC;IACtC,yEAAyE;IACzE,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1B,6DAA6D;IAC7D,WAAW,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,qBAAqB,CAAC;CAC1C;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,aAAa,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,cAAc,CAAC;IAC1B,+EAA+E;IAC/E,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B;;;;;;;OAOG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,eAAe,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;IAC7C,UAAU,EAAE,OAAO,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,gEAAgE;IAChE,iBAAiB,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC,OAAO,CAAC,CAAC;IACjD,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,aAAa,CAAC;IACrB;;;;;;;;;;OAUG;IACH,MAAM,EAAE,WAAW,GAAG,eAAe,GAAG,aAAa,CAAC;IACtD,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,mBAAmB,EAAE,CAAC;CAC9B;AAeD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,CAAC;IACrB,aAAa,EAAE,SAAS,oBAAoB,EAAE,CAAC;IAC/C,qBAAqB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C,GAAG,kBAAkB,EAAE,CAwBvB;AAoLD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,CAAC;IACrB,wDAAwD;IACxD,gBAAgB,EAAE,OAAO,CAAC;IAC1B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,oBAAoB,EAAE,oBAAoB,CAAC;IAC3C,YAAY,EAAE,SAAS,oBAAoB,EAAE,CAAC;CAC/C,GAAG,kBAAkB,
|
|
1
|
+
{"version":3,"file":"skill-view-diagnostic.d.ts","sourceRoot":"","sources":["../../src/drift/skill-view-diagnostic.ts"],"names":[],"mappings":"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"}
|
|
@@ -277,33 +277,12 @@ export function diagnoseSkillViews(input) {
|
|
|
277
277
|
input.canonicalVersion !== null &&
|
|
278
278
|
viewVersion !== null &&
|
|
279
279
|
viewVersion !== input.canonicalVersion;
|
|
280
|
-
// The mirror case: the detector reports a copy as drifted while its
|
|
281
|
-
// version matches canonical. That is the expected state after a
|
|
282
|
-
// copy-strategy sync, because the OAT-managed banner and `.oat-generated`
|
|
283
|
-
// sentinel are not accounted for in the manifest hash
|
|
284
|
-
// (BL-260908-make-copy-strategy-skill), and no sync clears it.
|
|
285
|
-
//
|
|
286
|
-
// Suppressing the repair here is a conservative heuristic, not a proof:
|
|
287
|
-
// equal versions do not establish equal bodies, so an edit to either side
|
|
288
|
-
// that kept the version reaches this branch too, and a sync would fix
|
|
289
|
-
// that one. The detail therefore says which case it cannot distinguish
|
|
290
|
-
// and names the concrete scope command, rather than claiming the content
|
|
291
|
-
// is current.
|
|
292
|
-
const unrepairableCopy = versionComparable &&
|
|
293
|
-
classification.viewClass === 'modified' &&
|
|
294
|
-
input.canonicalVersion !== null &&
|
|
295
|
-
viewVersion === input.canonicalVersion;
|
|
296
280
|
const { viewClass, detail } = staleCopy
|
|
297
281
|
? {
|
|
298
282
|
viewClass: 'modified',
|
|
299
283
|
detail: `The tracked copy still matches the hash recorded at its last sync, but its version (${viewVersion}) differs from the canonical version (${input.canonicalVersion}), so the view is stale.`,
|
|
300
284
|
}
|
|
301
|
-
:
|
|
302
|
-
? {
|
|
303
|
-
viewClass: 'modified',
|
|
304
|
-
detail: `The detector reports this copy as drifted while its version still matches canonical (${input.canonicalVersion}). That is expected after a copy-strategy sync until BL-260908-make-copy-strategy-skill lands: the OAT-managed banner and ".oat-generated" sentinel are not accounted for in the manifest hash, and no sync clears it. Equal versions do not prove the bodies match, so if either side was edited without a version change, compare them and run "oat sync --scope ${input.scope}" yourself.`,
|
|
305
|
-
}
|
|
306
|
-
: classification;
|
|
285
|
+
: classification;
|
|
307
286
|
views.push({
|
|
308
287
|
skill: input.skillName,
|
|
309
288
|
scope: input.scope,
|
|
@@ -326,7 +305,7 @@ export function diagnoseSkillViews(input) {
|
|
|
326
305
|
// The narrowest safe repair is the single concrete scope where the gap
|
|
327
306
|
// was observed. `--scope all` would widen a one-scope repair into a
|
|
328
307
|
// two-scope write.
|
|
329
|
-
suggestion: REPAIRABLE.includes(viewClass)
|
|
308
|
+
suggestion: REPAIRABLE.includes(viewClass)
|
|
330
309
|
? `oat sync --scope ${input.scope}`
|
|
331
310
|
: null,
|
|
332
311
|
detail: `${versionComparable ? detail : `${detail}${versionNote(strategy)}`}${copyViewReadable
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compute-plan.d.ts","sourceRoot":"","sources":["../../src/engine/compute-plan.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"compute-plan.d.ts","sourceRoot":"","sources":["../../src/engine/compute-plan.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAGtD,OAAO,KAAK,EACV,QAAQ,EACR,aAAa,EAGd,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAEV,eAAe,EAChB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAGjD,OAAO,KAAK,EAEV,WAAW,EACX,oBAAoB,EACpB,QAAQ,EAET,MAAM,gBAAgB,CAAC;AAGxB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,UAAU,mBAAmB;IAC3B,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IACjC,sCAAsC,CAAC,EAAE,QAAQ,CAC/C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAClC,CAAC;IACF,+BAA+B,CAAC,EAAE,SAAS;QACzC,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,WAAW,CAAC;KAC1B,EAAE,CAAC;IACJ,gCAAgC,CAAC,EAAE,OAAO,CAAC;CAC5C;AAiOD,wBAAsB,iCAAiC,CACrD,aAAa,EAAE,aAAa,EAC5B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,oBAAoB,CAAC,CAqG/B;AAyJD,wBAAsB,eAAe,CAAC,EACpC,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,MAAM,EACN,SAAS,EAAE,iBAAiB,EAC5B,qBAAqB,EACrB,sCAAsC,EACtC,+BAAoC,EACpC,gCAAwC,GACzC,EAAE,mBAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4bzC"}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { access, lstat, readdir, readFile, readlink, stat, } from 'node:fs/promises';
|
|
1
|
+
import { access, lstat, readFile, readlink, stat } from 'node:fs/promises';
|
|
3
2
|
import { basename, dirname, join, normalize, relative, resolve, } from 'node:path';
|
|
4
3
|
import { computeContentHash, computeStringHash } from '../manifest/hash.js';
|
|
5
4
|
import { findEntry } from '../manifest/manager.js';
|
|
6
5
|
import { getSyncMappings } from '../providers/shared/adapter.utils.js';
|
|
7
6
|
import { proveCollectionIdentity } from './collection-sync.js';
|
|
8
|
-
import {
|
|
7
|
+
import { computeManagedDirectoryCopyHash } from './managed-copy-hash.js';
|
|
9
8
|
import { assertSafeProviderMutationPath } from './provider-path-safety.js';
|
|
10
9
|
function buildUpLevels(depth) {
|
|
11
10
|
return Array.from({ length: depth }, () => '..');
|
|
@@ -150,75 +149,6 @@ function manifestEntryInsideMapping(entry, mapping) {
|
|
|
150
149
|
return (canonicalPath === canonicalDir ||
|
|
151
150
|
canonicalPath.startsWith(`${canonicalDir}/`));
|
|
152
151
|
}
|
|
153
|
-
async function computeManagedDirectoryCopyHash(providerPath, canonicalPath, contentType) {
|
|
154
|
-
const expectedMarker = `${OAT_MARKER_PREFIX} Source: ${canonicalPath} -->`;
|
|
155
|
-
const sentinelPath = join(providerPath, OAT_DIRECTORY_SENTINEL);
|
|
156
|
-
try {
|
|
157
|
-
const sentinel = await readFile(sentinelPath, 'utf8');
|
|
158
|
-
if (sentinel !== `${expectedMarker}\n`) {
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
const files = [];
|
|
166
|
-
async function collectFiles(current) {
|
|
167
|
-
const entries = await readdir(current, { withFileTypes: true });
|
|
168
|
-
for (const entry of entries) {
|
|
169
|
-
const fullPath = join(current, entry.name);
|
|
170
|
-
if (fullPath === sentinelPath) {
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (entry.isDirectory()) {
|
|
174
|
-
if (!(await collectFiles(fullPath))) {
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (!entry.isFile()) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
files.push(fullPath);
|
|
183
|
-
}
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
try {
|
|
187
|
-
if (!(await collectFiles(providerPath))) {
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
files.sort((left, right) => relative(providerPath, left).localeCompare(relative(providerPath, right)));
|
|
191
|
-
const markerFileName = contentType === 'skill'
|
|
192
|
-
? 'SKILL.md'
|
|
193
|
-
: contentType === 'agent'
|
|
194
|
-
? 'AGENT.md'
|
|
195
|
-
: null;
|
|
196
|
-
const markerPath = markerFileName
|
|
197
|
-
? join(providerPath, markerFileName)
|
|
198
|
-
: null;
|
|
199
|
-
const hash = createHash('sha256');
|
|
200
|
-
for (const file of files) {
|
|
201
|
-
const relativePath = relative(providerPath, file);
|
|
202
|
-
let content = await readFile(file);
|
|
203
|
-
if (file === markerPath) {
|
|
204
|
-
const markerPrefix = Buffer.from(`${expectedMarker}\n`);
|
|
205
|
-
if (content.length < markerPrefix.length ||
|
|
206
|
-
!content.subarray(0, markerPrefix.length).equals(markerPrefix)) {
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
content = content.subarray(markerPrefix.length);
|
|
210
|
-
}
|
|
211
|
-
hash.update(relativePath);
|
|
212
|
-
hash.update('\0');
|
|
213
|
-
hash.update(content);
|
|
214
|
-
hash.update('\0');
|
|
215
|
-
}
|
|
216
|
-
return hash.digest('hex');
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
152
|
export async function classifyObsoleteMappingRetirement(manifestEntry, scopeRoot) {
|
|
223
153
|
const providerPath = resolve(scopeRoot, manifestEntry.providerPath);
|
|
224
154
|
const canonicalPath = resolve(scopeRoot, manifestEntry.canonicalPath);
|
|
@@ -335,6 +265,26 @@ async function classifyOperation(canonicalEntry, providerPath, strategy, rendere
|
|
|
335
265
|
reason: 'already in sync',
|
|
336
266
|
};
|
|
337
267
|
}
|
|
268
|
+
// A directory copy is decorated by the writer: `applyCopyMarker` adds the
|
|
269
|
+
// `.oat-generated` sentinel and prepends the banner to SKILL.md/AGENT.md, so
|
|
270
|
+
// the raw hashes above can never agree and every re-plan produced another
|
|
271
|
+
// `update_copy`. Re-ask the question with those two artifacts excluded. This
|
|
272
|
+
// runs only after the raw comparison already failed, so a genuinely in-sync
|
|
273
|
+
// tree is never hashed twice. `null` means "not a verifiable managed copy"
|
|
274
|
+
// (no sentinel, a sentinel naming a different canonical path, a missing
|
|
275
|
+
// banner, a non-regular entry) and keeps the `update_copy` below, as does any
|
|
276
|
+
// other digest — a tampered body still re-syncs.
|
|
277
|
+
if (strategy === 'copy' && !canonicalEntry.isFile) {
|
|
278
|
+
const managedHash = await computeManagedDirectoryCopyHash(providerPath,
|
|
279
|
+
// Exactly the string `applyCopyMarker` wrote into the sentinel.
|
|
280
|
+
canonicalEntry.canonicalPath, canonicalEntry.type);
|
|
281
|
+
if (managedHash !== null && managedHash === canonicalHash) {
|
|
282
|
+
return {
|
|
283
|
+
operation: 'skip',
|
|
284
|
+
reason: 'already in sync',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
338
288
|
return {
|
|
339
289
|
operation: 'update_copy',
|
|
340
290
|
reason: 'copied content differs from canonical content',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ManifestEntry } from '../manifest/manifest.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The banner-and-sentinel-aware hash of an OAT-managed directory copy.
|
|
4
|
+
*
|
|
5
|
+
* When `oat sync` materializes a directory copy it deliberately decorates the
|
|
6
|
+
* provider tree with two artifacts the canonical tree does not have: an
|
|
7
|
+
* `.oat-generated` sentinel and a marker line prepended to `SKILL.md` /
|
|
8
|
+
* `AGENT.md`. Hashing that tree raw therefore never equals the canonical hash
|
|
9
|
+
* the manifest records, which is why every raw reader saw a fresh copy as
|
|
10
|
+
* drifted. This module owns the comparison that accounts for both.
|
|
11
|
+
*
|
|
12
|
+
* Standing claims owned here (backstop: `managed-copy-hash.test.ts`):
|
|
13
|
+
*
|
|
14
|
+
* 1. For a faithful managed directory copy the digest returned is **equal** to
|
|
15
|
+
* `computeDirectoryHash` of the canonical directory — the same value
|
|
16
|
+
* `execute-plan.ts` writes into the manifest `contentHash`. The encoding is
|
|
17
|
+
* not merely the same shape as `manifest/hash.ts`: this module calls that
|
|
18
|
+
* module's `createFramedDirectoryDigest`, `compareDirectoryRelativePaths`
|
|
19
|
+
* and `updateFramedDirectoryDigest`, so the domain tag, the ordering and
|
|
20
|
+
* the per-field length framing cannot drift apart. That framing is what
|
|
21
|
+
* makes the digest a self-delimiting encoding of the sorted file list
|
|
22
|
+
* (wave-7 final review, Critical 4 — the header of `manifest/hash.ts`
|
|
23
|
+
* records the collision that motivated it, the cross-encoding forgery the
|
|
24
|
+
* domain tag closes, and the one-time manifest migration it forces).
|
|
25
|
+
* 2. `null` is returned rather than a digest whenever the sentinel does not
|
|
26
|
+
* name exactly this canonical path — and likewise when the sentinel is
|
|
27
|
+
* absent or has trailing content, when the marker file is missing or does
|
|
28
|
+
* not start with exactly the expected banner, or when the provider tree
|
|
29
|
+
* holds any non-regular entry.
|
|
30
|
+
* 3. "Non-regular entry" includes two shapes the pre-hardening helper waved
|
|
31
|
+
* through, for two different reasons: a `.oat-generated` sentinel that is a
|
|
32
|
+
* symlink (or anything other than a regular file) — it was skipped by
|
|
33
|
+
* pathname before its type was ever examined and then read through a
|
|
34
|
+
* symlink-following `readFile`; and a provider root that is a symlink
|
|
35
|
+
* rather than a real directory — the root was never type-checked before
|
|
36
|
+
* `readdir`, which traverses a symlinked directory. Both return `null`.
|
|
37
|
+
* Without this the detector and planner would newly accept a
|
|
38
|
+
* symlink-substituted provider view as `in_sync`.
|
|
39
|
+
* 4. A `skill` or `agent` copy must actually contain its marker file. The
|
|
40
|
+
* banner check used to be keyed on `file === markerPath` while walking the
|
|
41
|
+
* tree, so deleting `SKILL.md` outright did not fail the check — it simply
|
|
42
|
+
* skipped it. Combined with the unframed digest that let a view drop
|
|
43
|
+
* `SKILL.md`, fuse its bytes into a surviving file, and read `in_sync`. The
|
|
44
|
+
* requirement is unconditional, so a skill directory that genuinely has no
|
|
45
|
+
* `SKILL.md` (a layout `applyCopyMarker` tolerates as best-effort) is now
|
|
46
|
+
* permanently `drifted` / `update_copy` rather than `in_sync`: the safe
|
|
47
|
+
* direction, and the direction the review asked for.
|
|
48
|
+
*
|
|
49
|
+
* These claims describe a quiescent tree. Every check here is path-based, so
|
|
50
|
+
* none of them survives an adversary swapping an entry between the check and
|
|
51
|
+
* the read — the same pre-existing limitation every other filesystem reader in
|
|
52
|
+
* this package shares. The guarantee that does hold unconditionally is the
|
|
53
|
+
* superset property below: each check is additive, so no interleaving lets
|
|
54
|
+
* this helper accept an input the pre-hardening helper rejected.
|
|
55
|
+
*
|
|
56
|
+
* This module's rejection set is therefore a strict superset of the original
|
|
57
|
+
* private helper's, on every consumer. `classifyObsoleteMappingRetirement`
|
|
58
|
+
* inherits the stricter contract deliberately: for the symlinked-sentinel
|
|
59
|
+
* shape it now classifies `detach` rather than `remove`, the safe direction
|
|
60
|
+
* for a destructive path (a symlinked root was already `detach` there, because
|
|
61
|
+
* `expectedTypeMatches` is computed from `lstat` before the helper runs).
|
|
62
|
+
*
|
|
63
|
+
* `null` means "this is not a verifiable managed copy", never "it matches".
|
|
64
|
+
* Callers must fall back to their raw comparison on `null`, so an unverifiable
|
|
65
|
+
* copy degrades to `drifted` / `update_copy` rather than to a false `in_sync`.
|
|
66
|
+
* A tampered body still produces a digest, but a different one.
|
|
67
|
+
*/
|
|
68
|
+
export declare function computeManagedDirectoryCopyHash(providerPath: string, canonicalPath: string, contentType: ManifestEntry['contentType']): Promise<string | null>;
|
|
69
|
+
//# sourceMappingURL=managed-copy-hash.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"managed-copy-hash.d.ts","sourceRoot":"","sources":["../../src/engine/managed-copy-hash.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAI9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,wBAAsB,+BAA+B,CACnD,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,aAAa,CAAC,aAAa,CAAC,GACxC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyHxB"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { compareDirectoryRelativePaths, createFramedDirectoryDigest, updateFramedDirectoryDigest, } from '../manifest/hash.js';
|
|
4
|
+
import { OAT_DIRECTORY_SENTINEL, OAT_MARKER_PREFIX } from './markers.js';
|
|
5
|
+
/**
|
|
6
|
+
* The banner-and-sentinel-aware hash of an OAT-managed directory copy.
|
|
7
|
+
*
|
|
8
|
+
* When `oat sync` materializes a directory copy it deliberately decorates the
|
|
9
|
+
* provider tree with two artifacts the canonical tree does not have: an
|
|
10
|
+
* `.oat-generated` sentinel and a marker line prepended to `SKILL.md` /
|
|
11
|
+
* `AGENT.md`. Hashing that tree raw therefore never equals the canonical hash
|
|
12
|
+
* the manifest records, which is why every raw reader saw a fresh copy as
|
|
13
|
+
* drifted. This module owns the comparison that accounts for both.
|
|
14
|
+
*
|
|
15
|
+
* Standing claims owned here (backstop: `managed-copy-hash.test.ts`):
|
|
16
|
+
*
|
|
17
|
+
* 1. For a faithful managed directory copy the digest returned is **equal** to
|
|
18
|
+
* `computeDirectoryHash` of the canonical directory — the same value
|
|
19
|
+
* `execute-plan.ts` writes into the manifest `contentHash`. The encoding is
|
|
20
|
+
* not merely the same shape as `manifest/hash.ts`: this module calls that
|
|
21
|
+
* module's `createFramedDirectoryDigest`, `compareDirectoryRelativePaths`
|
|
22
|
+
* and `updateFramedDirectoryDigest`, so the domain tag, the ordering and
|
|
23
|
+
* the per-field length framing cannot drift apart. That framing is what
|
|
24
|
+
* makes the digest a self-delimiting encoding of the sorted file list
|
|
25
|
+
* (wave-7 final review, Critical 4 — the header of `manifest/hash.ts`
|
|
26
|
+
* records the collision that motivated it, the cross-encoding forgery the
|
|
27
|
+
* domain tag closes, and the one-time manifest migration it forces).
|
|
28
|
+
* 2. `null` is returned rather than a digest whenever the sentinel does not
|
|
29
|
+
* name exactly this canonical path — and likewise when the sentinel is
|
|
30
|
+
* absent or has trailing content, when the marker file is missing or does
|
|
31
|
+
* not start with exactly the expected banner, or when the provider tree
|
|
32
|
+
* holds any non-regular entry.
|
|
33
|
+
* 3. "Non-regular entry" includes two shapes the pre-hardening helper waved
|
|
34
|
+
* through, for two different reasons: a `.oat-generated` sentinel that is a
|
|
35
|
+
* symlink (or anything other than a regular file) — it was skipped by
|
|
36
|
+
* pathname before its type was ever examined and then read through a
|
|
37
|
+
* symlink-following `readFile`; and a provider root that is a symlink
|
|
38
|
+
* rather than a real directory — the root was never type-checked before
|
|
39
|
+
* `readdir`, which traverses a symlinked directory. Both return `null`.
|
|
40
|
+
* Without this the detector and planner would newly accept a
|
|
41
|
+
* symlink-substituted provider view as `in_sync`.
|
|
42
|
+
* 4. A `skill` or `agent` copy must actually contain its marker file. The
|
|
43
|
+
* banner check used to be keyed on `file === markerPath` while walking the
|
|
44
|
+
* tree, so deleting `SKILL.md` outright did not fail the check — it simply
|
|
45
|
+
* skipped it. Combined with the unframed digest that let a view drop
|
|
46
|
+
* `SKILL.md`, fuse its bytes into a surviving file, and read `in_sync`. The
|
|
47
|
+
* requirement is unconditional, so a skill directory that genuinely has no
|
|
48
|
+
* `SKILL.md` (a layout `applyCopyMarker` tolerates as best-effort) is now
|
|
49
|
+
* permanently `drifted` / `update_copy` rather than `in_sync`: the safe
|
|
50
|
+
* direction, and the direction the review asked for.
|
|
51
|
+
*
|
|
52
|
+
* These claims describe a quiescent tree. Every check here is path-based, so
|
|
53
|
+
* none of them survives an adversary swapping an entry between the check and
|
|
54
|
+
* the read — the same pre-existing limitation every other filesystem reader in
|
|
55
|
+
* this package shares. The guarantee that does hold unconditionally is the
|
|
56
|
+
* superset property below: each check is additive, so no interleaving lets
|
|
57
|
+
* this helper accept an input the pre-hardening helper rejected.
|
|
58
|
+
*
|
|
59
|
+
* This module's rejection set is therefore a strict superset of the original
|
|
60
|
+
* private helper's, on every consumer. `classifyObsoleteMappingRetirement`
|
|
61
|
+
* inherits the stricter contract deliberately: for the symlinked-sentinel
|
|
62
|
+
* shape it now classifies `detach` rather than `remove`, the safe direction
|
|
63
|
+
* for a destructive path (a symlinked root was already `detach` there, because
|
|
64
|
+
* `expectedTypeMatches` is computed from `lstat` before the helper runs).
|
|
65
|
+
*
|
|
66
|
+
* `null` means "this is not a verifiable managed copy", never "it matches".
|
|
67
|
+
* Callers must fall back to their raw comparison on `null`, so an unverifiable
|
|
68
|
+
* copy degrades to `drifted` / `update_copy` rather than to a false `in_sync`.
|
|
69
|
+
* A tampered body still produces a digest, but a different one.
|
|
70
|
+
*/
|
|
71
|
+
export async function computeManagedDirectoryCopyHash(providerPath, canonicalPath, contentType) {
|
|
72
|
+
const expectedMarker = `${OAT_MARKER_PREFIX} Source: ${canonicalPath} -->`;
|
|
73
|
+
const sentinelPath = join(providerPath, OAT_DIRECTORY_SENTINEL);
|
|
74
|
+
try {
|
|
75
|
+
// The provider root must be a real directory. `lstat` does not resolve the
|
|
76
|
+
// final component, so a symlink pointing at an otherwise faithful tree is
|
|
77
|
+
// rejected here rather than silently traversed by `readdir`.
|
|
78
|
+
const providerStat = await lstat(providerPath);
|
|
79
|
+
if (!providerStat.isDirectory()) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Type-check the sentinel before reading it. `readFile` follows symlinks,
|
|
83
|
+
// so without this a symlink to a file holding the exact marker would
|
|
84
|
+
// authenticate a tree the writer never produced.
|
|
85
|
+
//
|
|
86
|
+
// This is a check against the state on disk, not a handle-bound one: like
|
|
87
|
+
// every other filesystem reader in this package (`manifest/hash.ts`, the
|
|
88
|
+
// detector's own `lstat` + `computeContentHash`, and this file's caller in
|
|
89
|
+
// `classifyObsoleteMappingRetirement`), it resolves each path more than
|
|
90
|
+
// once and so cannot exclude a concurrent swap between the check and the
|
|
91
|
+
// read. It is not a defence against an adversary racing writes inside the
|
|
92
|
+
// provider directory; closing that would take handle-bound traversal
|
|
93
|
+
// (`openat`/`O_NOFOLLOW`) across all of those readers. What it does
|
|
94
|
+
// guarantee is that this helper's checks are a strict superset of the ones
|
|
95
|
+
// it replaced, so no input accepted here was rejected before.
|
|
96
|
+
const sentinelStat = await lstat(sentinelPath);
|
|
97
|
+
if (!sentinelStat.isFile()) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const sentinel = await readFile(sentinelPath, 'utf8');
|
|
101
|
+
if (sentinel !== `${expectedMarker}\n`) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const files = [];
|
|
109
|
+
async function collectFiles(current) {
|
|
110
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const fullPath = join(current, entry.name);
|
|
113
|
+
if (fullPath === sentinelPath) {
|
|
114
|
+
// Validate the type before skipping by pathname: a non-regular
|
|
115
|
+
// sentinel must disqualify the tree, not be waved through.
|
|
116
|
+
if (!entry.isFile()) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
if (!(await collectFiles(fullPath))) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!entry.isFile()) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
files.push(fullPath);
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
if (!(await collectFiles(providerPath))) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
files.sort((left, right) => compareDirectoryRelativePaths(relative(providerPath, left), relative(providerPath, right)));
|
|
139
|
+
const markerFileName = contentType === 'skill'
|
|
140
|
+
? 'SKILL.md'
|
|
141
|
+
: contentType === 'agent'
|
|
142
|
+
? 'AGENT.md'
|
|
143
|
+
: null;
|
|
144
|
+
const markerPath = markerFileName
|
|
145
|
+
? join(providerPath, markerFileName)
|
|
146
|
+
: null;
|
|
147
|
+
// The marker file must be present, not merely validated when it happens to
|
|
148
|
+
// exist. Keying the banner check on `file === markerPath` inside the loop
|
|
149
|
+
// below meant a view with no `SKILL.md` at all skipped the check entirely,
|
|
150
|
+
// which is half of the forgery this module now rejects.
|
|
151
|
+
if (markerPath !== null && !files.includes(markerPath)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const hash = createFramedDirectoryDigest();
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const relativePath = relative(providerPath, file);
|
|
157
|
+
let content = await readFile(file);
|
|
158
|
+
if (file === markerPath) {
|
|
159
|
+
const markerPrefix = Buffer.from(`${expectedMarker}\n`);
|
|
160
|
+
if (content.length < markerPrefix.length ||
|
|
161
|
+
!content.subarray(0, markerPrefix.length).equals(markerPrefix)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
content = content.subarray(markerPrefix.length);
|
|
165
|
+
}
|
|
166
|
+
// Shared with `manifest/hash.ts` rather than mirrored: the canonical
|
|
167
|
+
// digest this must reproduce is produced by the very same encoder.
|
|
168
|
+
updateFramedDirectoryDigest(hash, relativePath, content);
|
|
169
|
+
}
|
|
170
|
+
return hash.digest('hex');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|