wicked-crew 0.7.25 → 0.7.26
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/dist/api/endpoint-manifest-live.d.ts.map +1 -1
- package/dist/api/endpoint-manifest-live.js +5 -0
- package/dist/api/endpoint-manifest-live.js.map +1 -1
- package/dist/api/eval-compare.d.ts +306 -0
- package/dist/api/eval-compare.d.ts.map +1 -0
- package/dist/api/eval-compare.js +610 -0
- package/dist/api/eval-compare.js.map +1 -0
- package/dist/api/eval-sample.d.ts +158 -0
- package/dist/api/eval-sample.d.ts.map +1 -0
- package/dist/api/eval-sample.js +88 -0
- package/dist/api/eval-sample.js.map +1 -0
- package/dist/api/routes.d.ts +5 -0
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +31 -0
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-files.d.ts +12 -2
- package/dist/api/run-files.d.ts.map +1 -1
- package/dist/api/run-files.js +17 -5
- package/dist/api/run-files.js.map +1 -1
- package/dist/api/server.d.ts +20 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +62 -9
- package/dist/api/server.js.map +1 -1
- package/dist/api/skills.d.ts +88 -0
- package/dist/api/skills.d.ts.map +1 -0
- package/dist/api/skills.js +264 -0
- package/dist/api/skills.js.map +1 -0
- package/dist/api/testing.d.ts +2 -75
- package/dist/api/testing.d.ts.map +1 -1
- package/dist/api/testing.js +14 -28
- package/dist/api/testing.js.map +1 -1
- package/dist/core/adapter.d.ts +42 -0
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +83 -12
- package/dist/core/adapter.js.map +1 -1
- package/dist/interactive/bridge-root.d.ts +117 -7
- package/dist/interactive/bridge-root.d.ts.map +1 -1
- package/dist/interactive/bridge-root.js +229 -9
- package/dist/interactive/bridge-root.js.map +1 -1
- package/dist/interactive/doc-delete-routes.d.ts +2 -0
- package/dist/interactive/doc-delete-routes.d.ts.map +1 -1
- package/dist/interactive/doc-delete-routes.js +3 -19
- package/dist/interactive/doc-delete-routes.js.map +1 -1
- package/dist/interactive/doc-list-routes.d.ts +42 -0
- package/dist/interactive/doc-list-routes.d.ts.map +1 -0
- package/dist/interactive/doc-list-routes.js +165 -0
- package/dist/interactive/doc-list-routes.js.map +1 -0
- package/dist/interactive/project-root.d.ts +36 -0
- package/dist/interactive/project-root.d.ts.map +1 -0
- package/dist/interactive/project-root.js +45 -0
- package/dist/interactive/project-root.js.map +1 -0
- package/dist/interactive/proxy-routes.d.ts +4 -1
- package/dist/interactive/proxy-routes.d.ts.map +1 -1
- package/dist/interactive/proxy-routes.js +4 -22
- package/dist/interactive/proxy-routes.js.map +1 -1
- package/dist/projects/default-project.d.ts +10 -0
- package/dist/projects/default-project.d.ts.map +1 -0
- package/dist/projects/default-project.js +10 -0
- package/dist/projects/default-project.js.map +1 -0
- package/dist/projects/routes.d.ts +0 -2
- package/dist/projects/routes.d.ts.map +1 -1
- package/dist/projects/routes.js +1 -2
- package/dist/projects/routes.js.map +1 -1
- package/dist/skills/bundle.d.ts +94 -0
- package/dist/skills/bundle.d.ts.map +1 -0
- package/dist/skills/bundle.js +187 -0
- package/dist/skills/bundle.js.map +1 -0
- package/dist/skills/contain.d.ts +57 -0
- package/dist/skills/contain.d.ts.map +1 -0
- package/dist/skills/contain.js +90 -0
- package/dist/skills/contain.js.map +1 -0
- package/dist/skills/core-closure.d.ts +83 -0
- package/dist/skills/core-closure.d.ts.map +1 -0
- package/dist/skills/core-closure.js +130 -0
- package/dist/skills/core-closure.js.map +1 -0
- package/dist/skills/engine-env.d.ts +48 -0
- package/dist/skills/engine-env.d.ts.map +1 -0
- package/dist/skills/engine-env.js +69 -0
- package/dist/skills/engine-env.js.map +1 -0
- package/dist/skills/frontmatter.d.ts +77 -0
- package/dist/skills/frontmatter.d.ts.map +1 -0
- package/dist/skills/frontmatter.js +190 -0
- package/dist/skills/frontmatter.js.map +1 -0
- package/dist/skills/guards.d.ts +72 -0
- package/dist/skills/guards.d.ts.map +1 -0
- package/dist/skills/guards.js +147 -0
- package/dist/skills/guards.js.map +1 -0
- package/dist/skills/live-generations.d.ts +80 -0
- package/dist/skills/live-generations.d.ts.map +1 -0
- package/dist/skills/live-generations.js +163 -0
- package/dist/skills/live-generations.js.map +1 -0
- package/dist/skills/plugin-source.d.ts +109 -0
- package/dist/skills/plugin-source.d.ts.map +1 -0
- package/dist/skills/plugin-source.js +272 -0
- package/dist/skills/plugin-source.js.map +1 -0
- package/dist/skills/refs.d.ts +58 -0
- package/dist/skills/refs.d.ts.map +1 -0
- package/dist/skills/refs.js +133 -0
- package/dist/skills/refs.js.map +1 -0
- package/dist/skills/root-fence.d.ts +53 -0
- package/dist/skills/root-fence.d.ts.map +1 -0
- package/dist/skills/root-fence.js +109 -0
- package/dist/skills/root-fence.js.map +1 -0
- package/dist/skills/root-names.d.ts +42 -0
- package/dist/skills/root-names.d.ts.map +1 -0
- package/dist/skills/root-names.js +44 -0
- package/dist/skills/root-names.js.map +1 -0
- package/dist/skills/runtime.d.ts +130 -0
- package/dist/skills/runtime.d.ts.map +1 -0
- package/dist/skills/runtime.js +246 -0
- package/dist/skills/runtime.js.map +1 -0
- package/dist/skills/store.d.ts +895 -0
- package/dist/skills/store.d.ts.map +1 -0
- package/dist/skills/store.js +3575 -0
- package/dist/skills/store.js.map +1 -0
- package/dist/skills/tree.d.ts +292 -0
- package/dist/skills/tree.d.ts.map +1 -0
- package/dist/skills/tree.js +645 -0
- package/dist/skills/tree.js.map +1 -0
- package/dist/skills/venv.d.ts +52 -0
- package/dist/skills/venv.d.ts.map +1 -0
- package/dist/skills/venv.js +80 -0
- package/dist/skills/venv.js.map +1 -0
- package/dist/studio/assets/index-CK1uJWs2.css +32 -0
- package/dist/studio/assets/index-FcZg3WDR.js +539 -0
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +605 -7
- package/endpoint-manifest.json +183 -1
- package/package.json +4 -3
- package/dist/studio/assets/index-C4iz4uAw.js +0 -537
- package/dist/studio/assets/index-DEEQcRQ_.css +0 -32
|
@@ -0,0 +1,3575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon-owned skills root (design v3 DECISIONS 1/4/5/6/7): ONE effective garden-shaped plugin
|
|
3
|
+
* root the operator edits like a filesystem, PUBLISHED as immutable snapshots the engine hands to
|
|
4
|
+
* every worker. Nothing a worker runs is ever read from `effective/`.
|
|
5
|
+
*
|
|
6
|
+
* # Layout — `<root>/` (`<crewStateHome()>/skills` by default; never a `~/.wicked-crew` literal —
|
|
7
|
+
* the ONE storage root every crew store shares, design v3.1 §1; the worker Read fence is core's
|
|
8
|
+
* explicit denylist of state-home subtrees — `skills/baseline/`, `skills/effective/`,
|
|
9
|
+
* `skills/manifest.json`, `skills/current`, … — with the resolved `skills/snapshots/<gen>/` the
|
|
10
|
+
* only non-denied path; `tests/fixtures/state-home-subtrees.json` is the shared registry)
|
|
11
|
+
*
|
|
12
|
+
* manifest.json the state (`SkillManifest`; presence = seeded), CAS `revision`
|
|
13
|
+
* baseline/<contentHash>/ the shipped bundle (dependency closure, bundle.ts) — identity is the
|
|
14
|
+
* content hash of the bundle, never the version string alone; `.venv`
|
|
15
|
+
* is provisioned here once per hash (venv.ts) and shared READ-ONLY;
|
|
16
|
+
* a baseline lives as long as ANY snapshot on disk references it
|
|
17
|
+
* effective/ the same shape, holding what the operator edits: EVERY skill, enabled
|
|
18
|
+
* or not (enablement is manifest state, orthogonal to content)
|
|
19
|
+
* snapshots/<gen>/ immutable published trees, LOCKED read-only at publish: enabled
|
|
20
|
+
* skills only, nested layout verbatim, support closure, `snapshot.json`,
|
|
21
|
+
* `.venv -> baseline venv` (only when that env exists), and the
|
|
22
|
+
* generated delivery VIEWS (below)
|
|
23
|
+
* snapshots/<gen>/views/copilot/.github/skills/<name>/
|
|
24
|
+
* the copilot view (design v3.2 §2/§4): a copy of every enabled
|
|
25
|
+
* PORTABLE skill's own files under its frontmatter name — what core
|
|
26
|
+
* hands a copilot seat as `--add-dir <snapshot>/views/copilot`.
|
|
27
|
+
* Generated at publish, part of the content hash, never edited. The
|
|
28
|
+
* ONLY view today; `portable` is the admission key for every one.
|
|
29
|
+
* current -> snapshots/<gen> flipped atomically after each publish; the engine receives the
|
|
30
|
+
* absolute REAL path of the generation as `WICKED_SKILLS_SNAPSHOT`
|
|
31
|
+
* (engine-env.ts) — the ONLY input core reads (v3.1 §2)
|
|
32
|
+
* .uv-cache/ the daemon's own uv cache (`UV_CACHE_DIR`), never the operator's
|
|
33
|
+
*
|
|
34
|
+
* NOTHING under this root is ever written into the user's own CLI directories — `~/.codex`,
|
|
35
|
+
* `~/.pi`, `~/.copilot`, `~/.config/opencode`, `~/.claude` are never touched (design v3.2 §1; the
|
|
36
|
+
* v3 additive mirror is withdrawn). Skills reach workers only through per-launch, wicked-owned
|
|
37
|
+
* delivery core performs from the snapshot (Claude: the plugin root; pi: `--skill` per portable
|
|
38
|
+
* skill; copilot: `--add-dir <snapshot>/views/copilot`). A CLI without a lever (codex today) runs
|
|
39
|
+
* WITHOUT wicked skills, and a unit on such a seat that requires one (`skill_ref` / mandate) is
|
|
40
|
+
* REFUSED at launch by core — there is no proceed-with-disclosure setting and never a side channel.
|
|
41
|
+
*
|
|
42
|
+
* # Identity, ownership, hashes
|
|
43
|
+
*
|
|
44
|
+
* A skill is a directory holding `SKILL.md`, keyed by the PATH-DERIVED name
|
|
45
|
+
* `wicked-garden-<dir segments joined by '-'>` — which the live plugin's frontmatter `name` equals
|
|
46
|
+
* for all 142 (unique). A declared name that differs is a `name-mismatch` finding, blocking at
|
|
47
|
+
* publish; a declared name that is ANOTHER catalog skill's key is a `name-collision`, blocking
|
|
48
|
+
* whether or not the declaring skill is enabled: the manifest key, the directory, and the
|
|
49
|
+
* invocation identity are one thing. A skill's OWN files are everything under its dir except a
|
|
50
|
+
* nested skill's subtree — the deepest `SKILL.md` ancestor ON DISK owns a path (v3 §6; codex round
|
|
51
|
+
* 2: ownership follows the filesystem, not the manifest, so a child created by a direct edit is
|
|
52
|
+
* still nobody else's), so disabling/resetting/replacing a parent never touches a child, and the
|
|
53
|
+
* file API refuses a child's path through the parent's endpoint. Every managed file carries
|
|
54
|
+
* `{baselineHash, effectiveHash, lastPublishedHash}`; provenance is derived from them, never
|
|
55
|
+
* asserted. Every mutation takes `expectedRevision` (CAS) and bumps `revision`; direct filesystem
|
|
56
|
+
* edits are detected by hash at publish and REPORTED (`fs-drift`), never silently trusted.
|
|
57
|
+
*
|
|
58
|
+
* # Containment (no-follow everywhere, the root included)
|
|
59
|
+
*
|
|
60
|
+
* Every path the store reads or writes — a write's DESTINATION (replace/add descendants included),
|
|
61
|
+
* a baseline READ (reset, the `?side=baseline` copy, every baseline hash lookup) — is lstat-walked
|
|
62
|
+
* FROM THE SKILLS ROOT (`effective/…`, `baseline/<hash>/…`), so a skill directory replaced by a
|
|
63
|
+
* symlink, a symlinked child inside it, or a symlinked baseline ancestor is refused, never
|
|
64
|
+
* followed (contain.ts / tree.ts). The ROOT ITSELF is checked before EVERY read and mutation
|
|
65
|
+
* (`assertRootIdentity`, codex round 4): it must be a real directory, never a symlink, whose
|
|
66
|
+
* canonical path is the one the store bound at boot — a root replaced by a link to a copied store
|
|
67
|
+
* (or an ancestor swapped under the daemon) refuses every operation (`SkillsRootInvalidError`, a
|
|
68
|
+
* loud 503), never redirects one. Every PERSISTED name is validated at manifest parse — a skill
|
|
69
|
+
* `dir`, a baseline hash, a file-record key, and the skill KEY itself (a safe single segment that
|
|
70
|
+
* IS the path-derived name of its `dir`; the copilot view lays a skill out under its key) — and
|
|
71
|
+
* `copyFiles` re-checks every destination's shape and walk before the first byte moves. A
|
|
72
|
+
* path-guard failure on a WRITE answers the normal 2xx `{verdict: 'blocked', findings:
|
|
73
|
+
* [path-invalid]}` envelope, never a 400 — a refresh included: it preflights every destination,
|
|
74
|
+
* stages under the root and swaps, so a refused destination changes NOTHING. Names the store itself
|
|
75
|
+
* owns at the snapshot/root level (`snapshot.json`, `manifest.json`, `current`, `views/`, `.venv`)
|
|
76
|
+
* are refused as support paths.
|
|
77
|
+
*
|
|
78
|
+
* # Publish (serialized, root-bound, crash-safe, idempotent on retry)
|
|
79
|
+
*
|
|
80
|
+
* ONE publish at a time (a second concurrent request is refused, `SkillsPublishInFlightError`; the
|
|
81
|
+
* route answers it as a 2xx `blocked` `publish-in-flight` envelope — nothing was written, so it is
|
|
82
|
+
* not a 409, codex round 3). The operation binds the root's identity (path + realpath) at start and
|
|
83
|
+
* re-checks it after every await: a root whose canonical path moved or vanished while the baseline
|
|
84
|
+
* env was provisioning aborts the publish (`SkillsRootChangedError` → the 2xx `blocked`
|
|
85
|
+
* `root-changed` envelope) — nothing is written through a root the operation did not validate. (The
|
|
86
|
+
* root is never re-aimed: `<state home>/skills`, full stop — root-fence.ts; there is no `skills_root`
|
|
87
|
+
* setting and no env override, codex round 5.) Provisions the
|
|
88
|
+
* baseline's `.venv` (awaited; one provisioning per baseline
|
|
89
|
+
* hash — a concurrent caller awaits the in-flight one; a snapshot never links an env still being
|
|
90
|
+
* written; a FAILED provisioning is BLOCKING, `venv-failed` — the shared env is required, not
|
|
91
|
+
* best-effort), re-checks the CAS, validates the WHOLE tree — frontmatter (strict subset), name ==
|
|
92
|
+
* path, no cross-catalog name collision, the core closure COMPLETE (a missing registered ref or an
|
|
93
|
+
* absent transitive mandate is BLOCKING), every `${CLAUDE_PLUGIN_ROOT}/<p>` and `../<p>` reference
|
|
94
|
+
* of an enabled skill judged by TARGET (one that ESCAPES the plugin root is BLOCKING; one whose
|
|
95
|
+
* target is MISSING inside it is a WARNING — design v3.4 §1: an upstream content bug publishes as
|
|
96
|
+
* found, `verdict: 'warnings'`), every support file INSIDE the bundle closure (a file outside the
|
|
97
|
+
* ONE allowlist — bundle.ts `inBundleClosure` — is BLOCKING, `outside-closure`), the plugin manifest
|
|
98
|
+
* naming the plugin and the `.claude-plugin/*` catalogs present AND well-formed, no unregistered
|
|
99
|
+
* `SKILL.md` — then: allocates the generation from the FILESYSTEM (max existing + 1), writes
|
|
100
|
+
* `snapshots/.staging-<random>/` (the snapshot files + the generated views), renames it to
|
|
101
|
+
* `snapshots/<gen>/` (never over an existing generation), LOCKS it read-only, commits the manifest
|
|
102
|
+
* (the venv state included — nothing is persisted before validation passes), and only THEN flips
|
|
103
|
+
* `current`. A crash between the commit and the flip is finished at the next boot (`ensureReady`);
|
|
104
|
+
* a torn staging dir is swept by the next publish. Generations beyond the newest three that no
|
|
105
|
+
* LIVE run may still be reading are reaped (`live`, fed from the CoreEvent stream AND from the
|
|
106
|
+
* daemon's own launch handoffs — live-generations.ts: a generation handed to a launch stays pinned
|
|
107
|
+
* until the engine reports what that launch used or the run ends, never released by publish
|
|
108
|
+
* count), and a baseline is removed only once no generation on disk references it.
|
|
109
|
+
* `analyze` is the same validation as a PURE dry run: it persists nothing and moves no revision; a
|
|
110
|
+
* `blocked` publish persists nothing either — not the drift it observed, not the provisioning state.
|
|
111
|
+
*
|
|
112
|
+
* # `current` is never trusted — for the daemon's whole lifetime
|
|
113
|
+
*
|
|
114
|
+
* `currentSnapshot()` realpaths the link, requires the target to be a generation directory directly
|
|
115
|
+
* under `snapshots/`, requires a well-formed `snapshot.json` whose `gen` matches the directory and
|
|
116
|
+
* whose skill rows are safe relative `skills/…` dirs with the path-derived name, and re-hashes the
|
|
117
|
+
* tree against `contentHash` before it exports the path or reads the metadata — on EVERY call
|
|
118
|
+
* (codex round 2: a verification cached by link text was ineffective after its first success; a
|
|
119
|
+
* snapshot modified under a running daemon is refused by the same store instance). Any failure is
|
|
120
|
+
* `SkillsCurrentInvalidError` — a loud config error, never a silent "not published".
|
|
121
|
+
*/
|
|
122
|
+
import { randomBytes } from 'node:crypto';
|
|
123
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, } from 'node:fs';
|
|
124
|
+
import { basename, dirname, join, posix, resolve } from 'node:path';
|
|
125
|
+
import { NotARegularFileError, readFileCapped } from '../api/run-files.js';
|
|
126
|
+
import { crewStateHome } from '../projects/state-home.js';
|
|
127
|
+
import { BUNDLE_CLOSURE_SPELLING, inBundleClosure, NotAPluginRootError, owningSkillDir, pluginBundleFiles, REQUIRED_PLUGIN_CATALOGS, skillDirsOf, SKILLS_SUBDIR, } from './bundle.js';
|
|
128
|
+
import { containedPath, SkillPathError, validateRelSegments } from './contain.js';
|
|
129
|
+
import { coreClosure } from './core-closure.js';
|
|
130
|
+
import { derivedSkillName, parseFrontmatter, PLUGIN_NAME, SKILL_NAME_PREFIX, skillKindOf } from './frontmatter.js';
|
|
131
|
+
import { collisionGuard, coreDisableGuard, finding, frontmatterGuard, nameGuard, nestedSkillCreateGuard, noBaselineGuard, nonPortableGuard, SKILL_NAME_RE, supportFileGuard, verdictOf, } from './guards.js';
|
|
132
|
+
import { LiveGenerations } from './live-generations.js';
|
|
133
|
+
import { discoverLivePlugin, gitStateOf, PluginSourceSymlinkError } from './plugin-source.js';
|
|
134
|
+
import { extractPluginRootRefs, extractRelativeRefs, looksBinary, portabilityIssueOf, resolvePluginRootRef, resolveRelativeRef, } from './refs.js';
|
|
135
|
+
import { CURRENT_TMP_PREFIX, STAGING_PREFIX } from './root-names.js';
|
|
136
|
+
import { assertNoSymlinkComponents, copyFiles, EntrySwappedError, hashFileSet, hashTree, impliedDirs, isCarriedFile, makeTreeReadOnly, pruneEmptyDirs, readFileNoFollow, removeTreeForce, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntries, walkFiles, walkTree, writeFileAtomic, } from './tree.js';
|
|
137
|
+
/** The root-level names the store creates live in ONE table (`root-names.ts`, design v3.5 §2) — re-exported for the tests that observe the flip. */
|
|
138
|
+
export { CURRENT_TMP_PREFIX } from './root-names.js';
|
|
139
|
+
import { baselineVenvDir, UV_CACHE_DIRNAME, VENV_READY_MARKER } from './venv.js';
|
|
140
|
+
/** The root's name under the daemon state home — a top-level entry `tests/fixtures/state-home-subtrees.json` registers. */
|
|
141
|
+
export const SKILLS_DIRNAME = 'skills';
|
|
142
|
+
export const MANIFEST_FILENAME = 'manifest.json';
|
|
143
|
+
export const EFFECTIVE_DIRNAME = 'effective';
|
|
144
|
+
export const BASELINE_DIRNAME = 'baseline';
|
|
145
|
+
export const SNAPSHOTS_DIRNAME = 'snapshots';
|
|
146
|
+
export const CURRENT_LINKNAME = 'current';
|
|
147
|
+
export const SNAPSHOT_MANIFEST_FILENAME = 'snapshot.json';
|
|
148
|
+
/** The generated delivery views inside a snapshot (design v3.2 §4). */
|
|
149
|
+
export const VIEWS_DIRNAME = 'views';
|
|
150
|
+
/** The copilot view root inside a snapshot — what core passes as `--add-dir`. */
|
|
151
|
+
export const COPILOT_VIEW_REL = `${VIEWS_DIRNAME}/copilot`;
|
|
152
|
+
/** Where the copilot view lays its skills out: copilot loads `.github/skills/<name>/SKILL.md` from an added dir. */
|
|
153
|
+
export const COPILOT_VIEW_SKILLS_REL = `${COPILOT_VIEW_REL}/.github/skills`;
|
|
154
|
+
/** The `.venv` link name inside a snapshot (the shared per-baseline env). */
|
|
155
|
+
const VENV_LINKNAME = '.venv';
|
|
156
|
+
/**
|
|
157
|
+
* Root-level names the store itself owns — refused as support paths (codex round 2: a support
|
|
158
|
+
* file named `snapshot.json` published `clear` and then failed `current` verification because
|
|
159
|
+
* publish overwrote it with the generated metadata).
|
|
160
|
+
*/
|
|
161
|
+
export const RESERVED_SUPPORT_NAMES = new Set([
|
|
162
|
+
SNAPSHOT_MANIFEST_FILENAME,
|
|
163
|
+
MANIFEST_FILENAME,
|
|
164
|
+
CURRENT_LINKNAME,
|
|
165
|
+
VIEWS_DIRNAME,
|
|
166
|
+
VENV_LINKNAME,
|
|
167
|
+
]);
|
|
168
|
+
export const MANIFEST_VERSION = 2;
|
|
169
|
+
/** Generations kept after a publish (the newest `current` included) — older ones are reaped. */
|
|
170
|
+
export const KEEP_GENERATIONS = 3;
|
|
171
|
+
/** The `.claude-plugin/plugin.json` every snapshot must carry with the plugin's own name. */
|
|
172
|
+
const PLUGIN_JSON_REL = '.claude-plugin/plugin.json';
|
|
173
|
+
/** Drift findings name at most this many paths — the count carries the rest. */
|
|
174
|
+
const DRIFT_LIST_CAP = 12;
|
|
175
|
+
// `STAGING_PREFIX` (a half-written directory — a torn publish / seed / refresh — swept at the next
|
|
176
|
+
// pass) and `CURRENT_TMP_PREFIX` (the transient `current` link while it is being flipped — created
|
|
177
|
+
// INSIDE `snapshots/`, a name core's fence classifies, renamed over `skills/current`) come from
|
|
178
|
+
// `root-names.ts`: the ONE table of every name the store creates under the root (design v3.5 §2).
|
|
179
|
+
/**
|
|
180
|
+
* Where the root lives: `<state home>/skills` — full stop (design v3.1 §1: the same storage root as
|
|
181
|
+
* every other crew store; v3.2 §1: never a user CLI directory). There is no setting and no env
|
|
182
|
+
* override (codex round 5 on #480: a configurable root let seeding write into `~/.codex/skills`);
|
|
183
|
+
* the daemon's state home is the ONE knob (`--db`), and the boot asserts the resolved root stays
|
|
184
|
+
* fenced (root-fence.ts). Tests redirect the state home, never the root.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveSkillsRoot() {
|
|
187
|
+
return join(crewStateHome(), SKILLS_DIRNAME);
|
|
188
|
+
}
|
|
189
|
+
/** The root has no manifest: nothing was seeded (no installed plugin, or the seed is not armed). */
|
|
190
|
+
export class SkillsUnseededError extends Error {
|
|
191
|
+
root;
|
|
192
|
+
constructor(root) {
|
|
193
|
+
super(`skills root ${root} is not seeded — no manifest.json. The daemon seeds it at boot from the live ` +
|
|
194
|
+
`installed wicked-garden plugin; install the plugin (or set WICKED_CREW_SKILLS_SOURCE) and restart`);
|
|
195
|
+
this.root = root;
|
|
196
|
+
this.name = 'SkillsUnseededError';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** No plugin source could be found to seed or refresh from — crew does not vendor garden (v3 §8). */
|
|
200
|
+
export class SkillsSourceUnavailableError extends Error {
|
|
201
|
+
constructor(detail) {
|
|
202
|
+
super(`no wicked-garden plugin source: ${detail} — install wicked-garden first`);
|
|
203
|
+
this.name = 'SkillsSourceUnavailableError';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** `manifest.json` exists but is not a manifest — refused loudly, never blanked (it holds enablement). */
|
|
207
|
+
export class SkillsManifestCorruptError extends Error {
|
|
208
|
+
path;
|
|
209
|
+
constructor(path, detail) {
|
|
210
|
+
super(`${path} is not a skills manifest (${detail}) — fix or remove it; it is not rewritten from a blank state because it records which skills are enabled`);
|
|
211
|
+
this.path = path;
|
|
212
|
+
this.name = 'SkillsManifestCorruptError';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* `current` exists but does not point at a valid published generation — a loud config error. The
|
|
217
|
+
* store never exports or reads through a link it could not verify (design v3 §3: "explicit path
|
|
218
|
+
* invalid → fails loudly").
|
|
219
|
+
*/
|
|
220
|
+
export class SkillsCurrentInvalidError extends Error {
|
|
221
|
+
link;
|
|
222
|
+
constructor(link, detail) {
|
|
223
|
+
super(`${link} does not point at a valid published snapshot: ${detail} — re-publish (POST /skills/publish) or remove the link`);
|
|
224
|
+
this.link = link;
|
|
225
|
+
this.name = 'SkillsCurrentInvalidError';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** A publish could not land its generation directory without destroying an existing one, or could not lock it. */
|
|
229
|
+
export class SkillsPublishError extends Error {
|
|
230
|
+
constructor(detail) {
|
|
231
|
+
super(`publish refused: ${detail}`);
|
|
232
|
+
this.name = 'SkillsPublishError';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A publish is already in flight — one at a time. The route answers a 2xx `blocked`
|
|
236
|
+
* `publish-in-flight` envelope (nothing was written), never a 409 (codex round 3). */
|
|
237
|
+
export class SkillsPublishInFlightError extends Error {
|
|
238
|
+
revision;
|
|
239
|
+
constructor(revision) {
|
|
240
|
+
super('publish refused: another publish is in flight — one publish runs at a time; wait for it and retry against the revision it answers');
|
|
241
|
+
this.revision = revision;
|
|
242
|
+
this.name = 'SkillsPublishInFlightError';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** The skills root changed identity (its canonical path moved, or it vanished) while a publish was
|
|
246
|
+
* awaiting its baseline env — the operation is aborted, nothing was written. The route answers a
|
|
247
|
+
* 2xx `blocked` `root-changed` envelope, not a 409 (codex round 3). */
|
|
248
|
+
export class SkillsRootChangedError extends Error {
|
|
249
|
+
root;
|
|
250
|
+
revision;
|
|
251
|
+
constructor(root, detail, revision) {
|
|
252
|
+
super(`publish aborted: the skills root ${root} ${detail} while the baseline environment was being provisioned — nothing was published; re-read GET /skills and retry`);
|
|
253
|
+
this.root = root;
|
|
254
|
+
this.revision = revision;
|
|
255
|
+
this.name = 'SkillsRootChangedError';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The skills root ITSELF is not the directory the store bound: a symlink stands in for it, it is
|
|
260
|
+
* not a directory, or its canonical path moved under the running daemon. Every read and mutation
|
|
261
|
+
* is refused (a loud 503 on the routes, `skills.config` at boot) — nothing is ever read or written
|
|
262
|
+
* through it (codex round 4).
|
|
263
|
+
*/
|
|
264
|
+
export class SkillsRootInvalidError extends Error {
|
|
265
|
+
root;
|
|
266
|
+
constructor(root, detail) {
|
|
267
|
+
super(`skills root ${root} is not usable: ${detail} — every read and mutation is refused; restore the directory under the daemon state home and restart`);
|
|
268
|
+
this.root = root;
|
|
269
|
+
this.name = 'SkillsRootInvalidError';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A `baseline/<hash>` on disk does not hash to `<hash>` (a bundle file modified, planted or removed,
|
|
274
|
+
* a symlink inside) — content-addressed baselines are verified before EVERY reuse (codex round 7):
|
|
275
|
+
* a refresh refuses to reuse it (its 2xx `blocked` `baseline-corrupt` envelope, nothing copied),
|
|
276
|
+
* reset refuses to restore from it, publish/analyze report it blocking; only the seed re-captures.
|
|
277
|
+
*/
|
|
278
|
+
export class SkillsBaselineCorruptError extends Error {
|
|
279
|
+
dir;
|
|
280
|
+
constructor(dir, detail) {
|
|
281
|
+
super(`baseline ${dir} is corrupt: ${detail} — its content does not hash to its name; nothing was copied from it`);
|
|
282
|
+
this.dir = dir;
|
|
283
|
+
this.name = 'SkillsBaselineCorruptError';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export class UnknownSkillError extends Error {
|
|
287
|
+
skillName;
|
|
288
|
+
constructor(skillName) {
|
|
289
|
+
super(`unknown skill: ${skillName}`);
|
|
290
|
+
this.skillName = skillName;
|
|
291
|
+
this.name = 'UnknownSkillError';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** The caller's `expectedRevision` is stale — the 409 of every mutation. */
|
|
295
|
+
export class RevisionMismatchError extends Error {
|
|
296
|
+
expected;
|
|
297
|
+
actual;
|
|
298
|
+
constructor(expected, actual) {
|
|
299
|
+
super(`revision mismatch: expected ${expected}, the manifest is at ${actual} — re-read GET /skills and retry`);
|
|
300
|
+
this.expected = expected;
|
|
301
|
+
this.actual = actual;
|
|
302
|
+
this.name = 'RevisionMismatchError';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A skill row is `{name, dir}` + a boolean `portable` (the seat-compatibility fact core requires),
|
|
307
|
+
* and — codex round 2: metadata is never trusted to name paths — `name` is a legal skill name,
|
|
308
|
+
* `dir` a safe relative `skills/<…>` path (no `..`, no absolute, no empty segment, no separator
|
|
309
|
+
* but the nested `/`) whose path-derived name IS `name`. A row that fails this cannot address any
|
|
310
|
+
* file of the snapshot it sits in.
|
|
311
|
+
*/
|
|
312
|
+
function isSnapshotSkillRow(row) {
|
|
313
|
+
if (typeof row !== 'object' || row === null)
|
|
314
|
+
return false;
|
|
315
|
+
const r = row;
|
|
316
|
+
if (typeof r.name !== 'string' || !SKILL_NAME_RE.test(r.name) || !r.name.startsWith(SKILL_NAME_PREFIX))
|
|
317
|
+
return false;
|
|
318
|
+
if (typeof r.dir !== 'string' || typeof r.portable !== 'boolean')
|
|
319
|
+
return false;
|
|
320
|
+
// EVERY field is typed (codex round 7): `kind` an enum, `core` / `nested` real booleans — and
|
|
321
|
+
// `nested` IS the fact the dir spells, a row cannot claim otherwise. `kind` and `portable` are
|
|
322
|
+
// re-derived from the generation's own files at verify (`snapshotRowsProblem`); `core` is
|
|
323
|
+
// authenticated by the manifest's metadata hash.
|
|
324
|
+
if (!SKILL_KINDS.has(String(r.kind)) || typeof r.core !== 'boolean' || typeof r.nested !== 'boolean')
|
|
325
|
+
return false;
|
|
326
|
+
let segments;
|
|
327
|
+
try {
|
|
328
|
+
segments = validateRelSegments(r.dir);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (segments.length < 2 || segments[0] !== SKILLS_SUBDIR)
|
|
334
|
+
return false;
|
|
335
|
+
if (r.nested !== isNestedSkillDir(r.dir))
|
|
336
|
+
return false;
|
|
337
|
+
return derivedSkillName(segments.slice(1).join('/')) === r.name;
|
|
338
|
+
}
|
|
339
|
+
/** Rows sorted by name and unique — the order publish writes; a reordered or duplicated row is not publish's metadata. */
|
|
340
|
+
function isSortedUniqueRows(rows) {
|
|
341
|
+
for (let i = 1; i < rows.length; i += 1) {
|
|
342
|
+
if (!(rows[i - 1].name < rows[i].name))
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The `views` block: exactly the copilot view at its fixed dir, naming EXACTLY the sorted set of the
|
|
349
|
+
* portable rows — no subset, no extra (codex round 7: a subset used to pass, so a view that dropped
|
|
350
|
+
* a portable skill or claimed one verified).
|
|
351
|
+
*/
|
|
352
|
+
function isSnapshotViews(views, rows) {
|
|
353
|
+
if (typeof views !== 'object' || views === null)
|
|
354
|
+
return false;
|
|
355
|
+
const copilot = views.copilot;
|
|
356
|
+
if (typeof copilot !== 'object' || copilot === null)
|
|
357
|
+
return false;
|
|
358
|
+
if (copilot.dir !== COPILOT_VIEW_REL || !Array.isArray(copilot.skills))
|
|
359
|
+
return false;
|
|
360
|
+
const portable = rows.filter((r) => r.portable).map((r) => r.name).sort();
|
|
361
|
+
return copilot.skills.length === portable.length && copilot.skills.every((n, i) => n === portable[i]);
|
|
362
|
+
}
|
|
363
|
+
/** Whether a skill dir is nested: anything deeper than `skills/<dir>`. */
|
|
364
|
+
export function isNestedSkillDir(dir) {
|
|
365
|
+
return dir.slice(`${SKILLS_SUBDIR}/`.length).includes('/');
|
|
366
|
+
}
|
|
367
|
+
function compact(items) {
|
|
368
|
+
return items.filter((x) => x !== null);
|
|
369
|
+
}
|
|
370
|
+
function sortedRels(items) {
|
|
371
|
+
return items.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
372
|
+
}
|
|
373
|
+
function errnoCode(err) {
|
|
374
|
+
return err.code;
|
|
375
|
+
}
|
|
376
|
+
/** `lstat`, or `null` when nothing can stand at `path` — it does not exist (ENOENT) or a parent is a regular file (ENOTDIR). */
|
|
377
|
+
function lstatOrNull(path) {
|
|
378
|
+
try {
|
|
379
|
+
return lstatSync(path);
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
const code = errnoCode(err);
|
|
383
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
384
|
+
return null;
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** The dir a user-added skill lands at: always top-level (`skills/<name minus the prefix>`). */
|
|
389
|
+
export function dirForUserSkill(name) {
|
|
390
|
+
return `${SKILLS_SUBDIR}/${name.slice(SKILL_NAME_PREFIX.length)}`;
|
|
391
|
+
}
|
|
392
|
+
/** Zero-padded so a lexical listing of `snapshots/` is the generation order. */
|
|
393
|
+
export function generationDirName(gen) {
|
|
394
|
+
return String(gen).padStart(6, '0');
|
|
395
|
+
}
|
|
396
|
+
const GENERATION_DIR_RE = /^\d{6}$/;
|
|
397
|
+
const CONTENT_HASH_RE = /^[0-9a-f]{64}$/;
|
|
398
|
+
/** The persisted enums `snapshot.json` may carry — validated on read, never trusted as free text (codex round 6). */
|
|
399
|
+
const SOURCE_KINDS = new Set(['claude-plugin-cache', 'checkout', 'directory']);
|
|
400
|
+
const VENV_STATES = new Set(['pending', 'synced', 'failed', 'skipped']);
|
|
401
|
+
/** The persisted enums `manifest.json` may carry — the schema validator refuses anything else (codex round 7). */
|
|
402
|
+
const SKILL_KINDS = new Set(['router', 'fork-worker', 'module']);
|
|
403
|
+
const PROVENANCES = new Set(['shipped', 'override', 'user-added']);
|
|
404
|
+
/** POSIX write bits — a locked env / snapshot carries none (mirrors tree.ts). */
|
|
405
|
+
const WRITE_BITS = 0o222;
|
|
406
|
+
/** The structural storage directories under the root a symlink must never stand in for. */
|
|
407
|
+
const STORAGE_CHILD_DIRS = [EFFECTIVE_DIRNAME, BASELINE_DIRNAME, SNAPSHOTS_DIRNAME];
|
|
408
|
+
/**
|
|
409
|
+
* A safe relative `skills/<…>` dir: at least two segments, first `skills`, every segment a legal
|
|
410
|
+
* path component (no `..`, no absolute/separator/NUL) — the shape a persisted skill `dir` must
|
|
411
|
+
* have before the store ever joins it onto the root. Returns the reason it is unsafe, or `null`.
|
|
412
|
+
*/
|
|
413
|
+
function unsafeSkillDir(dir) {
|
|
414
|
+
if (typeof dir !== 'string')
|
|
415
|
+
return `dir ${JSON.stringify(dir)} is not a string`;
|
|
416
|
+
let segments;
|
|
417
|
+
try {
|
|
418
|
+
segments = validateRelSegments(dir);
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
return err instanceof SkillPathError ? err.message : String(err);
|
|
422
|
+
}
|
|
423
|
+
if (segments.length < 2 || segments[0] !== SKILLS_SUBDIR)
|
|
424
|
+
return `dir ${JSON.stringify(dir)} is not a nested skills/… path`;
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
/** A safe persisted file-record path (plugin-relative, no traversal), or the reason it is not. */
|
|
428
|
+
function unsafeRecordPath(rel) {
|
|
429
|
+
if (typeof rel !== 'string')
|
|
430
|
+
return `file path ${JSON.stringify(rel)} is not a string`;
|
|
431
|
+
try {
|
|
432
|
+
validateRelSegments(rel);
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
return err instanceof SkillPathError ? err.message : String(err);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
export class SkillsStore {
|
|
440
|
+
rootDir;
|
|
441
|
+
registeredRefs;
|
|
442
|
+
provisionVenv;
|
|
443
|
+
sourceFn;
|
|
444
|
+
now;
|
|
445
|
+
warn;
|
|
446
|
+
/** Generations live runs may still read — the reaper keeps them (fed by `observeEvent`). */
|
|
447
|
+
live = new LiveGenerations();
|
|
448
|
+
/** The one publish that may run at a time (module header) — `null` when none is in flight. */
|
|
449
|
+
publishInFlight = null;
|
|
450
|
+
/** One provisioning per baseline hash: a concurrent caller awaits the in-flight one. */
|
|
451
|
+
venvInFlight = new Map();
|
|
452
|
+
/** The canonical (realpath) identity of the root the store BOUND — recorded the first time the
|
|
453
|
+
* root is seen as a real directory (boot / seed), compared on every operation for the store's
|
|
454
|
+
* whole lifetime (the root is never re-aimed: a new root is a new store). */
|
|
455
|
+
boundRootReal = null;
|
|
456
|
+
constructor(opts) {
|
|
457
|
+
this.rootDir = opts.root;
|
|
458
|
+
this.registeredRefs = opts.registeredSkillRefs;
|
|
459
|
+
this.provisionVenv = opts.provisionVenv;
|
|
460
|
+
this.sourceFn = opts.source ?? (() => discoverLivePlugin());
|
|
461
|
+
this.now = opts.now ?? (() => new Date().toISOString());
|
|
462
|
+
this.warn = opts.warn ?? ((m) => console.warn(m));
|
|
463
|
+
}
|
|
464
|
+
// ── Paths ─────────────────────────────────────────────────────────────────────────────────
|
|
465
|
+
get root() {
|
|
466
|
+
return this.rootDir;
|
|
467
|
+
}
|
|
468
|
+
/** Whether a publish is running right now (diagnostics + tests). */
|
|
469
|
+
isPublishing() {
|
|
470
|
+
return this.publishInFlight !== null;
|
|
471
|
+
}
|
|
472
|
+
effectiveDir() {
|
|
473
|
+
return join(this.rootDir, EFFECTIVE_DIRNAME);
|
|
474
|
+
}
|
|
475
|
+
baselineDir(hash) {
|
|
476
|
+
return join(this.rootDir, BASELINE_DIRNAME, hash);
|
|
477
|
+
}
|
|
478
|
+
snapshotsDir() {
|
|
479
|
+
return join(this.rootDir, SNAPSHOTS_DIRNAME);
|
|
480
|
+
}
|
|
481
|
+
snapshotDir(gen) {
|
|
482
|
+
return join(this.snapshotsDir(), generationDirName(gen));
|
|
483
|
+
}
|
|
484
|
+
currentLink() {
|
|
485
|
+
return join(this.rootDir, CURRENT_LINKNAME);
|
|
486
|
+
}
|
|
487
|
+
/** The daemon's uv cache (`UV_CACHE_DIR`) — a writable cache under the root, not the operator's. */
|
|
488
|
+
uvCacheDir() {
|
|
489
|
+
return join(this.rootDir, UV_CACHE_DIRNAME);
|
|
490
|
+
}
|
|
491
|
+
manifestPath() {
|
|
492
|
+
return join(this.rootDir, MANIFEST_FILENAME);
|
|
493
|
+
}
|
|
494
|
+
pluginPath(rel, base = this.effectiveDir()) {
|
|
495
|
+
return join(base, ...rel.split('/'));
|
|
496
|
+
}
|
|
497
|
+
/** `effective/<dir>` after the lstat walk from the ROOT — the skill dir itself is a walked component. */
|
|
498
|
+
containedEffective(segments) {
|
|
499
|
+
this.assertRootIdentity();
|
|
500
|
+
return containedPath(this.rootDir, [EFFECTIVE_DIRNAME, ...segments]);
|
|
501
|
+
}
|
|
502
|
+
containedBaseline(hash, segments) {
|
|
503
|
+
this.assertRootIdentity();
|
|
504
|
+
return containedPath(this.rootDir, [BASELINE_DIRNAME, hash, ...segments]);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The skills root ITSELF must be a REAL directory whose canonical path is the one the store bound
|
|
508
|
+
* at boot — checked before EVERY read and mutation, not only at publish (codex round 4: the
|
|
509
|
+
* containment walk started BENEATH the root, so a root replaced by a symlink to a copied store
|
|
510
|
+
* after boot redirected file reads, writes and manifest commits outside the configured root).
|
|
511
|
+
* Refuses a symlink standing in for the root, a non-directory, and a root whose realpath moved (an
|
|
512
|
+
* ancestor replaced under the running daemon). A root that does not exist yet is not refused —
|
|
513
|
+
* there is nothing to redirect through; `manifest()` reports it unseeded and the seed binds it
|
|
514
|
+
* once it has created it. Throws `SkillsRootInvalidError`.
|
|
515
|
+
*/
|
|
516
|
+
assertRootIdentity() {
|
|
517
|
+
const root = this.rootDir;
|
|
518
|
+
let st;
|
|
519
|
+
try {
|
|
520
|
+
st = lstatSync(root);
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
if (errnoCode(err) === 'ENOENT')
|
|
524
|
+
return;
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
if (st.isSymbolicLink())
|
|
528
|
+
throw new SkillsRootInvalidError(root, 'a symlink stands in for the skills root — the store never follows links, the root included');
|
|
529
|
+
if (!st.isDirectory())
|
|
530
|
+
throw new SkillsRootInvalidError(root, 'it is not a directory');
|
|
531
|
+
const real = realpathSync(root);
|
|
532
|
+
if (this.boundRootReal === null) {
|
|
533
|
+
this.boundRootReal = real;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (real !== this.boundRootReal) {
|
|
537
|
+
throw new SkillsRootInvalidError(root, `its canonical path is now ${real} but the store bound ${this.boundRootReal} at boot — an ancestor was replaced under the running daemon`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** A skill's dir must be reachable without crossing a link before anything walks or writes it. */
|
|
541
|
+
assertSkillDirContained(dir) {
|
|
542
|
+
this.containedEffective(dir.split('/'));
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* The structural storage directories — the root itself and its `effective/`, `baseline/`,
|
|
546
|
+
* `snapshots/` children — must be REAL directories, never symlinks (codex round 3). The
|
|
547
|
+
* per-destination walk `copyFiles`/`writeFileAtomic` do starts BENEATH a staging dir, so a
|
|
548
|
+
* `snapshots -> /outside` (or `baseline -> …`) redirect would take a publish, a baseline
|
|
549
|
+
* capture, an env provisioning, a reap or a `current` verification outside the store before that
|
|
550
|
+
* walk ever ran. Refuses a symlink at the `skills` component and each child (the operator's
|
|
551
|
+
* state-home path ABOVE the root is theirs — only the store's own dirs are checked). Throws
|
|
552
|
+
* `SymlinkComponentError`; callers map it to their own refusal.
|
|
553
|
+
*/
|
|
554
|
+
assertStorageAncestorsClean() {
|
|
555
|
+
assertNoSymlinkComponents(dirname(this.rootDir), [basename(this.rootDir)]);
|
|
556
|
+
for (const child of STORAGE_CHILD_DIRS)
|
|
557
|
+
assertNoSymlinkComponents(this.rootDir, [child]);
|
|
558
|
+
}
|
|
559
|
+
// ── Manifest ──────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
isSeeded() {
|
|
561
|
+
this.assertRootIdentity(); // never answered THROUGH a link standing in for the root
|
|
562
|
+
// lstat, not exists (codex round 6): a `manifest.json` that is a SYMLINK counts as seeded, so the
|
|
563
|
+
// seed never wipes `effective/` around it — and `manifest()` refuses it by name, never follows it.
|
|
564
|
+
return lstatOrNull(this.manifestPath()) !== null;
|
|
565
|
+
}
|
|
566
|
+
/** The manifest, or `SkillsUnseededError` / `SkillsManifestCorruptError` / `SkillsRootInvalidError`. */
|
|
567
|
+
manifest() {
|
|
568
|
+
this.assertRootIdentity(); // every read and mutation begins here — the root is checked first
|
|
569
|
+
const path = this.manifestPath();
|
|
570
|
+
// NO-FOLLOW (codex round 6): the manifest is lstat'ed before it is read — a symlink standing in
|
|
571
|
+
// for `manifest.json` would route every catalog read (and every commit) through a file outside
|
|
572
|
+
// the skills root; it is refused by name, never followed. A missing manifest is "unseeded".
|
|
573
|
+
const st = lstatOrNull(path);
|
|
574
|
+
if (st === null)
|
|
575
|
+
throw new SkillsUnseededError(this.rootDir);
|
|
576
|
+
if (st.isSymbolicLink()) {
|
|
577
|
+
throw new SkillsManifestCorruptError(path, `${MANIFEST_FILENAME} is a symlink (-> ${readlinkSync(path)}) — the store never reads its state through a link`);
|
|
578
|
+
}
|
|
579
|
+
if (!st.isFile())
|
|
580
|
+
throw new SkillsManifestCorruptError(path, `${MANIFEST_FILENAME} is not a regular file`);
|
|
581
|
+
const raw = readFileNoFollow(path).toString('utf8'); // O_NOFOLLOW + identity: the entry lstat judged is the one read (v3.5 §3)
|
|
582
|
+
let parsed;
|
|
583
|
+
try {
|
|
584
|
+
parsed = JSON.parse(raw);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
throw new SkillsManifestCorruptError(path, err instanceof Error ? err.message : String(err));
|
|
588
|
+
}
|
|
589
|
+
return this.validateManifest(path, parsed);
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* The COMPLETE runtime schema of `manifest.json` (codex round 7): every field is typed strictly
|
|
593
|
+
* and ONE malformed field refuses the whole manifest — `manifest-invalid` →
|
|
594
|
+
* `SkillsManifestCorruptError`, the routes' 503 and the daemon's `skills.config` — never a
|
|
595
|
+
* truthiness fallback (`"enabled": "false"` used to load, and a `!== true` check read it as
|
|
596
|
+
* disabled while `=== false` read it as enabled). Enums (`kind`, `provenance`, `source.kind`,
|
|
597
|
+
* `venv`), hashes (64-hex, or `null` where allowed), integers (`revision` ≥ 0, `published.gen`
|
|
598
|
+
* ≥ 1), strings, unknown or missing keys — all refused by name. Every PERSISTED path is validated
|
|
599
|
+
* here too (codex rounds 3/4): a skill `dir`, a baseline identifier, a file-record key and the
|
|
600
|
+
* skill KEY itself (a safe single segment that IS the path-derived name of its `dir` — the copilot
|
|
601
|
+
* view lays a skill out under `views/copilot/.github/skills/<name>/`; the manifest key, the
|
|
602
|
+
* directory and the invocation identity are one thing) — a manifest is disk state an attacker (or
|
|
603
|
+
* a bad merge) can craft, never a path the store follows out of the root. The same validator runs
|
|
604
|
+
* on every manifest the store is about to WRITE (`writeManifest`). The answer is a fresh object
|
|
605
|
+
* holding exactly the validated fields.
|
|
606
|
+
*/
|
|
607
|
+
validateManifest(path, parsed) {
|
|
608
|
+
const fail = (detail) => {
|
|
609
|
+
throw new SkillsManifestCorruptError(path, `manifest-invalid: ${detail}`);
|
|
610
|
+
};
|
|
611
|
+
const record = (value, where) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : fail(`${where} is not an object`);
|
|
612
|
+
const exactKeys = (obj, keys, where) => {
|
|
613
|
+
for (const k of keys)
|
|
614
|
+
if (!(k in obj))
|
|
615
|
+
fail(`${where} lacks ${JSON.stringify(k)}`);
|
|
616
|
+
for (const k of Object.keys(obj))
|
|
617
|
+
if (!keys.includes(k))
|
|
618
|
+
fail(`${where} carries an unknown key ${JSON.stringify(k)}`);
|
|
619
|
+
};
|
|
620
|
+
const bool = (value, where) => (typeof value === 'boolean' ? value : fail(`${where} is ${JSON.stringify(value)}, not a boolean`));
|
|
621
|
+
const str = (value, where) => (typeof value === 'string' ? value : fail(`${where} is ${JSON.stringify(value)}, not a string`));
|
|
622
|
+
const strOrNull = (value, where) => (value === null ? null : str(value, where));
|
|
623
|
+
const hash = (value, where) => typeof value === 'string' && CONTENT_HASH_RE.test(value) ? value : fail(`${where} is ${JSON.stringify(value)}, not a sha256 content hash`);
|
|
624
|
+
const hashOrNull = (value, where) => (value === null ? null : hash(value, where));
|
|
625
|
+
const oneOf = (value, set, where) => typeof value === 'string' && set.has(value) ? value : fail(`${where} is ${JSON.stringify(value)}, not one of ${[...set].join('|')}`);
|
|
626
|
+
const integerAtLeast = (value, min, where) => typeof value === 'number' && Number.isInteger(value) && value >= min ? value : fail(`${where} is ${JSON.stringify(value)}, not an integer ≥ ${min}`);
|
|
627
|
+
const top = record(parsed, 'the manifest');
|
|
628
|
+
exactKeys(top, ['version', 'revision', 'baseline', 'baselines', 'skills', 'files', 'published'], 'the manifest');
|
|
629
|
+
if (top['version'] !== MANIFEST_VERSION)
|
|
630
|
+
fail(`version ${JSON.stringify(top['version'])} (expected ${MANIFEST_VERSION})`);
|
|
631
|
+
const revision = integerAtLeast(top['revision'], 0, 'revision');
|
|
632
|
+
const baseline = hash(top['baseline'], 'baseline');
|
|
633
|
+
const baselines = {};
|
|
634
|
+
for (const [key, value] of Object.entries(record(top['baselines'], 'baselines'))) {
|
|
635
|
+
hash(key, 'a baselines key');
|
|
636
|
+
const where = `baselines[${key}]`;
|
|
637
|
+
const r = record(value, where);
|
|
638
|
+
exactKeys(r, ['plugin_version', 'source', 'git_sha', 'captured_at', 'venv'], where);
|
|
639
|
+
const source = record(r['source'], `${where}.source`);
|
|
640
|
+
exactKeys(source, ['kind', 'path'], `${where}.source`);
|
|
641
|
+
baselines[key] = {
|
|
642
|
+
plugin_version: str(r['plugin_version'], `${where}.plugin_version`),
|
|
643
|
+
source: { kind: oneOf(source['kind'], SOURCE_KINDS, `${where}.source.kind`), path: str(source['path'], `${where}.source.path`) },
|
|
644
|
+
git_sha: strOrNull(r['git_sha'], `${where}.git_sha`),
|
|
645
|
+
captured_at: str(r['captured_at'], `${where}.captured_at`),
|
|
646
|
+
venv: oneOf(r['venv'], VENV_STATES, `${where}.venv`),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (baselines[baseline] === undefined)
|
|
650
|
+
fail(`baseline ${baseline} has no record under baselines`);
|
|
651
|
+
const skills = {};
|
|
652
|
+
for (const [name, value] of Object.entries(record(top['skills'], 'skills'))) {
|
|
653
|
+
if (!SKILL_NAME_RE.test(name) || !name.startsWith(SKILL_NAME_PREFIX) || name.length === SKILL_NAME_PREFIX.length) {
|
|
654
|
+
fail(`skill key ${JSON.stringify(name)} is not a skill name (${SKILL_NAME_RE.source} with the ${SKILL_NAME_PREFIX} prefix and a non-empty remainder)`);
|
|
655
|
+
}
|
|
656
|
+
const where = `skills[${name}]`;
|
|
657
|
+
const e = record(value, where);
|
|
658
|
+
exactKeys(e, ['dir', 'kind', 'core', 'portable', 'enabled', 'provenance', 'editedAt', 'upgradeAvailable', 'conflict', 'upstreamDir'], where);
|
|
659
|
+
const dir = str(e['dir'], `${where}.dir`);
|
|
660
|
+
const bad = unsafeSkillDir(dir);
|
|
661
|
+
if (bad !== null)
|
|
662
|
+
fail(`${where}: ${bad}`);
|
|
663
|
+
const derived = derivedSkillName(dir.slice(`${SKILLS_SUBDIR}/`.length));
|
|
664
|
+
if (derived !== name)
|
|
665
|
+
fail(`${where} sits at ${dir}, which derives ${JSON.stringify(derived)} — the key must be the path-derived name of its dir`);
|
|
666
|
+
skills[name] = {
|
|
667
|
+
dir,
|
|
668
|
+
kind: oneOf(e['kind'], SKILL_KINDS, `${where}.kind`),
|
|
669
|
+
core: bool(e['core'], `${where}.core`),
|
|
670
|
+
portable: bool(e['portable'], `${where}.portable`),
|
|
671
|
+
enabled: bool(e['enabled'], `${where}.enabled`),
|
|
672
|
+
provenance: oneOf(e['provenance'], PROVENANCES, `${where}.provenance`),
|
|
673
|
+
editedAt: strOrNull(e['editedAt'], `${where}.editedAt`),
|
|
674
|
+
upgradeAvailable: bool(e['upgradeAvailable'], `${where}.upgradeAvailable`),
|
|
675
|
+
conflict: bool(e['conflict'], `${where}.conflict`),
|
|
676
|
+
upstreamDir: strOrNull(e['upstreamDir'], `${where}.upstreamDir`),
|
|
677
|
+
};
|
|
678
|
+
const upstreamDir = skills[name]?.upstreamDir ?? null;
|
|
679
|
+
if (upstreamDir !== null) {
|
|
680
|
+
const badUpstream = unsafeSkillDir(upstreamDir);
|
|
681
|
+
if (badUpstream !== null)
|
|
682
|
+
fail(`${where}.upstreamDir: ${badUpstream}`);
|
|
683
|
+
if (upstreamDir === dir)
|
|
684
|
+
fail(`${where}.upstreamDir equals dir — a held-back upstream skill lives at ANOTHER directory`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const files = {};
|
|
688
|
+
for (const [rel, value] of Object.entries(record(top['files'], 'files'))) {
|
|
689
|
+
const bad = unsafeRecordPath(rel);
|
|
690
|
+
if (bad !== null)
|
|
691
|
+
fail(`file record: ${bad}`);
|
|
692
|
+
const where = `files[${rel}]`;
|
|
693
|
+
const r = record(value, where);
|
|
694
|
+
exactKeys(r, ['baselineHash', 'effectiveHash', 'lastPublishedHash', 'conflict'], where);
|
|
695
|
+
files[rel] = {
|
|
696
|
+
baselineHash: hashOrNull(r['baselineHash'], `${where}.baselineHash`),
|
|
697
|
+
effectiveHash: hashOrNull(r['effectiveHash'], `${where}.effectiveHash`),
|
|
698
|
+
lastPublishedHash: hashOrNull(r['lastPublishedHash'], `${where}.lastPublishedHash`),
|
|
699
|
+
conflict: bool(r['conflict'], `${where}.conflict`),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
let published = null;
|
|
703
|
+
if (top['published'] !== null) {
|
|
704
|
+
const p = record(top['published'], 'published');
|
|
705
|
+
exactKeys(p, ['gen', 'contentHash', 'at', 'snapshotHash'], 'published');
|
|
706
|
+
published = {
|
|
707
|
+
gen: integerAtLeast(p['gen'], 1, 'published.gen'),
|
|
708
|
+
contentHash: hash(p['contentHash'], 'published.contentHash'),
|
|
709
|
+
at: str(p['at'], 'published.at'),
|
|
710
|
+
snapshotHash: hash(p['snapshotHash'], 'published.snapshotHash'),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
return { version: MANIFEST_VERSION, revision, baseline, baselines, skills, files, published };
|
|
714
|
+
}
|
|
715
|
+
revision() {
|
|
716
|
+
return this.manifest().revision;
|
|
717
|
+
}
|
|
718
|
+
/** Write the manifest with its revision bumped — every mutation ends here. */
|
|
719
|
+
commit(m) {
|
|
720
|
+
m.revision += 1;
|
|
721
|
+
this.writeManifest(m);
|
|
722
|
+
}
|
|
723
|
+
writeManifest(m) {
|
|
724
|
+
this.assertRootIdentity(); // a commit never lands through a root that changed identity
|
|
725
|
+
// Validated on the way OUT too (codex round 7): a manifest the store is about to write must pass
|
|
726
|
+
// the schema it demands on load — a bug producing a malformed field never lands on disk.
|
|
727
|
+
const text = `${JSON.stringify(m, null, 2)}\n`;
|
|
728
|
+
this.validateManifest(this.manifestPath(), JSON.parse(text));
|
|
729
|
+
writeFileAtomic(this.manifestPath(), text);
|
|
730
|
+
}
|
|
731
|
+
assertRevision(m, expected) {
|
|
732
|
+
if (expected !== m.revision)
|
|
733
|
+
throw new RevisionMismatchError(expected, m.revision);
|
|
734
|
+
}
|
|
735
|
+
// ── `current` ─────────────────────────────────────────────────────────────────────────────
|
|
736
|
+
/**
|
|
737
|
+
* The published snapshot `current` resolves to, VERIFIED (module header), or `null` when there
|
|
738
|
+
* is no link (never published). `path` is the absolute REAL path of the generation — exactly the
|
|
739
|
+
* value the engine is handed as `WICKED_SKILLS_SNAPSHOT` (v3.1 §2). A link that exists but fails
|
|
740
|
+
* verification throws `SkillsCurrentInvalidError`. Verified on EVERY call — never memoized by
|
|
741
|
+
* link text (codex round 2): the answer holds for the daemon's whole lifetime only because it is
|
|
742
|
+
* re-derived each time it is handed out.
|
|
743
|
+
*/
|
|
744
|
+
currentSnapshot() {
|
|
745
|
+
this.assertRootIdentity(); // never answered through a root that changed identity (codex round 4)
|
|
746
|
+
const link = this.currentLink();
|
|
747
|
+
let target;
|
|
748
|
+
try {
|
|
749
|
+
target = readlinkSync(link);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
if (errnoCode(err) === 'ENOENT')
|
|
753
|
+
return null;
|
|
754
|
+
if (errnoCode(err) === 'EINVAL')
|
|
755
|
+
throw new SkillsCurrentInvalidError(link, 'it is not a symbolic link');
|
|
756
|
+
throw err;
|
|
757
|
+
}
|
|
758
|
+
return this.verifyCurrent(link, target);
|
|
759
|
+
}
|
|
760
|
+
verifyCurrent(link, target) {
|
|
761
|
+
const invalid = (detail) => {
|
|
762
|
+
throw new SkillsCurrentInvalidError(link, detail);
|
|
763
|
+
};
|
|
764
|
+
// The root itself first (codex round 4), then containment judged against the LSTAT-CLEAN
|
|
765
|
+
// `snapshots/` path, never the realpath of a redirected one (codex round 3): a
|
|
766
|
+
// `snapshots -> /outside` symlink would otherwise make the target's realpath fall inside the
|
|
767
|
+
// redirected boundary and verify. Refuse it up front.
|
|
768
|
+
this.assertRootIdentity();
|
|
769
|
+
try {
|
|
770
|
+
this.assertStorageAncestorsClean();
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
if (err instanceof SymlinkComponentError)
|
|
774
|
+
return invalid(`a storage directory is a symlink (${err.message}) — the snapshots boundary is not trusted`);
|
|
775
|
+
throw err;
|
|
776
|
+
}
|
|
777
|
+
const lexical = resolve(this.rootDir, target);
|
|
778
|
+
let real;
|
|
779
|
+
try {
|
|
780
|
+
real = realpathSync(lexical);
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
return invalid(`its target ${target} does not resolve (${errnoCode(err) ?? 'error'})`);
|
|
784
|
+
}
|
|
785
|
+
let snapshotsReal;
|
|
786
|
+
try {
|
|
787
|
+
snapshotsReal = realpathSync(this.snapshotsDir());
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return invalid(`${this.snapshotsDir()} does not exist`);
|
|
791
|
+
}
|
|
792
|
+
if (dirname(real) !== snapshotsReal)
|
|
793
|
+
return invalid(`its target resolves to ${real}, outside ${this.snapshotsDir()}`);
|
|
794
|
+
const dirName = basename(real);
|
|
795
|
+
if (!GENERATION_DIR_RE.test(dirName))
|
|
796
|
+
return invalid(`its target ${dirName} is not a generation directory`);
|
|
797
|
+
if (!lstatSync(real).isDirectory())
|
|
798
|
+
return invalid(`its target ${dirName} is not a directory`);
|
|
799
|
+
const meta = this.readSnapshotMetadata(lexical);
|
|
800
|
+
if (typeof meta === 'string')
|
|
801
|
+
return invalid(meta);
|
|
802
|
+
const parsed = meta.parsed;
|
|
803
|
+
if (parsed.gen !== Number(dirName))
|
|
804
|
+
return invalid(`snapshot.json says gen ${parsed.gen} but the directory is ${dirName}`);
|
|
805
|
+
// `snapshot.json` is AUTHENTICATED by the crew-owned manifest (codex round 7): it is excluded from
|
|
806
|
+
// the content hash, so its claims — kind, core, portable, nested, the view membership — used to
|
|
807
|
+
// be trusted as written. Publish records the sha256 of the exact bytes it wrote
|
|
808
|
+
// (`manifest.published.snapshotHash`); `current` must be THAT generation with THAT metadata. A
|
|
809
|
+
// torn flip (`current` behind `published`) is finished by `ensureReady` before verification —
|
|
810
|
+
// an older generation is never verified on trust. The recorded baseline (codex round 6) must
|
|
811
|
+
// also be one THIS root's manifest knows — it authorizes the `.venv` link below.
|
|
812
|
+
let known;
|
|
813
|
+
try {
|
|
814
|
+
known = this.manifest();
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
return invalid(`the snapshot cannot be cross-checked against ${MANIFEST_FILENAME} (${err instanceof Error ? err.message : String(err)})`);
|
|
818
|
+
}
|
|
819
|
+
if (known.published === null)
|
|
820
|
+
return invalid(`${MANIFEST_FILENAME} records no publish — a generation the manifest does not own`);
|
|
821
|
+
if (known.published.gen !== parsed.gen) {
|
|
822
|
+
return invalid(`current names generation ${parsed.gen} but ${MANIFEST_FILENAME} published generation ${known.published.gen} — not the generation manifest.json published`);
|
|
823
|
+
}
|
|
824
|
+
if (known.published.contentHash !== parsed.contentHash || known.published.snapshotHash !== meta.rawSha) {
|
|
825
|
+
return invalid(`snapshot.json is not the metadata this root published for generation ${parsed.gen} (recorded content hash ${known.published.contentHash} / metadata hash ${known.published.snapshotHash}; found ${parsed.contentHash} / ${meta.rawSha}) — the metadata was modified`);
|
|
826
|
+
}
|
|
827
|
+
if (known.baselines[parsed.gardenSource.baseline] === undefined) {
|
|
828
|
+
return invalid(`snapshot.json records baseline ${parsed.gardenSource.baseline}, which ${MANIFEST_FILENAME} does not know — the metadata is not this root's`);
|
|
829
|
+
}
|
|
830
|
+
// EVERY entry is judged, links included (codex round 5): the walk enumerates symlinks instead of
|
|
831
|
+
// skipping them, the hash covers them (path + link text), and the only link a generation may
|
|
832
|
+
// carry is `.venv` at its root, pointing at THIS root's baseline env for the recorded baseline.
|
|
833
|
+
const tree = walkTree(real);
|
|
834
|
+
const special = tree.others[0];
|
|
835
|
+
if (special !== undefined)
|
|
836
|
+
return invalid(`${special.rel} is neither a file, a directory nor a symlink — a published generation carries no special nodes`);
|
|
837
|
+
// A pruned-name directory (`SKIP_DIR_NAMES`) never enters a generation: publish copies a file set
|
|
838
|
+
// that excludes such subtrees, and the environment is the `.venv` LINK at the generation root. One
|
|
839
|
+
// on disk is therefore an unexpected entry, refused BY NAME before the hash is compared (codex
|
|
840
|
+
// round 10) — whatever it holds, and however the metadata was re-stamped.
|
|
841
|
+
const prunedDir = tree.dirs.find((d) => SKIP_DIR_NAMES.has(posix.basename(d)));
|
|
842
|
+
if (prunedDir !== undefined) {
|
|
843
|
+
return invalid(`unexpected directory ${prunedDir} — a published generation never carries a ${posix.basename(prunedDir)} directory (publish copies no pruned directory; the environment is the .venv link at the generation root): the immutable snapshot was modified`);
|
|
844
|
+
}
|
|
845
|
+
const hash = this.snapshotHash(tree);
|
|
846
|
+
if (hash !== parsed.contentHash) {
|
|
847
|
+
return invalid(`content hash mismatch — snapshot.json records ${parsed.contentHash}, the tree hashes ${hash}: the immutable snapshot was modified`);
|
|
848
|
+
}
|
|
849
|
+
const rowProblem = this.snapshotRowsProblem(parsed, tree);
|
|
850
|
+
if (rowProblem !== null)
|
|
851
|
+
return invalid(rowProblem);
|
|
852
|
+
const linkProblem = this.snapshotLinkProblem(real, tree.links, parsed);
|
|
853
|
+
if (linkProblem !== null)
|
|
854
|
+
return invalid(linkProblem);
|
|
855
|
+
return { gen: parsed.gen, path: real };
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Re-derive every claim of a skill row from the generation's OWN files (codex round 7; the
|
|
859
|
+
* metadata hash authenticates the rest): `dir/SKILL.md` must be in the tree, `kind` must be what
|
|
860
|
+
* its frontmatter derives, `portable` what the skill's own files (nested subtrees excluded)
|
|
861
|
+
* derive, `nested` what the dir spells (checked at parse); the copilot view ON DISK must lay out
|
|
862
|
+
* EXACTLY the sorted set of portable rows — no skill missing, no extra directory — and nothing
|
|
863
|
+
* else may sit under `views/`. `core` is the registered-reference closure AT PUBLISH (the
|
|
864
|
+
* registered set may legitimately move afterwards), so it is authenticated by the metadata hash
|
|
865
|
+
* rather than re-derived. Read-only mode bits are re-checked by nobody: they are a guard against
|
|
866
|
+
* accidents, never the integrity boundary — the hashes are.
|
|
867
|
+
*/
|
|
868
|
+
snapshotRowsProblem(parsed, tree) {
|
|
869
|
+
const files = tree.files;
|
|
870
|
+
const byRel = new Map(files.map((f) => [f.rel, f]));
|
|
871
|
+
const dirs = new Set(parsed.skills.map((r) => r.dir));
|
|
872
|
+
for (const row of parsed.skills) {
|
|
873
|
+
const skillMd = byRel.get(`${row.dir}/SKILL.md`);
|
|
874
|
+
if (skillMd === undefined)
|
|
875
|
+
return `skill row ${row.name} names ${row.dir}, but the generation carries no ${row.dir}/SKILL.md`;
|
|
876
|
+
const fm = parseFrontmatter(readFileNoFollow(skillMd.abs).toString('utf8'));
|
|
877
|
+
if (!fm.ok)
|
|
878
|
+
return `${row.dir}/SKILL.md frontmatter does not parse (${fm.reason}) — its row cannot be re-derived`;
|
|
879
|
+
const kind = skillKindOf(fm.fields);
|
|
880
|
+
if (kind !== row.kind)
|
|
881
|
+
return `skill row ${row.name} claims kind ${row.kind}, but its SKILL.md derives ${kind}`;
|
|
882
|
+
let portable = true;
|
|
883
|
+
const prefix = `${row.dir}/`;
|
|
884
|
+
for (const f of files) {
|
|
885
|
+
if (!f.rel.startsWith(prefix) || owningSkillDir(f.rel, dirs) !== row.dir)
|
|
886
|
+
continue;
|
|
887
|
+
const buf = readFileNoFollow(f.abs);
|
|
888
|
+
if (looksBinary(buf))
|
|
889
|
+
continue;
|
|
890
|
+
if (portabilityIssueOf(buf.toString('utf8')) !== null) {
|
|
891
|
+
portable = false;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (portable !== row.portable)
|
|
896
|
+
return `skill row ${row.name} claims portable: ${String(row.portable)}, but its files derive ${String(portable)}`;
|
|
897
|
+
}
|
|
898
|
+
// The copilot view as a WHOLE tree (codex round 9): EXACTLY `views/copilot/.github/skills/<name>/…`
|
|
899
|
+
// for the sorted portable rows — the files each row owns, the directories they imply — and nothing
|
|
900
|
+
// else under `views/`: no other directory (empty or not), no other file; every unexpected entry is
|
|
901
|
+
// named. Links and special nodes anywhere in the generation are refused by `verifyCurrent` /
|
|
902
|
+
// `snapshotLinkProblem` before this runs.
|
|
903
|
+
const expectedView = parsed.skills.filter((r) => r.portable).map((r) => r.name).sort();
|
|
904
|
+
const expectedFiles = new Set();
|
|
905
|
+
for (const row of parsed.skills) {
|
|
906
|
+
if (!row.portable)
|
|
907
|
+
continue;
|
|
908
|
+
const prefix = `${row.dir}/`;
|
|
909
|
+
for (const f of files) {
|
|
910
|
+
if (f.rel.startsWith(prefix) && owningSkillDir(f.rel, dirs) === row.dir)
|
|
911
|
+
expectedFiles.add(`${COPILOT_VIEW_SKILLS_REL}/${row.name}/${f.rel.slice(prefix.length)}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const underViews = (rel) => rel === VIEWS_DIRNAME || rel.startsWith(`${VIEWS_DIRNAME}/`);
|
|
915
|
+
const expectedDirs = new Set(impliedDirs(expectedFiles).filter(underViews));
|
|
916
|
+
const shape = `the copilot view is exactly ${COPILOT_VIEW_SKILLS_REL}/<name>/… for [${expectedView.join(', ')}] (each skill's own files and the directories they imply); a view entry is missing or extra`;
|
|
917
|
+
for (const f of files)
|
|
918
|
+
if (underViews(f.rel) && !expectedFiles.has(f.rel))
|
|
919
|
+
return `unexpected view file ${f.rel} — ${shape}`;
|
|
920
|
+
for (const d of tree.dirs)
|
|
921
|
+
if (underViews(d) && !expectedDirs.has(d))
|
|
922
|
+
return `unexpected view directory ${d} — ${shape}`;
|
|
923
|
+
for (const f of expectedFiles)
|
|
924
|
+
if (!byRel.has(f))
|
|
925
|
+
return `the copilot view is missing ${f} — ${shape}`;
|
|
926
|
+
const dirSet = new Set(tree.dirs);
|
|
927
|
+
for (const d of expectedDirs)
|
|
928
|
+
if (!dirSet.has(d))
|
|
929
|
+
return `the copilot view is missing directory ${d} — ${shape}`;
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
/** Hash over a snapshot tree — files (`snapshot.json` excluded), link entries (path + link text) AND directory entries (codex round 9: an extra empty directory changes it). */
|
|
933
|
+
snapshotHash(tree) {
|
|
934
|
+
return hashTree(tree.files.filter((f) => f.rel !== SNAPSHOT_MANIFEST_FILENAME), tree.links, tree.dirs);
|
|
935
|
+
}
|
|
936
|
+
/** The link text publish writes for a snapshot's `.venv`: relative on POSIX, the absolute target for a Windows junction. */
|
|
937
|
+
venvLinkText(baseline) {
|
|
938
|
+
const absTarget = baselineVenvDir(this.baselineDir(baseline));
|
|
939
|
+
return {
|
|
940
|
+
text: process.platform === 'win32' ? absTarget : posix.join('..', '..', BASELINE_DIRNAME, baseline, VENV_LINKNAME),
|
|
941
|
+
absTarget,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Why a generation's symlinks are not acceptable, or `null`: any link other than the root-level
|
|
946
|
+
* `.venv` is refused by name and target; `.venv` may exist only when `snapshot.json` records the
|
|
947
|
+
* env as `synced`, must carry EXACTLY the link text publish writes (the relative path to this
|
|
948
|
+
* root's `baseline/<recorded baseline>/.venv`), and must resolve to that very directory (a
|
|
949
|
+
* dangling or redirected link is refused — a worker's `uv run` through it would create or read
|
|
950
|
+
* an env somewhere else). A `synced` snapshot WITHOUT the link is not what publish wrote either.
|
|
951
|
+
*
|
|
952
|
+
* The link is verified INDEPENDENTLY of the metadata text (codex round 6: an altered manifest,
|
|
953
|
+
* link and recorded hash used to let an outside-pointing `.venv` verify, because the expected
|
|
954
|
+
* target was JOINED from a `gardenSource.baseline` validated only as a string). The only two facts
|
|
955
|
+
* of `snapshot.json` this decision consumes are that the baseline is a 64-hex content hash
|
|
956
|
+
* (`parseSnapshotManifest`) that `manifest.json` knows (`verifyCurrent`); the env it must reach is
|
|
957
|
+
* then walked FROM THE ROOT with lstat — no link at `baseline`, `<hash>` or `.venv` — must be a
|
|
958
|
+
* REAL directory, and the link's canonical target must equal that directory's canonical path
|
|
959
|
+
* inside the canonical root.
|
|
960
|
+
*/
|
|
961
|
+
snapshotLinkProblem(snapshotDir, links, parsed) {
|
|
962
|
+
const venv = links.find((l) => l.rel === VENV_LINKNAME);
|
|
963
|
+
for (const l of links) {
|
|
964
|
+
if (l.rel !== VENV_LINKNAME)
|
|
965
|
+
return `unexpected symlink ${l.rel} -> ${l.target}: a published generation carries no link but its root-level ${VENV_LINKNAME}`;
|
|
966
|
+
}
|
|
967
|
+
if (venv === undefined) {
|
|
968
|
+
return parsed.venv === 'synced' ? `snapshot.json records the baseline env as synced but the generation has no ${VENV_LINKNAME} link` : null;
|
|
969
|
+
}
|
|
970
|
+
if (parsed.venv !== 'synced')
|
|
971
|
+
return `${VENV_LINKNAME} -> ${venv.target} is present although snapshot.json records the env as ${parsed.venv}`;
|
|
972
|
+
const hash = parsed.gardenSource.baseline; // 64-hex by parse; a manifest.json key by verifyCurrent
|
|
973
|
+
const expected = this.venvLinkText(hash);
|
|
974
|
+
if (venv.target !== expected.text)
|
|
975
|
+
return `${VENV_LINKNAME} -> ${venv.target} is not the baseline env link publish wrote (${expected.text})`;
|
|
976
|
+
let envDir;
|
|
977
|
+
try {
|
|
978
|
+
envDir = containedPath(this.rootDir, [BASELINE_DIRNAME, hash, VENV_LINKNAME]);
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
if (err instanceof SkillPathError)
|
|
982
|
+
return `${VENV_LINKNAME} -> ${venv.target}: the baseline env is not reachable without following a link (${err.message})`;
|
|
983
|
+
throw err;
|
|
984
|
+
}
|
|
985
|
+
const envStat = lstatOrNull(envDir);
|
|
986
|
+
if (envStat === null || !envStat.isDirectory())
|
|
987
|
+
return `${VENV_LINKNAME} -> ${venv.target} names ${envDir}, which is not a real directory`;
|
|
988
|
+
let resolved;
|
|
989
|
+
let rootReal;
|
|
990
|
+
try {
|
|
991
|
+
resolved = realpathSync(join(snapshotDir, VENV_LINKNAME));
|
|
992
|
+
rootReal = realpathSync(this.rootDir);
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
return `${VENV_LINKNAME} -> ${venv.target} does not resolve (${errnoCode(err) ?? 'error'})`;
|
|
996
|
+
}
|
|
997
|
+
const expectedReal = join(rootReal, BASELINE_DIRNAME, hash, VENV_LINKNAME);
|
|
998
|
+
if (resolved !== expectedReal)
|
|
999
|
+
return `${VENV_LINKNAME} resolves to ${resolved}, not the baseline env ${expectedReal} inside the canonical root ${rootReal}`;
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
/** `snapshot.json` at `dir`, structurally validated (`readSnapshotMetadata`) — for readers that need no authentication (baseline retention). */
|
|
1003
|
+
parseSnapshotManifest(dir) {
|
|
1004
|
+
const meta = this.readSnapshotMetadata(dir);
|
|
1005
|
+
return typeof meta === 'string' ? meta : meta.parsed;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* `snapshot.json` at `dir`, read NO-FOLLOW and structurally validated — with the sha256 of its
|
|
1009
|
+
* exact bytes (`rawSha`, what `manifest.published.snapshotHash` authenticates; codex round 7) — or
|
|
1010
|
+
* the reason it is not one. Every field a decision consumes is regex- or enum-validated here
|
|
1011
|
+
* (codex round 6): `gardenSource.baseline` must be a sha256 content hash (it authorizes the `.venv`
|
|
1012
|
+
* link — a free string could carry `..`), `venv` one of the four states, `gardenSource.kind` a
|
|
1013
|
+
* known source kind, every skill row fully typed (`isSnapshotSkillRow`), rows sorted and unique,
|
|
1014
|
+
* the view block naming EXACTLY the portable rows.
|
|
1015
|
+
*/
|
|
1016
|
+
readSnapshotMetadata(dir) {
|
|
1017
|
+
const path = join(dir, SNAPSHOT_MANIFEST_FILENAME);
|
|
1018
|
+
const st = lstatOrNull(path);
|
|
1019
|
+
if (st === null)
|
|
1020
|
+
return `no ${SNAPSHOT_MANIFEST_FILENAME} in ${dir}`;
|
|
1021
|
+
if (st.isSymbolicLink())
|
|
1022
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} in ${dir} is a symlink (-> ${readlinkSync(path)}) — the store never reads snapshot metadata through a link`;
|
|
1023
|
+
if (!st.isFile())
|
|
1024
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} in ${dir} is not a regular file`;
|
|
1025
|
+
let raw;
|
|
1026
|
+
try {
|
|
1027
|
+
raw = readFileNoFollow(path).toString('utf8'); // O_NOFOLLOW + identity (v3.5 §3)
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
return `no readable ${SNAPSHOT_MANIFEST_FILENAME} in ${dir} (${err instanceof EntrySwappedError || err instanceof SymlinkComponentError ? err.message : (errnoCode(err) ?? 'error')})`;
|
|
1031
|
+
}
|
|
1032
|
+
let parsed;
|
|
1033
|
+
try {
|
|
1034
|
+
parsed = JSON.parse(raw);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} does not parse: ${err instanceof Error ? err.message : String(err)}`;
|
|
1038
|
+
}
|
|
1039
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
1040
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} is not an object`;
|
|
1041
|
+
const s = parsed;
|
|
1042
|
+
if (typeof s.gen !== 'number' || !Number.isInteger(s.gen) || s.gen < 1)
|
|
1043
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has no integer gen`;
|
|
1044
|
+
if (typeof s.contentHash !== 'string' || !CONTENT_HASH_RE.test(s.contentHash))
|
|
1045
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has no sha256 contentHash`;
|
|
1046
|
+
if (!Array.isArray(s.skills))
|
|
1047
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has no skills array`;
|
|
1048
|
+
if (!s.skills.every(isSnapshotSkillRow)) {
|
|
1049
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has a skill row that is not {name: a wicked-garden-* skill name, dir: a safe relative skills/… path deriving that name, kind: ${[...SKILL_KINDS].join('|')}, core: boolean, portable: boolean, nested: what the dir spells} — metadata is never trusted to name a path, and core cannot judge seat compatibility from it`;
|
|
1050
|
+
}
|
|
1051
|
+
if (!isSortedUniqueRows(s.skills))
|
|
1052
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} skill rows are not sorted by unique name — not what publish writes`;
|
|
1053
|
+
const gs = s.gardenSource;
|
|
1054
|
+
if (typeof gs !== 'object' || gs === null || typeof gs.baseline !== 'string' || !CONTENT_HASH_RE.test(gs.baseline)) {
|
|
1055
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has no gardenSource.baseline that is a sha256 content hash — the recorded baseline authorizes the ${VENV_LINKNAME} link and is never trusted as free text`;
|
|
1056
|
+
}
|
|
1057
|
+
if (typeof gs.path !== 'string' || typeof gs.plugin_version !== 'string' || !SOURCE_KINDS.has(String(gs.kind))) {
|
|
1058
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} gardenSource is not {kind: ${[...SOURCE_KINDS].join('|')}, path, plugin_version, baseline}`;
|
|
1059
|
+
}
|
|
1060
|
+
if (!VENV_STATES.has(String(s.venv)))
|
|
1061
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has no venv state (${[...VENV_STATES].join('|')})`;
|
|
1062
|
+
if (!isSnapshotViews(s.views, s.skills)) {
|
|
1063
|
+
return `${SNAPSHOT_MANIFEST_FILENAME} has no well-formed views block ({copilot: {dir: "${COPILOT_VIEW_REL}", skills: EXACTLY the sorted portable names}})`;
|
|
1064
|
+
}
|
|
1065
|
+
return { parsed: s, rawSha: sha256Hex(raw) };
|
|
1066
|
+
}
|
|
1067
|
+
/** The verified `snapshot.json` of a published generation. */
|
|
1068
|
+
readSnapshotManifest(dir) {
|
|
1069
|
+
const parsed = this.parseSnapshotManifest(dir);
|
|
1070
|
+
if (typeof parsed === 'string')
|
|
1071
|
+
throw new SkillsCurrentInvalidError(dir, parsed);
|
|
1072
|
+
return parsed;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Fold one CoreEvent into the live-generation ledger (v3 §1 reaping rule): a live session pins
|
|
1076
|
+
* the EXACT generation the engine reports it was handed (`skillsSnapshotHanded`), plus every
|
|
1077
|
+
* generation published while it stays live; a launch pin the daemon opened when it handed the
|
|
1078
|
+
* launch to the engine (`live.launched`) is released by that report or the terminal frame — never
|
|
1079
|
+
* by publish count (codex round 4); a terminal frame releases the pins and reaps what no other
|
|
1080
|
+
* live session or open launch holds. The daemon calls this from its one `adapter.onEvent`
|
|
1081
|
+
* listener (live-generations.ts).
|
|
1082
|
+
*/
|
|
1083
|
+
observeEvent(event) {
|
|
1084
|
+
if (this.live.observe(event) === 'released')
|
|
1085
|
+
this.reapStale();
|
|
1086
|
+
}
|
|
1087
|
+
/** Reap generations beyond the newest `KEEP_GENERATIONS` that no live session or open launch pins (no-op before a publish). */
|
|
1088
|
+
reapStale() {
|
|
1089
|
+
// A root that changed identity or a symlinked storage ancestor makes reaping unsafe (it would
|
|
1090
|
+
// rm through the link): skip it, never throw — this runs from the event listener (codex round 3).
|
|
1091
|
+
try {
|
|
1092
|
+
this.assertRootIdentity();
|
|
1093
|
+
this.assertStorageAncestorsClean();
|
|
1094
|
+
}
|
|
1095
|
+
catch (err) {
|
|
1096
|
+
this.warn(`[skills] reaping skipped, the skills root is not intact: ${err instanceof Error ? err.message : String(err)}`);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
let current;
|
|
1100
|
+
try {
|
|
1101
|
+
current = this.currentSnapshot();
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
this.warn(`[skills] reaping skipped, current snapshot unverifiable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (current !== null) {
|
|
1108
|
+
this.reapGenerations(current.gen);
|
|
1109
|
+
this.reapBaselines();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
// ── Seed ──────────────────────────────────────────────────────────────────────────────────
|
|
1113
|
+
/**
|
|
1114
|
+
* Seed the root from the live plugin when it has no manifest. Idempotent: a seeded root is left
|
|
1115
|
+
* alone. A torn earlier seed (dirs but no manifest) is cleared and redone. Throws
|
|
1116
|
+
* `SkillsSourceUnavailableError` when no plugin is installed, and `PluginSourceSymlinkError` when
|
|
1117
|
+
* a designated entry of the source is a symlink (codex round 6) — BEFORE the root is created:
|
|
1118
|
+
* nothing is copied, the runtime reports it as `skills.config` naming the entry.
|
|
1119
|
+
*/
|
|
1120
|
+
seed() {
|
|
1121
|
+
if (this.isSeeded())
|
|
1122
|
+
return { seeded: false, baseline: null, source: null };
|
|
1123
|
+
const source = this.requireSource('no installed wicked-garden plugin found in the Claude plugin cache (plugins/cache/wicked-garden); set WICKED_CREW_SKILLS_SOURCE to use another plugin root deliberately');
|
|
1124
|
+
const bundle = pluginBundleFiles(source.path);
|
|
1125
|
+
const hash = hashFileSet(bundle);
|
|
1126
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
1127
|
+
this.assertRootIdentity(); // bind the identity of the directory just created — or refuse a link that stood there
|
|
1128
|
+
this.captureBaseline(bundle, hash, 'recapture');
|
|
1129
|
+
const effective = this.effectiveDir();
|
|
1130
|
+
rmSync(effective, { recursive: true, force: true });
|
|
1131
|
+
copyFiles(bundle, effective);
|
|
1132
|
+
removeTreeForce(this.snapshotsDir()); // published generations are locked read-only
|
|
1133
|
+
rmSync(this.currentLink(), { force: true });
|
|
1134
|
+
const files = {};
|
|
1135
|
+
for (const f of this.scanEffective().files) {
|
|
1136
|
+
files[f.rel] = { baselineHash: f.sha, effectiveHash: f.sha, lastPublishedHash: null, conflict: false };
|
|
1137
|
+
}
|
|
1138
|
+
const m = {
|
|
1139
|
+
version: MANIFEST_VERSION,
|
|
1140
|
+
revision: 1,
|
|
1141
|
+
baseline: hash,
|
|
1142
|
+
baselines: { [hash]: this.baselineRecord(source) },
|
|
1143
|
+
skills: {},
|
|
1144
|
+
files,
|
|
1145
|
+
published: null,
|
|
1146
|
+
};
|
|
1147
|
+
this.rebuildCatalog(m);
|
|
1148
|
+
this.writeManifest(m);
|
|
1149
|
+
return { seeded: true, baseline: hash, source };
|
|
1150
|
+
}
|
|
1151
|
+
requireSource(detail) {
|
|
1152
|
+
const source = this.sourceFn();
|
|
1153
|
+
if (source === null)
|
|
1154
|
+
throw new SkillsSourceUnavailableError(detail);
|
|
1155
|
+
return source;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Copy the bundle to `baseline/<hash>/` through a staging dir (a torn copy never bears the hash),
|
|
1159
|
+
* LOCKED read-only before it lands. An EXISTING `baseline/<hash>` is reused only after its tree
|
|
1160
|
+
* re-hashes to `<hash>` (codex round 7): a dir that merely exists proved nothing — modified,
|
|
1161
|
+
* planted or pre-planted content would have been restored by reset and published as ordinary
|
|
1162
|
+
* drift. On a mismatch the seed RE-CAPTURES over it (`recapture`: the seed is creating the root's
|
|
1163
|
+
* first state and says so); a refresh REFUSES (`SkillsBaselineCorruptError` → its 2xx `blocked`
|
|
1164
|
+
* `baseline-corrupt` envelope, nothing copied).
|
|
1165
|
+
*/
|
|
1166
|
+
captureBaseline(bundle, hash, onCorrupt) {
|
|
1167
|
+
// A symlinked `baseline/` (or root) would redirect the capture outside the store (codex round 3).
|
|
1168
|
+
this.assertRootIdentity();
|
|
1169
|
+
try {
|
|
1170
|
+
this.assertStorageAncestorsClean();
|
|
1171
|
+
}
|
|
1172
|
+
catch (err) {
|
|
1173
|
+
if (err instanceof SymlinkComponentError)
|
|
1174
|
+
throw new SkillsPublishError(`a storage directory is a symlink (${err.message}) — baseline capture refused`);
|
|
1175
|
+
throw err;
|
|
1176
|
+
}
|
|
1177
|
+
const dest = this.baselineDir(hash);
|
|
1178
|
+
if (this.entryExists(dest)) {
|
|
1179
|
+
const problem = this.baselineProblem(hash);
|
|
1180
|
+
if (problem === null)
|
|
1181
|
+
return;
|
|
1182
|
+
if (onCorrupt === 'refuse')
|
|
1183
|
+
throw new SkillsBaselineCorruptError(dest, problem);
|
|
1184
|
+
this.warn(`[skills] ${problem} — re-captured from the source (the seed owns the root's first state)`);
|
|
1185
|
+
removeTreeForce(dest);
|
|
1186
|
+
}
|
|
1187
|
+
const parent = join(this.rootDir, BASELINE_DIRNAME);
|
|
1188
|
+
mkdirSync(parent, { recursive: true });
|
|
1189
|
+
this.sweepStaging(parent);
|
|
1190
|
+
const staging = join(parent, `${STAGING_PREFIX}${randomBytes(6).toString('hex')}`);
|
|
1191
|
+
copyFiles(bundle, staging);
|
|
1192
|
+
// Re-walk (lstat) and re-hash the staged capture AFTER the copy and BEFORE the rename (design
|
|
1193
|
+
// v3.5 §3): links enumerated (none allowed), every file's digest — a staged tree that is not the
|
|
1194
|
+
// bundle its hash names never becomes `baseline/<hash>`.
|
|
1195
|
+
const staged = walkTree(staging);
|
|
1196
|
+
const stagedHash = hashFileSet(staged.files);
|
|
1197
|
+
if (staged.links.length > 0 || stagedHash !== hash) {
|
|
1198
|
+
removeTreeForce(staging);
|
|
1199
|
+
const link = staged.links[0];
|
|
1200
|
+
throw new SkillsBaselineCorruptError(staging, link !== undefined ? `the staged capture carries a symlink at ${link.rel} -> ${link.target}` : `the staged capture hashes to ${stagedHash}, not to ${hash} — modified between copy and rename`);
|
|
1201
|
+
}
|
|
1202
|
+
if (this.entryExists(dest)) {
|
|
1203
|
+
removeTreeForce(staging); // raced by another capture of the same bytes — theirs is as good, and verified on its next reuse
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
this.lockBaseline(staging);
|
|
1207
|
+
renameSync(staging, dest);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Why `baseline/<hash>` is not the bundle its name claims, or `null`: a real directory whose tree
|
|
1211
|
+
* — links ENUMERATED (a link inside is a corruption, never followed), the per-baseline `.venv`
|
|
1212
|
+
* excluded (it is provisioned INTO the dir after capture; `SKIP_DIR_NAMES`) — hashes to `<hash>`
|
|
1213
|
+
* (`hashFileSet`, the identity the seed computed). Re-derived on EVERY reuse (codex round 7):
|
|
1214
|
+
* before a refresh reuses it, before publish provisions in it and after the provisioner ran
|
|
1215
|
+
* (`validate`), and per file before reset restores from it. Read-only mode bits are a guard
|
|
1216
|
+
* against accidents, never the integrity boundary — this hash is.
|
|
1217
|
+
*
|
|
1218
|
+
* The pruned subtrees stay pruned AND UNCLASSIFIED here (codex round 10; `walkTree(...).pruned`
|
|
1219
|
+
* is deliberately not consulted), unlike under `effective/`: the provisioned `.venv` is an
|
|
1220
|
+
* interpreter environment and legitimately holds symlinks (`bin/python -> python3.x`,
|
|
1221
|
+
* `lib64 -> lib`). That is safe because nothing beneath a pruned directory is ever DELIVERED
|
|
1222
|
+
* except through the snapshot's `.venv` link, whose target both verifiers check by identity —
|
|
1223
|
+
* `snapshotLinkProblem` here (this root's `baseline/<hash>/.venv` for the recorded baseline, the
|
|
1224
|
+
* link text as publish wrote it) and core on its side (every component a real directory, the
|
|
1225
|
+
* target ending exactly at `<baseline>/<64-hex>/.venv`). A link inside the env reaches a worker
|
|
1226
|
+
* only as part of the env the store itself provisioned; the bundle identity re-derived here never
|
|
1227
|
+
* covers it, and no other pruned name is linked from anywhere.
|
|
1228
|
+
*/
|
|
1229
|
+
baselineProblem(hash) {
|
|
1230
|
+
const dir = this.baselineDir(hash);
|
|
1231
|
+
const st = lstatOrNull(dir);
|
|
1232
|
+
if (st === null)
|
|
1233
|
+
return `${dir} does not exist`;
|
|
1234
|
+
if (st.isSymbolicLink())
|
|
1235
|
+
return `${dir} is a symlink`;
|
|
1236
|
+
if (!st.isDirectory())
|
|
1237
|
+
return `${dir} is not a directory`;
|
|
1238
|
+
let tree;
|
|
1239
|
+
try {
|
|
1240
|
+
tree = walkTree(dir);
|
|
1241
|
+
}
|
|
1242
|
+
catch (err) {
|
|
1243
|
+
return `${dir} cannot be walked (${err instanceof Error ? err.message : String(err)})`;
|
|
1244
|
+
}
|
|
1245
|
+
const link = tree.links[0];
|
|
1246
|
+
if (link !== undefined)
|
|
1247
|
+
return `${dir} carries a symlink at ${link.rel} -> ${link.target}`;
|
|
1248
|
+
const special = tree.others[0];
|
|
1249
|
+
if (special !== undefined)
|
|
1250
|
+
return `${dir} carries ${special.rel}, which is neither a file nor a directory`;
|
|
1251
|
+
const actual = hashFileSet(tree.files);
|
|
1252
|
+
if (actual !== hash)
|
|
1253
|
+
return `${dir} hashes to ${actual}, not to its name — a bundle file was modified, added or removed`;
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Lock a captured bundle's FILES read-only — every bundle file loses its write bits. Directories
|
|
1258
|
+
* stay writable: `.venv` is provisioned into the top dir afterwards (venv.ts — the provisioner may
|
|
1259
|
+
* write only there, and publish re-hashes the bundle after it ran), and the store's own reap and
|
|
1260
|
+
* re-capture unlink through them. Mode bits are a guard against accidental edits, NOT the
|
|
1261
|
+
* integrity boundary: `baselineProblem` re-hashes the tree on every reuse regardless of what the
|
|
1262
|
+
* bits say, and `reset` re-hashes every file it restores against the manifest's record.
|
|
1263
|
+
*/
|
|
1264
|
+
lockBaseline(dir) {
|
|
1265
|
+
if (process.platform === 'win32')
|
|
1266
|
+
return;
|
|
1267
|
+
for (const f of walkFiles(dir))
|
|
1268
|
+
chmodSync(f.abs, lstatSync(f.abs).mode & 0o777 & ~0o222);
|
|
1269
|
+
}
|
|
1270
|
+
/** Give the operator's copies their owner-write bit back: a baseline file is locked read-only, an `effective/` file is theirs to edit. */
|
|
1271
|
+
restoreOwnerWrite(paths) {
|
|
1272
|
+
if (process.platform === 'win32')
|
|
1273
|
+
return;
|
|
1274
|
+
for (const p of paths) {
|
|
1275
|
+
const st = lstatOrNull(p);
|
|
1276
|
+
if (st !== null && st.isFile())
|
|
1277
|
+
chmodSync(p, (st.mode & 0o777) | 0o200);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Re-walk (lstat) and re-hash a staged tree AFTER the copy and BEFORE the swap/rename (design v3.5
|
|
1282
|
+
* §3; codex round 8): every expected file present with its expected digest, nothing else, no
|
|
1283
|
+
* symlink. Answers why the staged tree is not what the copy was meant to produce, or `null`.
|
|
1284
|
+
*/
|
|
1285
|
+
stagedTreeProblem(stagedDir, expected) {
|
|
1286
|
+
const tree = walkTree(stagedDir);
|
|
1287
|
+
const link = tree.links[0];
|
|
1288
|
+
if (link !== undefined)
|
|
1289
|
+
return `the staged tree carries a symlink at ${link.rel} -> ${link.target}`;
|
|
1290
|
+
const special = tree.others[0];
|
|
1291
|
+
if (special !== undefined)
|
|
1292
|
+
return `the staged tree carries ${special.rel}, which is neither a file nor a directory`;
|
|
1293
|
+
const implied = new Set(impliedDirs(expected.keys()));
|
|
1294
|
+
const extraDir = tree.dirs.find((d) => !implied.has(d));
|
|
1295
|
+
if (extraDir !== undefined)
|
|
1296
|
+
return `the staged tree carries a directory ${extraDir} that no staged file implies`;
|
|
1297
|
+
const seen = new Set();
|
|
1298
|
+
for (const f of tree.files) {
|
|
1299
|
+
const want = expected.get(f.rel);
|
|
1300
|
+
if (want === undefined)
|
|
1301
|
+
return `the staged tree carries ${f.rel}, which was not staged`;
|
|
1302
|
+
const got = sha256Hex(readFileNoFollow(f.abs));
|
|
1303
|
+
if (got !== want)
|
|
1304
|
+
return `staged ${f.rel} hashes to ${got}, expected ${want} — modified between copy and swap`;
|
|
1305
|
+
seen.add(f.rel);
|
|
1306
|
+
}
|
|
1307
|
+
if (seen.size !== expected.size)
|
|
1308
|
+
return `the staged tree is missing ${[...expected.keys()].filter((k) => !seen.has(k)).join(', ')}`;
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
/** The blocking finding for a staged tree (or a copy source) that changed under the operation — nothing swapped, nothing written (v3.5 §3). */
|
|
1312
|
+
stagedTreeFinding(skill, file, evidence) {
|
|
1313
|
+
return finding('path-invalid', 'blocking', 'the staged tree was re-walked and re-hashed before the swap and was not what the copy produced — a file modified, added, removed or swapped for a symlink between copy and rename (design v3.5 §3: the lstat walk is necessary, not sufficient); nothing was swapped or written', evidence, { skill, file });
|
|
1314
|
+
}
|
|
1315
|
+
/** The blocking `baseline-corrupt` finding (codex round 7). */
|
|
1316
|
+
baselineCorruptFinding(skill, file, evidence) {
|
|
1317
|
+
return finding('baseline-corrupt', 'blocking', 'a content-addressed baseline must hash to its name before anything is copied from it or provisioned in it — a bundle file that was modified, planted or removed (or a symlink inside) would otherwise be restored by reset and published as ordinary drift; nothing was copied or written. Remove the directory (POST /skills/refresh-baseline re-captures it) and retry', evidence, { skill, file });
|
|
1318
|
+
}
|
|
1319
|
+
/** Remove torn `.staging-*` (and legacy `.tmp-*`) dirs under `parent` — the idempotent-retry sweep. */
|
|
1320
|
+
sweepStaging(parent) {
|
|
1321
|
+
let entries;
|
|
1322
|
+
try {
|
|
1323
|
+
entries = readdirSync(parent);
|
|
1324
|
+
}
|
|
1325
|
+
catch (err) {
|
|
1326
|
+
if (errnoCode(err) === 'ENOENT')
|
|
1327
|
+
return;
|
|
1328
|
+
throw err;
|
|
1329
|
+
}
|
|
1330
|
+
for (const e of entries) {
|
|
1331
|
+
if (e.startsWith(STAGING_PREFIX) || e.startsWith('.tmp-'))
|
|
1332
|
+
removeTreeForce(join(parent, e));
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
baselineRecord(source) {
|
|
1336
|
+
const git = gitStateOf(source);
|
|
1337
|
+
if (git.error !== undefined) {
|
|
1338
|
+
this.warn(`[skills] ${source.path} is a checkout but git could not answer (${git.error}); git_sha recorded as null`);
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
plugin_version: source.plugin_version,
|
|
1342
|
+
source: { kind: source.kind, path: source.path },
|
|
1343
|
+
git_sha: git.git_sha,
|
|
1344
|
+
captured_at: this.now(),
|
|
1345
|
+
venv: 'pending',
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* A baseline env is READY only when its on-disk marker is present AND the tree is actually
|
|
1350
|
+
* read-only (codex round 3): the marker is written before the lock, so a marker whose lock never
|
|
1351
|
+
* took is NOT trust — the fast path and the post-lock verification both demand both. On POSIX the
|
|
1352
|
+
* marker's own mode bit and the venv root's prove the lock (a completed `makeTreeReadOnly` strips
|
|
1353
|
+
* every write bit); on Windows (no POSIX modes) the marker alone is the signal.
|
|
1354
|
+
*/
|
|
1355
|
+
venvReady(venvDir) {
|
|
1356
|
+
let marker;
|
|
1357
|
+
try {
|
|
1358
|
+
marker = lstatSync(join(venvDir, VENV_READY_MARKER));
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
if (!marker.isFile())
|
|
1364
|
+
return false;
|
|
1365
|
+
if (process.platform === 'win32')
|
|
1366
|
+
return true;
|
|
1367
|
+
if ((marker.mode & WRITE_BITS) !== 0)
|
|
1368
|
+
return false;
|
|
1369
|
+
try {
|
|
1370
|
+
return (lstatSync(venvDir).mode & WRITE_BITS) === 0;
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Provision the baseline's `.venv` unless it is already READY (marker + read-only bits verify),
|
|
1378
|
+
* and lock a synced env read-only. The marker is written, THEN the tree is locked, THEN both are
|
|
1379
|
+
* re-verified — and a FAILED lock removes the whole env (marker included) so nothing partial is
|
|
1380
|
+
* ever trusted and a retry re-provisions and re-locks from scratch (codex round 3: the marker
|
|
1381
|
+
* used to survive a failed lock and admit the env unconditionally on retry). PERSISTS NOTHING in
|
|
1382
|
+
* the manifest — the publish that succeeds records the state in its commit (a blocked publish
|
|
1383
|
+
* persists nothing, provisioning state included). One provisioning per hash: a concurrent caller
|
|
1384
|
+
* awaits the in-flight one. A `.venv` without a verified marker is a torn earlier sync and is
|
|
1385
|
+
* removed before `uv sync` runs again — the manifest is never the authority on what exists on
|
|
1386
|
+
* disk. AWAITED by publish.
|
|
1387
|
+
*/
|
|
1388
|
+
ensureVenv(hash) {
|
|
1389
|
+
// Every path provisioning touches is validated BEFORE the first filesystem operation — the
|
|
1390
|
+
// ready check, the torn-env removal, `uv sync`, the marker write and the lock all go through
|
|
1391
|
+
// `baseline/<hash>` (codex round 4). Throws `SkillsPublishError`; the publish rejects loudly.
|
|
1392
|
+
const { baselineDir, venvDir, cacheDir } = this.venvPaths(hash);
|
|
1393
|
+
if (this.venvReady(venvDir))
|
|
1394
|
+
return Promise.resolve('synced');
|
|
1395
|
+
const inFlight = this.venvInFlight.get(hash);
|
|
1396
|
+
if (inFlight !== undefined)
|
|
1397
|
+
return inFlight;
|
|
1398
|
+
const run = (async () => {
|
|
1399
|
+
if (this.entryExists(venvDir)) {
|
|
1400
|
+
this.warn(`[skills] ${venvDir} exists without a verified ready marker (a torn earlier sync or an unlockable env) — removed and re-provisioned`);
|
|
1401
|
+
removeTreeForce(venvDir);
|
|
1402
|
+
}
|
|
1403
|
+
const state = await this.provisionVenv(baselineDir, { log: this.warn, cacheDir });
|
|
1404
|
+
if (state !== 'synced')
|
|
1405
|
+
return state;
|
|
1406
|
+
if (!existsSync(venvDir)) {
|
|
1407
|
+
this.warn(`[skills] the provisioner answered synced but ${venvDir} does not exist — recorded as failed`);
|
|
1408
|
+
return 'failed';
|
|
1409
|
+
}
|
|
1410
|
+
try {
|
|
1411
|
+
// Marker FIRST (it must land inside the tree before the lock seals it), THEN lock.
|
|
1412
|
+
writeFileAtomic(join(venvDir, VENV_READY_MARKER), `synced ${this.now()}\n`);
|
|
1413
|
+
makeTreeReadOnly(venvDir);
|
|
1414
|
+
}
|
|
1415
|
+
catch (err) {
|
|
1416
|
+
// An env this daemon cannot lock read-only is not the shared read-only env the contract
|
|
1417
|
+
// requires (tree.ts surfaces the permission failure; codex round 2). A failed lock must
|
|
1418
|
+
// leave NO marker — remove the whole partial env so a retry re-provisions and re-locks
|
|
1419
|
+
// rather than trusting a marker whose lock never took (codex round 3).
|
|
1420
|
+
this.warn(`[skills] could not lock ${venvDir} read-only: ${err instanceof Error ? err.message : String(err)} — removed and recorded as failed`);
|
|
1421
|
+
removeTreeForce(venvDir);
|
|
1422
|
+
return 'failed';
|
|
1423
|
+
}
|
|
1424
|
+
// The lock must actually be in place before we call the env ready — a partial lock that left
|
|
1425
|
+
// the marker or the root writable is a failed provisioning, removed so a retry redoes it.
|
|
1426
|
+
if (!this.venvReady(venvDir)) {
|
|
1427
|
+
this.warn(`[skills] ${venvDir} did not verify read-only after locking — removed and recorded as failed`);
|
|
1428
|
+
removeTreeForce(venvDir);
|
|
1429
|
+
return 'failed';
|
|
1430
|
+
}
|
|
1431
|
+
return 'synced';
|
|
1432
|
+
})();
|
|
1433
|
+
const tracked = run.finally(() => {
|
|
1434
|
+
this.venvInFlight.delete(hash);
|
|
1435
|
+
});
|
|
1436
|
+
this.venvInFlight.set(hash, tracked);
|
|
1437
|
+
return tracked;
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* The provisioning paths of baseline `hash`, VALIDATED before `ensureVenv` deletes, syncs, writes
|
|
1441
|
+
* a marker or chmods anything (codex round 4: the structural check covered `baseline/` but never
|
|
1442
|
+
* `baseline/<hash>`, so a symlink AT the hash redirected the env removal, `uv sync`, the marker
|
|
1443
|
+
* write and the read-only lock outside the store). Requires: the content-hash charset; the root
|
|
1444
|
+
* and `baseline/` intact; `baseline/<hash>` reached without crossing a link and a REAL directory
|
|
1445
|
+
* (a manifest naming a baseline that is not on disk is refused, not provisioned into a void);
|
|
1446
|
+
* `baseline/<hash>/.venv`, its ready marker and the daemon's `.uv-cache` symlink-free (each may
|
|
1447
|
+
* not exist yet — the walk ends at the first missing component). Throws `SkillsPublishError`.
|
|
1448
|
+
*/
|
|
1449
|
+
venvPaths(hash) {
|
|
1450
|
+
const refuse = (detail) => {
|
|
1451
|
+
throw new SkillsPublishError(`baseline environment refused before any filesystem operation: ${detail}`);
|
|
1452
|
+
};
|
|
1453
|
+
if (!CONTENT_HASH_RE.test(hash))
|
|
1454
|
+
refuse(`baseline identifier ${JSON.stringify(hash)} is not a content hash`);
|
|
1455
|
+
this.assertRootIdentity();
|
|
1456
|
+
try {
|
|
1457
|
+
this.assertStorageAncestorsClean();
|
|
1458
|
+
}
|
|
1459
|
+
catch (err) {
|
|
1460
|
+
if (err instanceof SymlinkComponentError)
|
|
1461
|
+
refuse(`a storage directory is a symlink (${err.message})`);
|
|
1462
|
+
throw err;
|
|
1463
|
+
}
|
|
1464
|
+
const walked = (segments) => {
|
|
1465
|
+
try {
|
|
1466
|
+
return containedPath(this.rootDir, segments);
|
|
1467
|
+
}
|
|
1468
|
+
catch (err) {
|
|
1469
|
+
if (err instanceof SkillPathError)
|
|
1470
|
+
return refuse(err.message);
|
|
1471
|
+
throw err;
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
const baselineDir = walked([BASELINE_DIRNAME, hash]);
|
|
1475
|
+
let st;
|
|
1476
|
+
try {
|
|
1477
|
+
st = lstatSync(baselineDir);
|
|
1478
|
+
}
|
|
1479
|
+
catch (err) {
|
|
1480
|
+
if (errnoCode(err) === 'ENOENT')
|
|
1481
|
+
refuse(`${baselineDir} does not exist — the manifest names a baseline that is not on disk; POST /skills/refresh-baseline re-captures it`);
|
|
1482
|
+
throw err;
|
|
1483
|
+
}
|
|
1484
|
+
if (!st.isDirectory())
|
|
1485
|
+
refuse(`${baselineDir} is not a directory`);
|
|
1486
|
+
const venvDir = walked([BASELINE_DIRNAME, hash, VENV_LINKNAME]);
|
|
1487
|
+
walked([BASELINE_DIRNAME, hash, VENV_LINKNAME, VENV_READY_MARKER]);
|
|
1488
|
+
const cacheDir = walked([UV_CACHE_DIRNAME]);
|
|
1489
|
+
return { baselineDir, venvDir, cacheDir };
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Boot / settings entry point: wait out a publish in flight (never start a second one), seed
|
|
1493
|
+
* when unseeded, finish a publish a crash interrupted between the manifest commit and the
|
|
1494
|
+
* `current` flip, publish when nothing is published. Throws `SkillsSourceUnavailableError` for
|
|
1495
|
+
* the seed and `SkillsCurrentInvalidError` / `SkillsManifestCorruptError` for a corrupt root; a
|
|
1496
|
+
* blocked first publish is returned, not thrown. `source` is the plugin root the seed copied
|
|
1497
|
+
* from — its kind, path and plugin version — so the boot log names what was actually seeded (an
|
|
1498
|
+
* explicit `WICKED_CREW_SKILLS_SOURCE` checkout or directory as readily as the installed plugin;
|
|
1499
|
+
* Copilot on #480); `null` when the root was already seeded.
|
|
1500
|
+
*/
|
|
1501
|
+
async ensureReady() {
|
|
1502
|
+
while (this.publishInFlight !== null) {
|
|
1503
|
+
try {
|
|
1504
|
+
await this.publishInFlight;
|
|
1505
|
+
}
|
|
1506
|
+
catch {
|
|
1507
|
+
// Its own caller reports that outcome; this entry point only needed it to settle.
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
const { seeded, source } = this.seed();
|
|
1511
|
+
const m = this.manifest();
|
|
1512
|
+
// A crash between the manifest commit and the `current` flip leaves `current` ABSENT or naming an
|
|
1513
|
+
// OLDER generation. Finish the flip FIRST — from the published generation the manifest
|
|
1514
|
+
// AUTHENTICATES (gen, content hash, metadata hash, the tree re-hashed) — because `current` is then
|
|
1515
|
+
// verified as THE published generation (codex round 7: only that metadata is authenticated), never
|
|
1516
|
+
// as an older one taken on trust. ONLY those two shapes are repaired: a `current` that names
|
|
1517
|
+
// anything else (a non-generation target, a generation ahead of the manifest) is not a torn flip
|
|
1518
|
+
// but a corruption, left for the verification below to refuse loudly; so is a published
|
|
1519
|
+
// generation that does not verify.
|
|
1520
|
+
const named = this.currentLinkGen();
|
|
1521
|
+
if (m.published !== null && (named === null || (typeof named === 'number' && named < m.published.gen))) {
|
|
1522
|
+
const dir = this.snapshotDir(m.published.gen);
|
|
1523
|
+
const meta = this.readSnapshotMetadata(dir);
|
|
1524
|
+
if (typeof meta !== 'string' &&
|
|
1525
|
+
meta.parsed.gen === m.published.gen &&
|
|
1526
|
+
meta.parsed.contentHash === m.published.contentHash &&
|
|
1527
|
+
meta.rawSha === m.published.snapshotHash &&
|
|
1528
|
+
this.snapshotHash(walkTree(dir)) === meta.parsed.contentHash) {
|
|
1529
|
+
this.warn(`[skills] finishing an interrupted publish: current -> ${generationDirName(meta.parsed.gen)}`);
|
|
1530
|
+
this.flipCurrent(meta.parsed.gen);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const current = this.currentSnapshot();
|
|
1534
|
+
if (current !== null)
|
|
1535
|
+
return { seeded, source, published: null };
|
|
1536
|
+
return { seeded, source, published: await this.publish(m.revision) };
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* What `current` LEXICALLY names (its link text) — a hint for the torn-flip recovery, never a
|
|
1540
|
+
* verified answer: `null` when there is no entry at all, the generation number when the text names
|
|
1541
|
+
* a generation directory, `'other'` for anything else (a non-link entry, a target that is not a
|
|
1542
|
+
* generation dir) — which is never repaired, only refused by `verifyCurrent`.
|
|
1543
|
+
*/
|
|
1544
|
+
currentLinkGen() {
|
|
1545
|
+
let target;
|
|
1546
|
+
try {
|
|
1547
|
+
target = readlinkSync(this.currentLink());
|
|
1548
|
+
}
|
|
1549
|
+
catch (err) {
|
|
1550
|
+
return errnoCode(err) === 'ENOENT' ? null : 'other';
|
|
1551
|
+
}
|
|
1552
|
+
const name = basename(target);
|
|
1553
|
+
return GENERATION_DIR_RE.test(name) ? Number(name) : 'other';
|
|
1554
|
+
}
|
|
1555
|
+
// ── Scanning ──────────────────────────────────────────────────────────────────────────────
|
|
1556
|
+
/** Every managed file under `effective/`, hashed. */
|
|
1557
|
+
scanEffective() {
|
|
1558
|
+
this.assertRootIdentity();
|
|
1559
|
+
// ONE walker classifies every entry (tree.ts `walkEntries`; codex round 9): the files are hashed,
|
|
1560
|
+
// the links and special nodes are handed to the validation to refuse by name, the directories are
|
|
1561
|
+
// visible — a symlink or a fifo under effective/ used to be skipped and therefore never judged.
|
|
1562
|
+
// The walk DESCENDS pruned directories too (codex round 10): a link or special node beneath
|
|
1563
|
+
// `node_modules/`, `.venv/` or `__pycache__/` is in `links` / `others` (marked `pruned`) for the
|
|
1564
|
+
// validation to refuse; the files and directories beneath one stay out of the scan — never
|
|
1565
|
+
// carried, never hashed, exactly as before.
|
|
1566
|
+
const entries = walkEntries(this.effectiveDir());
|
|
1567
|
+
return {
|
|
1568
|
+
files: entries.filter(isCarriedFile).map((e) => ({ rel: e.rel, abs: e.abs, sha: sha256Hex(readFileNoFollow(e.abs)) })),
|
|
1569
|
+
links: entries.filter((e) => e.kind === 'symlink'),
|
|
1570
|
+
others: entries.filter((e) => e.kind === 'other'),
|
|
1571
|
+
dirs: entries.filter((e) => e.kind === 'dir' && e.pruned === null).map((e) => e.rel),
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
/** Every manifest `dir` — the registered skills. */
|
|
1575
|
+
manifestDirs(m) {
|
|
1576
|
+
return new Set(Object.values(m.skills).map((e) => e.dir));
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Every skill dir that OWNS files (v3 §6): the registered dirs plus every dir holding a `SKILL.md`
|
|
1580
|
+
* on disk in `effective/` — ownership follows the FILESYSTEM, so a nested skill created by a
|
|
1581
|
+
* direct edit (unregistered until publish reports it) still owns its subtree: a parent's
|
|
1582
|
+
* edit/reset/replace never reaches it, and the parent's endpoint refuses its paths (codex round 2).
|
|
1583
|
+
*/
|
|
1584
|
+
ownershipDirs(m) {
|
|
1585
|
+
const dirs = this.manifestDirs(m);
|
|
1586
|
+
for (const dir of skillDirsOf(walkFiles(this.effectiveDir())))
|
|
1587
|
+
dirs.add(dir);
|
|
1588
|
+
return dirs;
|
|
1589
|
+
}
|
|
1590
|
+
/** Plugin-relative paths of the skill's own files ON DISK in `base` (nested skill subtrees excluded). */
|
|
1591
|
+
ownFilesIn(base, dir, skillDirs) {
|
|
1592
|
+
return walkFiles(this.pluginPath(dir, base), (rel) => skillDirs.has(`${dir}/${rel}`)).map((f) => ({
|
|
1593
|
+
rel: `${dir}/${f.rel}`,
|
|
1594
|
+
abs: f.abs,
|
|
1595
|
+
}));
|
|
1596
|
+
}
|
|
1597
|
+
/** Manifest file records that belong to the skill (nested skills — registered or on disk — excluded). */
|
|
1598
|
+
ownRecords(m, dir) {
|
|
1599
|
+
const dirs = this.ownershipDirs(m);
|
|
1600
|
+
return Object.entries(m.files).filter(([rel]) => owningSkillDir(rel, dirs) === dir);
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* The baseline copy of a plugin-relative file, lstat-walked from the root (a symlinked baseline
|
|
1604
|
+
* ancestor is refused, never read through), or `null` when the baseline has no such file.
|
|
1605
|
+
*/
|
|
1606
|
+
baselineFile(m, rel) {
|
|
1607
|
+
const abs = this.containedBaseline(m.baseline, rel.split('/'));
|
|
1608
|
+
return this.entryExists(abs) && lstatSync(abs).isFile() ? abs : null;
|
|
1609
|
+
}
|
|
1610
|
+
/** `sha256` of the baseline copy of `rel`, or `null` when the baseline has none. */
|
|
1611
|
+
baselineHashOf(m, rel) {
|
|
1612
|
+
const abs = this.baselineFile(m, rel);
|
|
1613
|
+
return abs === null ? null : sha256Hex(readFileNoFollow(abs));
|
|
1614
|
+
}
|
|
1615
|
+
catalogView(m) {
|
|
1616
|
+
const view = {};
|
|
1617
|
+
for (const [name, e] of Object.entries(m.skills))
|
|
1618
|
+
view[name] = { core: e.core, enabled: e.enabled, dir: e.dir };
|
|
1619
|
+
return view;
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Rebuild the catalog from the file records: register skills for every `skills/**\/SKILL.md`
|
|
1623
|
+
* under its PATH-DERIVED name (a differing declared name is warned here and blocks at publish),
|
|
1624
|
+
* keep existing entries' state, recompute every content-derived field and the core closure.
|
|
1625
|
+
* Answers the recompute's refusals (a skill behind a symlink — `recomputeDerived`).
|
|
1626
|
+
*/
|
|
1627
|
+
rebuildCatalog(m) {
|
|
1628
|
+
const present = new Set(Object.entries(m.files).filter(([, r]) => r.effectiveHash !== null).map(([rel]) => rel));
|
|
1629
|
+
const dirs = skillDirsOf([...present].map((rel) => ({ rel })));
|
|
1630
|
+
const byDir = new Map(Object.entries(m.skills).map(([name, e]) => [e.dir, name]));
|
|
1631
|
+
for (const dir of [...dirs].sort()) {
|
|
1632
|
+
if (byDir.has(dir))
|
|
1633
|
+
continue;
|
|
1634
|
+
const name = this.nameForDir(m, dir);
|
|
1635
|
+
if (name === null)
|
|
1636
|
+
continue;
|
|
1637
|
+
m.skills[name] = {
|
|
1638
|
+
dir,
|
|
1639
|
+
kind: 'module',
|
|
1640
|
+
core: false,
|
|
1641
|
+
portable: true,
|
|
1642
|
+
enabled: true,
|
|
1643
|
+
provenance: 'shipped',
|
|
1644
|
+
editedAt: null,
|
|
1645
|
+
upgradeAvailable: false,
|
|
1646
|
+
conflict: false,
|
|
1647
|
+
upstreamDir: null,
|
|
1648
|
+
};
|
|
1649
|
+
byDir.set(dir, name);
|
|
1650
|
+
}
|
|
1651
|
+
return this.recomputeDerived(m);
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* The manifest key for a freshly discovered skill dir: ALWAYS the path-derived name (v3 §5 —
|
|
1655
|
+
* identity is one thing). A declared frontmatter name that differs is warned here and reported
|
|
1656
|
+
* as `name-mismatch` (blocking) at publish; two dirs deriving the same name (`a-b/c` and `a/b-c`)
|
|
1657
|
+
* cannot both be registered — the second is left for publish to report as `unregistered-skill`.
|
|
1658
|
+
* The `SKILL.md` is read CONTAINED (lstat-walked from the root; codex round 5): a dir behind a
|
|
1659
|
+
* link is warned and left unregistered, never read through.
|
|
1660
|
+
*/
|
|
1661
|
+
nameForDir(m, dir) {
|
|
1662
|
+
const derived = derivedSkillName(dir.slice(`${SKILLS_SUBDIR}/`.length));
|
|
1663
|
+
let skillMd;
|
|
1664
|
+
try {
|
|
1665
|
+
skillMd = this.containedEffectiveBytes(`${dir}/SKILL.md`);
|
|
1666
|
+
}
|
|
1667
|
+
catch (err) {
|
|
1668
|
+
if (!(err instanceof SkillPathError))
|
|
1669
|
+
throw err;
|
|
1670
|
+
this.warn(`[skills] ${dir}/SKILL.md is not reachable without crossing a symlink (${err.message}); not registered — publish reports it`);
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
if (skillMd === null)
|
|
1674
|
+
return null; // a record without a regular file behind it — drift, reported at publish
|
|
1675
|
+
const parsed = parseFrontmatter(skillMd.toString('utf8'));
|
|
1676
|
+
if (!parsed.ok) {
|
|
1677
|
+
this.warn(`[skills] ${dir}/SKILL.md frontmatter does not parse (${parsed.reason}); keyed by the path-derived name ${derived} — publish blocks until it parses`);
|
|
1678
|
+
}
|
|
1679
|
+
else {
|
|
1680
|
+
const declared = parsed.fields['name'];
|
|
1681
|
+
if (declared !== undefined && declared !== '' && declared !== derived) {
|
|
1682
|
+
this.warn(`[skills] ${dir}/SKILL.md declares name ${JSON.stringify(declared)} but its path derives ${JSON.stringify(derived)}; keyed by the path-derived name — publish reports name-mismatch`);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
const taken = m.skills[derived];
|
|
1686
|
+
if (taken !== undefined) {
|
|
1687
|
+
this.warn(`[skills] ${dir} derives ${derived}, already registered at ${taken.dir}; not registered — publish reports it as unregistered`);
|
|
1688
|
+
return null;
|
|
1689
|
+
}
|
|
1690
|
+
return derived;
|
|
1691
|
+
}
|
|
1692
|
+
/** Every file record grouped by the skill dir that owns it (`null` = support), computed once per pass. */
|
|
1693
|
+
recordsByOwner(m) {
|
|
1694
|
+
const dirs = this.ownershipDirs(m);
|
|
1695
|
+
const out = new Map();
|
|
1696
|
+
for (const pair of Object.entries(m.files)) {
|
|
1697
|
+
const owner = owningSkillDir(pair[0], dirs);
|
|
1698
|
+
const list = out.get(owner);
|
|
1699
|
+
if (list === undefined)
|
|
1700
|
+
out.set(owner, [pair]);
|
|
1701
|
+
else
|
|
1702
|
+
list.push(pair);
|
|
1703
|
+
}
|
|
1704
|
+
return out;
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Recompute provenance / kind / portable / upgradeAvailable / conflict for every entry, then the
|
|
1708
|
+
* core closure. `conflict` is `upgradeAvailable` (a file the last refresh saw change on both
|
|
1709
|
+
* sides — a user DELETION counts as the user's side) or a refresh's name-collision flag — the
|
|
1710
|
+
* latter only meaningful while the skill is user-added (upstream shipped the name at another
|
|
1711
|
+
* dir); refresh clears and re-derives it.
|
|
1712
|
+
*
|
|
1713
|
+
* CONTAINED (codex round 5): every byte this reads — a skill's `SKILL.md`, every recorded file it
|
|
1714
|
+
* judges portability from — is reached by an lstat walk from the SKILLS ROOT through the skill
|
|
1715
|
+
* dir and every component below it, never by a bare join that would follow `effective/skills/
|
|
1716
|
+
* gamma -> /outside` because only the leaf was checked. A skill whose dir (or a file inside it)
|
|
1717
|
+
* crosses a link is SKIPPED — its derived fields keep their last values, nothing outside is read —
|
|
1718
|
+
* and the refusal is answered as a blocking `path-invalid` finding: publish/analyze report it
|
|
1719
|
+
* blocking; a mutation on ANOTHER skill carries it as a warning (that mutation did land).
|
|
1720
|
+
*/
|
|
1721
|
+
recomputeDerived(m) {
|
|
1722
|
+
const refused = [];
|
|
1723
|
+
const catalogMd = new Map();
|
|
1724
|
+
const byOwner = this.recordsByOwner(m);
|
|
1725
|
+
for (const [name, entry] of Object.entries(m.skills)) {
|
|
1726
|
+
const records = byOwner.get(entry.dir) ?? [];
|
|
1727
|
+
const userAdded = records.length > 0 && records.every(([, r]) => r.baselineHash === null);
|
|
1728
|
+
const pristine = records.every(([, r]) => r.baselineHash !== null && r.effectiveHash === r.baselineHash);
|
|
1729
|
+
entry.provenance = userAdded ? 'user-added' : pristine ? 'shipped' : 'override';
|
|
1730
|
+
const fileConflict = records.some(([, r]) => r.conflict);
|
|
1731
|
+
entry.upgradeAvailable = fileConflict;
|
|
1732
|
+
entry.conflict = fileConflict || (entry.conflict && userAdded);
|
|
1733
|
+
// The skill dir first — walked from the root — so a linked dir is ONE finding, not one per file.
|
|
1734
|
+
let skillDirAbs;
|
|
1735
|
+
try {
|
|
1736
|
+
skillDirAbs = this.containedEffective(entry.dir.split('/'));
|
|
1737
|
+
}
|
|
1738
|
+
catch (err) {
|
|
1739
|
+
refused.push(this.pathFinding(err, name, entry.dir));
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
const below = (rel) => this.regularFileBytes(containedPath(skillDirAbs, rel.slice(entry.dir.length + 1).split('/')));
|
|
1743
|
+
let skillMd;
|
|
1744
|
+
try {
|
|
1745
|
+
skillMd = below(`${entry.dir}/SKILL.md`);
|
|
1746
|
+
}
|
|
1747
|
+
catch (err) {
|
|
1748
|
+
refused.push(this.pathFinding(err, name, `${entry.dir}/SKILL.md`));
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
let kind = 'module';
|
|
1752
|
+
if (skillMd !== null) {
|
|
1753
|
+
const text = skillMd.toString('utf8');
|
|
1754
|
+
catalogMd.set(name, text);
|
|
1755
|
+
const parsed = parseFrontmatter(text);
|
|
1756
|
+
if (parsed.ok)
|
|
1757
|
+
kind = skillKindOf(parsed.fields);
|
|
1758
|
+
}
|
|
1759
|
+
entry.kind = kind;
|
|
1760
|
+
// Judged from what is on disk as a REGULAR file reached without crossing a link: a recorded
|
|
1761
|
+
// file that vanished is drift (publish reports it; the mutation in progress must not die on
|
|
1762
|
+
// it); one that became a link, or sits behind one, is refused and reported — never read.
|
|
1763
|
+
let portable = true;
|
|
1764
|
+
for (const [rel, r] of records) {
|
|
1765
|
+
if (r.effectiveHash === null)
|
|
1766
|
+
continue;
|
|
1767
|
+
let buf;
|
|
1768
|
+
try {
|
|
1769
|
+
buf = below(rel);
|
|
1770
|
+
}
|
|
1771
|
+
catch (err) {
|
|
1772
|
+
refused.push(this.pathFinding(err, name, rel));
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
if (buf === null || looksBinary(buf))
|
|
1776
|
+
continue;
|
|
1777
|
+
if (portabilityIssueOf(buf.toString('utf8')) !== null)
|
|
1778
|
+
portable = false;
|
|
1779
|
+
}
|
|
1780
|
+
entry.portable = portable;
|
|
1781
|
+
}
|
|
1782
|
+
// A DIRECTLY registered reference is core regardless of its readability (design v3.5 §5; codex
|
|
1783
|
+
// round 8): a skill a workflow names by `skill_ref` keeps `core: true` when its `SKILL.md` is
|
|
1784
|
+
// missing or symlink-refused — it is absent from `catalogMd`, so the closure alone would have
|
|
1785
|
+
// dropped it and `disable` would have committed against the downgraded flag. The closure's
|
|
1786
|
+
// `missing` set (a ref naming NO catalog entry) is reported by `validate` as `core-missing`.
|
|
1787
|
+
const registered = this.registeredRefs();
|
|
1788
|
+
const closure = coreClosure(registered, catalogMd);
|
|
1789
|
+
for (const [name, entry] of Object.entries(m.skills))
|
|
1790
|
+
entry.core = closure.core.has(name) || registered.has(name);
|
|
1791
|
+
return refused;
|
|
1792
|
+
}
|
|
1793
|
+
/** The recompute's refusals as WARNINGS — for a mutation that landed on another skill (the blocking form is publish's). */
|
|
1794
|
+
recomputeWarnings(m) {
|
|
1795
|
+
return this.recomputeDerived(m).map((f) => ({ ...f, severity: 'warning' }));
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* The bytes of the REGULAR file at plugin-relative `rel` in `effective/`, every component
|
|
1799
|
+
* lstat-walked from the skills root (`containedEffective`; a link anywhere on the way is a
|
|
1800
|
+
* `SkillPathError` the caller decides on — never a read through it), `null` when absent.
|
|
1801
|
+
*/
|
|
1802
|
+
containedEffectiveBytes(rel) {
|
|
1803
|
+
return this.regularFileBytes(this.containedEffective(rel.split('/')));
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* The bytes of `abs` when it is a REGULAR file, `null` when absent or anything else. The leaf is
|
|
1807
|
+
* lstat'ed here; the PATH to it is the caller's business (`containedEffectiveBytes` /
|
|
1808
|
+
* `recomputeDerived` walk it from the root — a bare join is never enough, codex round 5).
|
|
1809
|
+
*/
|
|
1810
|
+
regularFileBytes(abs) {
|
|
1811
|
+
try {
|
|
1812
|
+
if (!lstatSync(abs).isFile())
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
catch (err) {
|
|
1816
|
+
if (errnoCode(err) === 'ENOENT')
|
|
1817
|
+
return null;
|
|
1818
|
+
throw err;
|
|
1819
|
+
}
|
|
1820
|
+
return readFileNoFollow(abs); // the entry lstat judged is the one read (v3.5 §3)
|
|
1821
|
+
}
|
|
1822
|
+
/** Whether the current baseline ships a skill at `dir` (a user-added skill has no baseline dir). */
|
|
1823
|
+
hasBaselineDir(m, dir) {
|
|
1824
|
+
return this.baselineFile(m, `${dir}/SKILL.md`) !== null;
|
|
1825
|
+
}
|
|
1826
|
+
// ── Reads ─────────────────────────────────────────────────────────────────────────────────
|
|
1827
|
+
entryOf(name) {
|
|
1828
|
+
const entry = this.manifest().skills[name];
|
|
1829
|
+
if (entry === undefined)
|
|
1830
|
+
throw new UnknownSkillError(name);
|
|
1831
|
+
return entry;
|
|
1832
|
+
}
|
|
1833
|
+
listFiles(name) {
|
|
1834
|
+
const m = this.manifest();
|
|
1835
|
+
const entry = m.skills[name];
|
|
1836
|
+
if (entry === undefined)
|
|
1837
|
+
throw new UnknownSkillError(name);
|
|
1838
|
+
this.assertSkillDirContained(entry.dir);
|
|
1839
|
+
const files = this.ownFilesIn(this.effectiveDir(), entry.dir, this.ownershipDirs(m)).map((f) => ({
|
|
1840
|
+
path: f.rel.slice(entry.dir.length + 1),
|
|
1841
|
+
size: statSync(f.abs).size,
|
|
1842
|
+
sha256: sha256Hex(readFileNoFollow(f.abs)),
|
|
1843
|
+
record: m.files[f.rel] ?? null,
|
|
1844
|
+
}));
|
|
1845
|
+
return { name, dir: entry.dir, enabled: entry.enabled, files };
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Contain a skill-relative path (the URL wildcard as Fastify hands it — ALREADY decoded once, never
|
|
1849
|
+
* decoded again; codex round 5): validate, refuse a path a NESTED skill owns (registered or merely
|
|
1850
|
+
* present on disk), lstat-walk FROM THE ROOT through the skill dir refusing symlinks. Returns the
|
|
1851
|
+
* on-disk target in `effective/` and both spellings of the path.
|
|
1852
|
+
*/
|
|
1853
|
+
resolveSkillFile(name, rawRel) {
|
|
1854
|
+
const m = this.manifest();
|
|
1855
|
+
const entry = m.skills[name];
|
|
1856
|
+
if (entry === undefined)
|
|
1857
|
+
throw new UnknownSkillError(name);
|
|
1858
|
+
const segments = validateRelSegments(rawRel);
|
|
1859
|
+
const rel = segments.join('/');
|
|
1860
|
+
const pluginRel = `${entry.dir}/${rel}`;
|
|
1861
|
+
const owner = owningSkillDir(pluginRel, this.ownershipDirs(m));
|
|
1862
|
+
if (owner !== entry.dir) {
|
|
1863
|
+
const ownerName = Object.entries(m.skills).find(([, e]) => e.dir === owner)?.[0];
|
|
1864
|
+
throw new SkillPathError('nested-skill', ownerName === undefined
|
|
1865
|
+
? `${rel} belongs to the nested skill at ${owner} (a SKILL.md on disk the manifest has not registered) — register it with POST /skills or remove it; it is not addressed through ${name}`
|
|
1866
|
+
: `${rel} belongs to the nested skill ${ownerName} (${owner}) — address it through that skill`);
|
|
1867
|
+
}
|
|
1868
|
+
const abs = this.containedEffective([...entry.dir.split('/'), ...segments]);
|
|
1869
|
+
return { abs, rel, pluginRel };
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Contain a root support path: not under `skills/` (those belong to a skill's endpoint), not a
|
|
1873
|
+
* name the store itself owns (`RESERVED_SUPPORT_NAMES`), INSIDE the bundle closure (the ONE
|
|
1874
|
+
* allowlist — bundle.ts `inBundleClosure`; codex round 6), no symlinks.
|
|
1875
|
+
*/
|
|
1876
|
+
resolveSupportFile(rawRel) {
|
|
1877
|
+
const segments = validateRelSegments(rawRel);
|
|
1878
|
+
const rel = segments.join('/');
|
|
1879
|
+
const head = segments[0] ?? '';
|
|
1880
|
+
if (head === SKILLS_SUBDIR) {
|
|
1881
|
+
throw new SkillPathError('nested-skill', `${rel} is under ${SKILLS_SUBDIR}/ — skill files are addressed through /skills/:name/files`);
|
|
1882
|
+
}
|
|
1883
|
+
if (RESERVED_SUPPORT_NAMES.has(head)) {
|
|
1884
|
+
throw new SkillPathError('reserved', `${rel}: ${head} is reserved — the store generates it at publish (${SNAPSHOT_MANIFEST_FILENAME}, ${VIEWS_DIRNAME}/), links it (${VENV_LINKNAME}, ${CURRENT_LINKNAME}) or keeps its state in it (${MANIFEST_FILENAME}); a support file by that name would be overwritten or break the snapshot`);
|
|
1885
|
+
}
|
|
1886
|
+
// The bundle closure is the ONE allowlist (bundle.ts; codex round 6): a support path the seed
|
|
1887
|
+
// would never copy (hooks/, tests/, the site, a stray docs/ page, the scripts/ dev tooling) is
|
|
1888
|
+
// not a support file the store manages — it cannot be added or read through the API, and a
|
|
1889
|
+
// snapshot never ships it. A write answers the 2xx `blocked` `outside-closure` envelope.
|
|
1890
|
+
if (!inBundleClosure(rel)) {
|
|
1891
|
+
throw new SkillPathError('outside-closure', `${rel} is outside the bundle closure — the support tree a snapshot carries is exactly what the seed copies (${BUNDLE_CLOSURE_SPELLING}); hooks, tests, the site and everything else a plugin checkout holds never ride a snapshot`);
|
|
1892
|
+
}
|
|
1893
|
+
return { abs: this.containedEffective(segments), rel };
|
|
1894
|
+
}
|
|
1895
|
+
/** A typed, capped read of one of the skill's files (`side: 'baseline'` reads the shipped copy, contained the same way). */
|
|
1896
|
+
async readFile(name, rawRel, side = 'effective') {
|
|
1897
|
+
const target = this.resolveSkillFile(name, rawRel);
|
|
1898
|
+
if (side === 'effective')
|
|
1899
|
+
return this.typedRead(target.pluginRel, target.abs);
|
|
1900
|
+
// The baseline side of a skill a refresh HELD BACK — upstream ships a skill under this name at
|
|
1901
|
+
// ANOTHER directory (`upstreamDir`, codex round 9) — is that upstream directory in the current
|
|
1902
|
+
// baseline, so the two sides of the collision are actually comparable; otherwise the skill's own
|
|
1903
|
+
// dir. `path` in the answer names the plugin-relative file actually read.
|
|
1904
|
+
const m = this.manifest();
|
|
1905
|
+
const entry = m.skills[name];
|
|
1906
|
+
if (entry === undefined)
|
|
1907
|
+
throw new UnknownSkillError(name);
|
|
1908
|
+
const baseDir = entry.upstreamDir ?? entry.dir;
|
|
1909
|
+
const pluginRel = `${baseDir}/${target.rel}`;
|
|
1910
|
+
return this.typedRead(pluginRel, this.containedBaseline(m.baseline, pluginRel.split('/')));
|
|
1911
|
+
}
|
|
1912
|
+
async readSupport(rawRel, side = 'effective') {
|
|
1913
|
+
const target = this.resolveSupportFile(rawRel);
|
|
1914
|
+
const abs = side === 'effective' ? target.abs : this.containedBaseline(this.manifest().baseline, target.rel.split('/'));
|
|
1915
|
+
return this.typedRead(target.rel, abs);
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* The capped read as the wire spells it: `content` is `null` — not `""` — when the file is binary.
|
|
1919
|
+
* The leaf the containment walk judged is lstat'ed here and the read opens it `O_NOFOLLOW` with a
|
|
1920
|
+
* dev/ino identity check against that lstat (v3.5 §3): a link swapped in between the walk and the
|
|
1921
|
+
* open is refused, never served.
|
|
1922
|
+
*/
|
|
1923
|
+
async typedRead(path, abs) {
|
|
1924
|
+
const before = lstatSync(abs);
|
|
1925
|
+
if (!before.isFile())
|
|
1926
|
+
throw new NotARegularFileError(abs);
|
|
1927
|
+
const read = await readFileCapped(abs, { noFollow: true, identity: { dev: before.dev, ino: before.ino } });
|
|
1928
|
+
return { path, content: read.binary ? null : read.content, size: read.size, truncated: read.truncated, binary: read.binary };
|
|
1929
|
+
}
|
|
1930
|
+
// ── Mutations ─────────────────────────────────────────────────────────────────────────────
|
|
1931
|
+
result(m, name, findings) {
|
|
1932
|
+
const entry = name === null ? undefined : m.skills[name];
|
|
1933
|
+
return entry === undefined || name === null
|
|
1934
|
+
? { verdict: verdictOf(findings), findings, revision: m.revision }
|
|
1935
|
+
: { verdict: verdictOf(findings), findings, revision: m.revision, skill: { name, ...entry } };
|
|
1936
|
+
}
|
|
1937
|
+
blocked(m, findings) {
|
|
1938
|
+
return { verdict: 'blocked', findings, revision: m.revision };
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* A containment failure on a WRITE is a guard result, not a transport error (design v3 §API:
|
|
1942
|
+
* "guard results ALWAYS return 2xx {verdict, findings, revision}"). Anything that is not a
|
|
1943
|
+
* `SkillPathError` is rethrown.
|
|
1944
|
+
*/
|
|
1945
|
+
pathFinding(err, skill, file) {
|
|
1946
|
+
if (!(err instanceof SkillPathError))
|
|
1947
|
+
throw err;
|
|
1948
|
+
if (err.reason === 'outside-closure') {
|
|
1949
|
+
return finding('outside-closure', 'blocking', `the bundle closure (${BUNDLE_CLOSURE_SPELLING}) is the ONE allowlist of what the seed copies and a snapshot may carry; a support file outside it would ship a tree the plugin contract excludes — nothing was written`, err.message, { skill, file });
|
|
1950
|
+
}
|
|
1951
|
+
const explanation = err.reason === 'symlink'
|
|
1952
|
+
? 'the skills root never follows symlinks — a link on the path (the skill directory itself, a child directory, or a baseline ancestor included) would redirect the operation outside the store'
|
|
1953
|
+
: err.reason === 'nested-skill'
|
|
1954
|
+
? 'a nested skill owns its own files; address it through that skill'
|
|
1955
|
+
: err.reason === 'reserved'
|
|
1956
|
+
? 'the name is owned by the store at that level (snapshot metadata, the manifest, the current link, the generated views, the shared env link)'
|
|
1957
|
+
: 'the path must be a normalized skill-relative POSIX path that stays inside the skill directory';
|
|
1958
|
+
return finding('path-invalid', 'blocking', explanation, err.message, { skill, file });
|
|
1959
|
+
}
|
|
1960
|
+
/** Re-hash the skill's own files on disk into the records (absent baseline files stay as `effectiveHash: null`). */
|
|
1961
|
+
refreshRecords(m, dir) {
|
|
1962
|
+
const onDisk = new Map(this.ownFilesIn(this.effectiveDir(), dir, this.ownershipDirs(m)).map((f) => [f.rel, f.abs]));
|
|
1963
|
+
for (const [rel, record] of this.ownRecords(m, dir)) {
|
|
1964
|
+
const abs = onDisk.get(rel);
|
|
1965
|
+
if (abs === undefined) {
|
|
1966
|
+
if (record.baselineHash === null)
|
|
1967
|
+
delete m.files[rel];
|
|
1968
|
+
else
|
|
1969
|
+
record.effectiveHash = null;
|
|
1970
|
+
}
|
|
1971
|
+
else {
|
|
1972
|
+
record.effectiveHash = sha256Hex(readFileNoFollow(abs));
|
|
1973
|
+
onDisk.delete(rel);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
for (const [rel, abs] of onDisk) {
|
|
1977
|
+
m.files[rel] = {
|
|
1978
|
+
baselineHash: this.baselineHashOf(m, rel),
|
|
1979
|
+
effectiveHash: sha256Hex(readFileNoFollow(abs)),
|
|
1980
|
+
lastPublishedHash: null,
|
|
1981
|
+
conflict: false,
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
/** The named entry, or `UnknownSkillError` — checked BEFORE the revision: a missing resource is a 404, not a stale-client 409. */
|
|
1986
|
+
requireEntry(m, name, expectedRevision) {
|
|
1987
|
+
const entry = m.skills[name];
|
|
1988
|
+
if (entry === undefined)
|
|
1989
|
+
throw new UnknownSkillError(name);
|
|
1990
|
+
this.assertRevision(m, expectedRevision);
|
|
1991
|
+
return entry;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Enable — with the content + containment guards a write gets (codex round 2: enable used to
|
|
1995
|
+
* flip the flag blind): the skill dir and its `SKILL.md` are lstat-walked from the root (a
|
|
1996
|
+
* symlinked skill is `path-invalid`), the `SKILL.md` must exist (`missing-skill-md`), parse
|
|
1997
|
+
* (`frontmatter-invalid`) and declare the path-derived name (`name-mismatch`) — a skill that
|
|
1998
|
+
* would block the next publish is not enabled.
|
|
1999
|
+
*/
|
|
2000
|
+
enable(name, expectedRevision) {
|
|
2001
|
+
const m = this.manifest();
|
|
2002
|
+
const entry = this.requireEntry(m, name, expectedRevision);
|
|
2003
|
+
const skillMdRel = `${entry.dir}/SKILL.md`;
|
|
2004
|
+
let skillMdAbs;
|
|
2005
|
+
try {
|
|
2006
|
+
skillMdAbs = this.containedEffective(skillMdRel.split('/'));
|
|
2007
|
+
}
|
|
2008
|
+
catch (err) {
|
|
2009
|
+
return this.blocked(m, [this.pathFinding(err, name, skillMdRel)]);
|
|
2010
|
+
}
|
|
2011
|
+
// The walk above ended at the leaf without crossing a link: an existing entry here is the real file.
|
|
2012
|
+
const skillMd = this.entryExists(skillMdAbs) && lstatSync(skillMdAbs).isFile() ? readFileNoFollow(skillMdAbs).toString('utf8') : undefined;
|
|
2013
|
+
const recompute = this.recomputeWarnings(m); // `core` from the live registered refs, never the cached entry
|
|
2014
|
+
const findings = [...frontmatterGuard(skillMd, name, { isCore: entry.core, file: skillMdRel }), ...recompute];
|
|
2015
|
+
if (verdictOf(findings) === 'blocked')
|
|
2016
|
+
return this.blocked(m, findings);
|
|
2017
|
+
if (entry.enabled)
|
|
2018
|
+
return this.result(m, name, findings);
|
|
2019
|
+
entry.enabled = true;
|
|
2020
|
+
this.commit(m);
|
|
2021
|
+
return this.result(m, name, findings);
|
|
2022
|
+
}
|
|
2023
|
+
/** Disable — core membership is RECOMPUTED here (registered refs are read at use time), never trusted from the cached entry. */
|
|
2024
|
+
disable(name, expectedRevision) {
|
|
2025
|
+
const m = this.manifest();
|
|
2026
|
+
const entry = this.requireEntry(m, name, expectedRevision);
|
|
2027
|
+
const recompute = this.recomputeWarnings(m);
|
|
2028
|
+
const findings = [...compact([coreDisableGuard(name, entry)]), ...recompute];
|
|
2029
|
+
if (verdictOf(findings) === 'blocked')
|
|
2030
|
+
return this.blocked(m, findings);
|
|
2031
|
+
if (!entry.enabled)
|
|
2032
|
+
return this.result(m, name, findings);
|
|
2033
|
+
entry.enabled = false;
|
|
2034
|
+
this.commit(m);
|
|
2035
|
+
return this.result(m, name, findings);
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Restore the skill's own files from the current baseline (mode bits ride the copy). `enabled`
|
|
2039
|
+
* is untouched by design. Both sides are walked from the root (a symlinked baseline ancestor is
|
|
2040
|
+
* refused, never imported from; codex round 2).
|
|
2041
|
+
*
|
|
2042
|
+
* Restore candidates EXCLUDE every path a nested `SKILL.md` owns in the EFFECTIVE tree — not only
|
|
2043
|
+
* one present in the baseline (codex round 3): a child created directly under the parent
|
|
2044
|
+
* (`alpha/refs/SKILL.md`) owns its files even though the baseline has none, so a parent reset must
|
|
2045
|
+
* not restore baseline bytes over the child's. And the reset PREFLIGHTS every destination
|
|
2046
|
+
* (containment, no-follow) and STAGES the baseline bytes into a temp dir under the root BEFORE it
|
|
2047
|
+
* removes a single effective file, so a blocked reset (a symlinked `alpha/refs`) mutates nothing
|
|
2048
|
+
* — the `SymlinkComponentError` maps to the 2xx `blocked` envelope, never an escaped 500 that
|
|
2049
|
+
* already deleted the parent's SKILL.md (codex round 3).
|
|
2050
|
+
*/
|
|
2051
|
+
reset(name, expectedRevision) {
|
|
2052
|
+
const m = this.manifest();
|
|
2053
|
+
const entry = this.requireEntry(m, name, expectedRevision);
|
|
2054
|
+
let baseSkillDir;
|
|
2055
|
+
try {
|
|
2056
|
+
this.assertSkillDirContained(entry.dir);
|
|
2057
|
+
baseSkillDir = this.containedBaseline(m.baseline, entry.dir.split('/'));
|
|
2058
|
+
}
|
|
2059
|
+
catch (err) {
|
|
2060
|
+
return this.blocked(m, [this.pathFinding(err, name, entry.dir)]);
|
|
2061
|
+
}
|
|
2062
|
+
// "User-added" is what the MANIFEST says (no record of the skill carries a baseline hash), never
|
|
2063
|
+
// what happens to be on disk (codex round 7): a baseline dir that vanished under a recorded skill
|
|
2064
|
+
// is a corrupt baseline, reported below by name — not a skill without a baseline.
|
|
2065
|
+
const records = new Map(this.ownRecords(m, entry.dir));
|
|
2066
|
+
const findings = compact([noBaselineGuard(name, [...records.values()].every((r) => r.baselineHash === null))]);
|
|
2067
|
+
if (verdictOf(findings) === 'blocked')
|
|
2068
|
+
return this.blocked(m, findings);
|
|
2069
|
+
const effective = this.effectiveDir();
|
|
2070
|
+
const owned = this.ownershipDirs(m); // effective + manifest ownership — a nested child is its own
|
|
2071
|
+
// Restore candidates from the baseline, pruning every subtree a nested skill owns in EFFECTIVE.
|
|
2072
|
+
const fresh = walkFiles(baseSkillDir, (rel) => owned.has(`${entry.dir}/${rel}`)).map((f) => ({ rel: `${entry.dir}/${f.rel}`, abs: f.abs }));
|
|
2073
|
+
const own = this.ownFilesIn(effective, entry.dir, owned);
|
|
2074
|
+
// The baseline is content-addressed (codex round 7): every file about to be restored must hash
|
|
2075
|
+
// to the record the manifest holds for it, and every recorded baseline file of the skill must be
|
|
2076
|
+
// there — a modified, planted or removed baseline file is `baseline-corrupt`, nothing written.
|
|
2077
|
+
const freshRels = new Set(fresh.map((f) => f.rel));
|
|
2078
|
+
for (const f of fresh) {
|
|
2079
|
+
const record = records.get(f.rel);
|
|
2080
|
+
const actual = sha256Hex(readFileNoFollow(f.abs));
|
|
2081
|
+
if (record === undefined || record.baselineHash === null) {
|
|
2082
|
+
return this.blocked(m, [this.baselineCorruptFinding(name, f.rel, `${BASELINE_DIRNAME}/${m.baseline}/${f.rel} is not a recorded baseline file of ${name} — planted`)]);
|
|
2083
|
+
}
|
|
2084
|
+
if (record.baselineHash !== actual) {
|
|
2085
|
+
return this.blocked(m, [this.baselineCorruptFinding(name, f.rel, `${BASELINE_DIRNAME}/${m.baseline}/${f.rel} hashes to ${actual}, the manifest recorded ${record.baselineHash} — modified`)]);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
for (const [rel, record] of records) {
|
|
2089
|
+
if (record.baselineHash !== null && !freshRels.has(rel)) {
|
|
2090
|
+
return this.blocked(m, [this.baselineCorruptFinding(name, rel, `${BASELINE_DIRNAME}/${m.baseline}/${rel} is recorded in the manifest but missing from the baseline — removed`)]);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
// Preflight: every destination path — the files to restore AND the files to remove — must be
|
|
2094
|
+
// symlink-free from the root BEFORE anything is removed; a refusal is a blocked envelope.
|
|
2095
|
+
const staging = join(this.rootDir, `${STAGING_PREFIX}reset-${randomBytes(6).toString('hex')}`);
|
|
2096
|
+
const stagedDir = join(staging, 'new');
|
|
2097
|
+
let place;
|
|
2098
|
+
try {
|
|
2099
|
+
for (const f of own)
|
|
2100
|
+
this.containedEffective(f.rel.split('/'));
|
|
2101
|
+
place = fresh.map((f) => ({ src: join(stagedDir, ...f.rel.split('/')), dest: this.containedEffective(f.rel.split('/')) }));
|
|
2102
|
+
}
|
|
2103
|
+
catch (err) {
|
|
2104
|
+
return this.blocked(m, [this.pathFinding(err, name, entry.dir)]);
|
|
2105
|
+
}
|
|
2106
|
+
// Stage the baseline bytes under the root (owner-write restored: the baseline is locked, the
|
|
2107
|
+
// operator's copies are theirs to edit), then the park-and-place transaction (`swapStaged`, codex
|
|
2108
|
+
// round 6) and the manifest half under `commitSwap` (codex round 7): a failure anywhere after the
|
|
2109
|
+
// swap — the records, the validated manifest write, the rename into place — rolls the content
|
|
2110
|
+
// back from the parked originals, so the skill is byte-for-byte what it was and the revision
|
|
2111
|
+
// unchanged.
|
|
2112
|
+
let swap;
|
|
2113
|
+
try {
|
|
2114
|
+
copyFiles(fresh, stagedDir);
|
|
2115
|
+
this.restoreOwnerWrite(place.map((p) => p.src));
|
|
2116
|
+
// Re-walked and re-hashed against the records the sources were verified with (v3.5 §3).
|
|
2117
|
+
const stagedProblem = this.stagedTreeProblem(stagedDir, new Map(fresh.map((f) => [f.rel, records.get(f.rel)?.baselineHash ?? ''])));
|
|
2118
|
+
if (stagedProblem !== null) {
|
|
2119
|
+
removeTreeForce(staging);
|
|
2120
|
+
return this.blocked(m, [this.stagedTreeFinding(name, entry.dir, stagedProblem)]);
|
|
2121
|
+
}
|
|
2122
|
+
swap = this.swapStaged(name, entry.dir, staging, own, place);
|
|
2123
|
+
}
|
|
2124
|
+
catch (err) {
|
|
2125
|
+
removeTreeForce(staging);
|
|
2126
|
+
// A source swapped for a link between the walk and the copy is refused, never copied (v3.5 §3).
|
|
2127
|
+
if (err instanceof EntrySwappedError || err instanceof SymlinkComponentError)
|
|
2128
|
+
return this.blocked(m, [this.stagedTreeFinding(name, entry.dir, err.message)]);
|
|
2129
|
+
throw err;
|
|
2130
|
+
}
|
|
2131
|
+
if ('finding' in swap)
|
|
2132
|
+
return this.blocked(m, [swap.finding]);
|
|
2133
|
+
this.commitSwap(swap.handle, m, () => {
|
|
2134
|
+
for (const [rel, record] of this.ownRecords(m, entry.dir)) {
|
|
2135
|
+
if (record.baselineHash === null)
|
|
2136
|
+
delete m.files[rel];
|
|
2137
|
+
else {
|
|
2138
|
+
record.effectiveHash = record.baselineHash;
|
|
2139
|
+
record.conflict = false;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
entry.editedAt = null;
|
|
2143
|
+
entry.conflict = false;
|
|
2144
|
+
findings.push(...this.recomputeWarnings(m));
|
|
2145
|
+
});
|
|
2146
|
+
return this.result(m, name, findings);
|
|
2147
|
+
}
|
|
2148
|
+
/** Write one file inside the skill (containment via `resolveSkillFile` → a `blocked` envelope on refusal); guards on SKILL.md + support paths. */
|
|
2149
|
+
writeFile(name, rawRel, content, expectedRevision) {
|
|
2150
|
+
const m = this.manifest();
|
|
2151
|
+
const entry = this.requireEntry(m, name, expectedRevision);
|
|
2152
|
+
let target;
|
|
2153
|
+
try {
|
|
2154
|
+
target = this.resolveSkillFile(name, rawRel);
|
|
2155
|
+
}
|
|
2156
|
+
catch (err) {
|
|
2157
|
+
return this.blocked(m, [this.pathFinding(err, name, rawRel)]);
|
|
2158
|
+
}
|
|
2159
|
+
const findings = this.putGuards(name, entry, target.rel, content);
|
|
2160
|
+
if (verdictOf(findings) === 'blocked')
|
|
2161
|
+
return this.blocked(m, findings);
|
|
2162
|
+
// One file, the same transaction as every multi-file swap (codex round 7): staged, the existing
|
|
2163
|
+
// file parked, placed, and committed WITH the manifest — a failed manifest commit rolls it back.
|
|
2164
|
+
const swap = this.stageSingleFile(name, target.pluginRel, target.abs, content);
|
|
2165
|
+
if ('finding' in swap)
|
|
2166
|
+
return this.blocked(m, [swap.finding]);
|
|
2167
|
+
this.commitSwap(swap.handle, m, () => {
|
|
2168
|
+
entry.editedAt = this.now();
|
|
2169
|
+
this.refreshRecords(m, entry.dir);
|
|
2170
|
+
findings.push(...this.recomputeWarnings(m));
|
|
2171
|
+
});
|
|
2172
|
+
return this.result(m, name, findings);
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Stage ONE file for the park-and-place transaction (codex round 7): the content is written under
|
|
2176
|
+
* `staging/new` (an existing regular file's mode bits carried over), the existing file — if any —
|
|
2177
|
+
* is parked, the staged file placed. `commitSwap` then commits the manifest or rolls this back.
|
|
2178
|
+
*/
|
|
2179
|
+
stageSingleFile(skill, rel, dest, content) {
|
|
2180
|
+
const staging = join(this.rootDir, `${STAGING_PREFIX}write-${randomBytes(6).toString('hex')}`);
|
|
2181
|
+
const src = join(staging, 'new', ...rel.split('/'));
|
|
2182
|
+
const existing = lstatOrNull(dest);
|
|
2183
|
+
const current = existing !== null && existing.isFile() ? existing : null;
|
|
2184
|
+
try {
|
|
2185
|
+
writeFileAtomic(src, content, current === null ? {} : { mode: current.mode & 0o777 });
|
|
2186
|
+
}
|
|
2187
|
+
catch (err) {
|
|
2188
|
+
removeTreeForce(staging);
|
|
2189
|
+
throw err;
|
|
2190
|
+
}
|
|
2191
|
+
const stagedProblem = this.stagedTreeProblem(join(staging, 'new'), new Map([[rel, sha256Hex(Buffer.from(content, 'utf8'))]]));
|
|
2192
|
+
if (stagedProblem !== null) {
|
|
2193
|
+
removeTreeForce(staging);
|
|
2194
|
+
return { finding: this.stagedTreeFinding(skill, rel, stagedProblem) };
|
|
2195
|
+
}
|
|
2196
|
+
return this.swapStaged(skill, rel, staging, current === null ? [] : [{ rel, abs: dest }], [{ src, dest }]);
|
|
2197
|
+
}
|
|
2198
|
+
/** Write one root support file (`scripts/`, `schemas/`, `.claude-plugin/`, …) — always a warning. */
|
|
2199
|
+
writeSupport(rawRel, content, expectedRevision) {
|
|
2200
|
+
const m = this.manifest();
|
|
2201
|
+
this.assertRevision(m, expectedRevision);
|
|
2202
|
+
let target;
|
|
2203
|
+
try {
|
|
2204
|
+
target = this.resolveSupportFile(rawRel);
|
|
2205
|
+
}
|
|
2206
|
+
catch (err) {
|
|
2207
|
+
return this.blocked(m, [this.pathFinding(err, null, rawRel)]);
|
|
2208
|
+
}
|
|
2209
|
+
const findings = [
|
|
2210
|
+
finding('support-file-edit', 'warning', 'root support files (scripts, schemas, the plugin manifest) back the behavior of every skill that resolves them — an edit here changes what skills DO across the whole catalog', `edit: ${target.rel}`, { file: target.rel }),
|
|
2211
|
+
];
|
|
2212
|
+
const swap = this.stageSingleFile(null, target.rel, target.abs, content);
|
|
2213
|
+
if ('finding' in swap)
|
|
2214
|
+
return this.blocked(m, [swap.finding]);
|
|
2215
|
+
this.commitSwap(swap.handle, m, () => {
|
|
2216
|
+
const sha = sha256Hex(Buffer.from(content, 'utf8'));
|
|
2217
|
+
const record = m.files[target.rel];
|
|
2218
|
+
if (record === undefined) {
|
|
2219
|
+
m.files[target.rel] = {
|
|
2220
|
+
baselineHash: this.baselineHashOf(m, target.rel),
|
|
2221
|
+
effectiveHash: sha,
|
|
2222
|
+
lastPublishedHash: null,
|
|
2223
|
+
conflict: false,
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
else {
|
|
2227
|
+
record.effectiveHash = sha;
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
return this.result(m, null, findings);
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Every destination of a multi-file write, lstat-walked from the ROOT (the skill dir AND every
|
|
2234
|
+
* component below it): a symlinked child directory inside the skill would otherwise redirect a
|
|
2235
|
+
* descendant write outside the store (codex round 2: `gamma/link -> /outside` + `link/victim.txt`).
|
|
2236
|
+
* And every destination is checked for an entry OF THE OTHER KIND already standing there (codex
|
|
2237
|
+
* round 5): a key that names an existing directory (one the swap cannot clear — it holds anything
|
|
2238
|
+
* but the skill's own files and empty dirs), or a key whose would-be parent is an existing NON-own
|
|
2239
|
+
* regular file, cannot land; refused here, BEFORE any mutation, so the ENOTDIR/EISDIR the
|
|
2240
|
+
* placement would hit never happens after the old files are gone. Answers the blocking
|
|
2241
|
+
* `path-invalid` finding for the first refused path, or the resolved targets.
|
|
2242
|
+
*/
|
|
2243
|
+
containedDestinations(name, dir, files, own) {
|
|
2244
|
+
const targets = [];
|
|
2245
|
+
const ownAbs = new Set(own.map((f) => f.abs));
|
|
2246
|
+
const kindConflict = (rel, evidence) => ({
|
|
2247
|
+
finding: finding('path-invalid', 'blocking', 'a files map lays each key out as a regular file; an entry of the other kind already standing on that path (a directory where the file goes, a file where a parent directory goes) cannot be replaced by it — nothing was changed', evidence, { skill: name, file: rel }),
|
|
2248
|
+
});
|
|
2249
|
+
for (const [rel, text] of Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
2250
|
+
let abs;
|
|
2251
|
+
try {
|
|
2252
|
+
abs = this.containedEffective([...dir.split('/'), ...rel.split('/')]);
|
|
2253
|
+
}
|
|
2254
|
+
catch (err) {
|
|
2255
|
+
return { finding: this.pathFinding(err, name, rel) };
|
|
2256
|
+
}
|
|
2257
|
+
// The walk ended without crossing a link, so an existing entry here is real. A DIRECTORY at
|
|
2258
|
+
// the destination survives the replace unless it holds nothing but own files / empty dirs.
|
|
2259
|
+
const st = lstatOrNull(abs);
|
|
2260
|
+
if (st !== null && st.isDirectory() && !this.clearableDir(abs, ownAbs)) {
|
|
2261
|
+
return kindConflict(rel, `${rel} names an existing directory in ${dir} that is not made of the skill's own files alone`);
|
|
2262
|
+
}
|
|
2263
|
+
if (st !== null && !st.isDirectory() && !st.isFile()) {
|
|
2264
|
+
return kindConflict(rel, `${rel} names an existing entry in ${dir} that is neither a file nor a directory`);
|
|
2265
|
+
}
|
|
2266
|
+
// A would-be PARENT that exists as a non-directory (a regular file the replace does not remove) blocks the child.
|
|
2267
|
+
const segments = rel.split('/');
|
|
2268
|
+
let parent = this.pluginPath(dir);
|
|
2269
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
2270
|
+
parent = join(parent, segments[i]);
|
|
2271
|
+
const pst = lstatOrNull(parent);
|
|
2272
|
+
if (pst === null)
|
|
2273
|
+
break; // nothing below exists yet — the write creates it
|
|
2274
|
+
if (!pst.isDirectory()) {
|
|
2275
|
+
if (ownAbs.has(parent))
|
|
2276
|
+
break; // an own file the swap parks first — the directory can then be made
|
|
2277
|
+
return kindConflict(rel, `${rel} needs ${segments.slice(0, i + 1).join('/')} as a directory, but a file the replace does not remove stands there`);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
targets.push({ rel, abs, text });
|
|
2281
|
+
}
|
|
2282
|
+
return { targets };
|
|
2283
|
+
}
|
|
2284
|
+
/** Whether a directory holds nothing but the skill's own regular files (removed by the swap) and empty subdirectories. */
|
|
2285
|
+
clearableDir(dir, ownAbs) {
|
|
2286
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
2287
|
+
const abs = join(dir, entry.name);
|
|
2288
|
+
if (entry.isSymbolicLink())
|
|
2289
|
+
return false;
|
|
2290
|
+
if (entry.isDirectory()) {
|
|
2291
|
+
if (!this.clearableDir(abs, ownAbs))
|
|
2292
|
+
return false;
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2295
|
+
if (!entry.isFile() || !ownAbs.has(abs))
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
return true;
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* The atomic heart of `add` and `replace` (codex round 5): the replacement is WRITTEN in full into
|
|
2302
|
+
* a staging dir under the root, THEN the skill's own files are parked and the staged files placed
|
|
2303
|
+
* by the park-and-place transaction every multi-file swap shares (`swapStaged` — reset and refresh
|
|
2304
|
+
* go through the same one, codex round 6). Any failure ROLLS BACK: the skill is byte-for-byte
|
|
2305
|
+
* what it was, the revision unchanged. The removal used to come first, so a placement that failed
|
|
2306
|
+
* (`x` a file, `x/y` needing it as a directory) had already destroyed the notes it could not replace.
|
|
2307
|
+
*/
|
|
2308
|
+
swapOwnFiles(name, dir, own, targets, modes) {
|
|
2309
|
+
const staging = join(this.rootDir, `${STAGING_PREFIX}swap-${randomBytes(6).toString('hex')}`);
|
|
2310
|
+
const stagedDir = join(staging, 'new');
|
|
2311
|
+
let place;
|
|
2312
|
+
try {
|
|
2313
|
+
// The whole replacement lands in staging first — a write failure here touches nothing live.
|
|
2314
|
+
place = targets.map((t) => {
|
|
2315
|
+
const src = join(stagedDir, ...t.rel.split('/'));
|
|
2316
|
+
const mode = modes.get(`${dir}/${t.rel}`);
|
|
2317
|
+
writeFileAtomic(src, t.text, mode === undefined ? {} : { mode });
|
|
2318
|
+
return { src, dest: t.abs };
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
catch (err) {
|
|
2322
|
+
removeTreeForce(staging);
|
|
2323
|
+
throw err;
|
|
2324
|
+
}
|
|
2325
|
+
// Re-walked and re-hashed against the texts just written (v3.5 §3) before a single rename.
|
|
2326
|
+
const stagedProblem = this.stagedTreeProblem(stagedDir, new Map(targets.map((t) => [t.rel, sha256Hex(Buffer.from(t.text, 'utf8'))])));
|
|
2327
|
+
if (stagedProblem !== null) {
|
|
2328
|
+
removeTreeForce(staging);
|
|
2329
|
+
return { finding: this.stagedTreeFinding(name, dir, stagedProblem) };
|
|
2330
|
+
}
|
|
2331
|
+
return this.swapStaged(name, dir, staging, own, place);
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* The park-and-place transaction EVERY content mutation goes through — replace/add (codex round
|
|
2335
|
+
* 5), reset and refresh-baseline (codex round 6), the single-file write (codex round 7). `park` is
|
|
2336
|
+
* every existing file the swap removes OR overwrites; `place` every staged source (already written
|
|
2337
|
+
* under `staging/new`) and its destination.
|
|
2338
|
+
*
|
|
2339
|
+
* 1. park: every `park` file is RENAMED into `staging/old` (never deleted), then the directories
|
|
2340
|
+
* it left empty are pruned up to `effective/`;
|
|
2341
|
+
* 2. place: every staged file is renamed into its destination (a destination that is now an
|
|
2342
|
+
* empty directory tree is removed first, parents are created).
|
|
2343
|
+
*
|
|
2344
|
+
* Every step is a same-filesystem rename. A failure INSIDE the swap rolls back — what was placed
|
|
2345
|
+
* is removed and its parents pruned, what was parked is renamed back (mode bits ride the rename)
|
|
2346
|
+
* — removes the staging and answers a blocking `path-invalid` finding: the tree is byte-for-byte
|
|
2347
|
+
* what it was. A swap that LANDED answers a `SwapHandle` whose parked originals stay under the
|
|
2348
|
+
* staging until `commitSwap` either committed the manifest or rolled the content back (codex round
|
|
2349
|
+
* 7): content and revision move together or not at all.
|
|
2350
|
+
*/
|
|
2351
|
+
swapStaged(skill, label, staging, park, place) {
|
|
2352
|
+
const effective = this.effectiveDir();
|
|
2353
|
+
const parkedDir = join(staging, 'old');
|
|
2354
|
+
const parked = [];
|
|
2355
|
+
const placed = [];
|
|
2356
|
+
const rollback = () => {
|
|
2357
|
+
for (const d of placed)
|
|
2358
|
+
rmSync(d, { force: true });
|
|
2359
|
+
// Prune the parents of EVERY destination, not only the placed ones: a failing placement had
|
|
2360
|
+
// already created its parent directories before its rename failed.
|
|
2361
|
+
for (const p of place)
|
|
2362
|
+
pruneEmptyDirs(dirname(p.dest), effective);
|
|
2363
|
+
for (const { from, to } of parked) {
|
|
2364
|
+
mkdirSync(dirname(from), { recursive: true });
|
|
2365
|
+
renameSync(to, from);
|
|
2366
|
+
}
|
|
2367
|
+
};
|
|
2368
|
+
try {
|
|
2369
|
+
for (const f of park) {
|
|
2370
|
+
const to = join(parkedDir, ...f.rel.split('/'));
|
|
2371
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
2372
|
+
renameSync(f.abs, to);
|
|
2373
|
+
parked.push({ from: f.abs, to });
|
|
2374
|
+
}
|
|
2375
|
+
for (const f of park)
|
|
2376
|
+
pruneEmptyDirs(dirname(f.abs), effective);
|
|
2377
|
+
for (const p of place) {
|
|
2378
|
+
this.removeEmptyDirTree(p.dest);
|
|
2379
|
+
mkdirSync(dirname(p.dest), { recursive: true });
|
|
2380
|
+
renameSync(p.src, p.dest);
|
|
2381
|
+
placed.push(p.dest);
|
|
2382
|
+
}
|
|
2383
|
+
return { handle: { rollback, dispose: () => removeTreeForce(staging) } };
|
|
2384
|
+
}
|
|
2385
|
+
catch (err) {
|
|
2386
|
+
rollback();
|
|
2387
|
+
removeTreeForce(staging);
|
|
2388
|
+
const code = errnoCode(err);
|
|
2389
|
+
return {
|
|
2390
|
+
finding: finding('path-invalid', 'blocking', 'the files could not be laid out on disk (a path collided with an entry of the other kind, or the filesystem refused a rename mid-swap); the swap was rolled back — every parked file is back byte-for-byte, nothing was written, the revision is unchanged', `${code === undefined ? 'error' : code}: ${err instanceof Error ? err.message : String(err)}`, { skill, file: label }),
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* The manifest half of a mutation whose content swap already LANDED (codex round 7): `finish`
|
|
2396
|
+
* updates the in-memory manifest (records re-hashed from the placed bytes, derived fields
|
|
2397
|
+
* recomputed from them — which is why it runs after the swap), then the manifest is validated and
|
|
2398
|
+
* written (`commit`: `manifest.json.tmp-…` + rename). If ANY of that fails — a record refresh, the
|
|
2399
|
+
* schema, the temp write, the rename into place — the content swap is rolled back from the parked
|
|
2400
|
+
* originals, so `effective/` and `manifest.json` move together or not at all: the request fails
|
|
2401
|
+
* with the error, the content is byte-for-byte what it was, and the persisted revision is
|
|
2402
|
+
* unchanged (it was never reusable against changed content). The parked originals are released
|
|
2403
|
+
* only after the commit landed or the rollback ran.
|
|
2404
|
+
*/
|
|
2405
|
+
commitSwap(handle, m, finish) {
|
|
2406
|
+
const revision = m.revision;
|
|
2407
|
+
try {
|
|
2408
|
+
finish();
|
|
2409
|
+
this.commit(m);
|
|
2410
|
+
}
|
|
2411
|
+
catch (err) {
|
|
2412
|
+
handle.rollback();
|
|
2413
|
+
m.revision = revision;
|
|
2414
|
+
throw err;
|
|
2415
|
+
}
|
|
2416
|
+
finally {
|
|
2417
|
+
handle.dispose();
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
/** Remove `path` when it is a directory tree holding only (empty) directories; anything else is left alone. */
|
|
2421
|
+
removeEmptyDirTree(path) {
|
|
2422
|
+
const st = lstatOrNull(path);
|
|
2423
|
+
if (st === null || !st.isDirectory())
|
|
2424
|
+
return;
|
|
2425
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
2426
|
+
if (!entry.isDirectory())
|
|
2427
|
+
return;
|
|
2428
|
+
this.removeEmptyDirTree(join(path, entry.name));
|
|
2429
|
+
}
|
|
2430
|
+
if (readdirSync(path).length === 0)
|
|
2431
|
+
rmSync(path, { recursive: false, force: true });
|
|
2432
|
+
}
|
|
2433
|
+
/** Add a user skill at `skills/<name minus the prefix>` — staged, then swapped in (`swapOwnFiles`). */
|
|
2434
|
+
add(name, files, expectedRevision) {
|
|
2435
|
+
const m = this.manifest();
|
|
2436
|
+
this.assertRevision(m, expectedRevision);
|
|
2437
|
+
const findings = this.addGuards(m, name, files);
|
|
2438
|
+
if (verdictOf(findings) === 'blocked')
|
|
2439
|
+
return this.blocked(m, findings);
|
|
2440
|
+
const dir = dirForUserSkill(name);
|
|
2441
|
+
try {
|
|
2442
|
+
this.assertSkillDirContained(dir);
|
|
2443
|
+
}
|
|
2444
|
+
catch (err) {
|
|
2445
|
+
return this.blocked(m, [this.pathFinding(err, name, dir)]);
|
|
2446
|
+
}
|
|
2447
|
+
const destinations = this.containedDestinations(name, dir, files, []);
|
|
2448
|
+
if ('finding' in destinations)
|
|
2449
|
+
return this.blocked(m, [destinations.finding]);
|
|
2450
|
+
const swap = this.swapOwnFiles(name, dir, [], destinations.targets, new Map());
|
|
2451
|
+
if ('finding' in swap)
|
|
2452
|
+
return this.blocked(m, [swap.finding]);
|
|
2453
|
+
this.commitSwap(swap.handle, m, () => {
|
|
2454
|
+
m.skills[name] = {
|
|
2455
|
+
dir,
|
|
2456
|
+
kind: 'module',
|
|
2457
|
+
core: false,
|
|
2458
|
+
portable: true,
|
|
2459
|
+
enabled: true,
|
|
2460
|
+
provenance: 'user-added',
|
|
2461
|
+
editedAt: this.now(),
|
|
2462
|
+
upgradeAvailable: false,
|
|
2463
|
+
conflict: false,
|
|
2464
|
+
upstreamDir: null,
|
|
2465
|
+
};
|
|
2466
|
+
this.refreshRecords(m, dir);
|
|
2467
|
+
findings.push(...this.recomputeWarnings(m));
|
|
2468
|
+
});
|
|
2469
|
+
return this.result(m, name, findings);
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Replace the skill's own files wholesale (nested skills — registered or on disk — untouched); an
|
|
2473
|
+
* existing file's mode bits survive the replace. EVERY destination is lstat-walked from the root
|
|
2474
|
+
* and checked against entries of the other kind BEFORE the first byte moves, and the swap itself
|
|
2475
|
+
* is staged + rolled back on failure (`swapOwnFiles`): a refused or failed replace changes nothing.
|
|
2476
|
+
*/
|
|
2477
|
+
replace(name, files, expectedRevision) {
|
|
2478
|
+
const m = this.manifest();
|
|
2479
|
+
const entry = this.requireEntry(m, name, expectedRevision);
|
|
2480
|
+
const findings = this.replaceGuards(m, name, entry, files);
|
|
2481
|
+
if (verdictOf(findings) === 'blocked')
|
|
2482
|
+
return this.blocked(m, findings);
|
|
2483
|
+
try {
|
|
2484
|
+
this.assertSkillDirContained(entry.dir);
|
|
2485
|
+
}
|
|
2486
|
+
catch (err) {
|
|
2487
|
+
return this.blocked(m, [this.pathFinding(err, name, entry.dir)]);
|
|
2488
|
+
}
|
|
2489
|
+
const effective = this.effectiveDir();
|
|
2490
|
+
const own = this.ownFilesIn(effective, entry.dir, this.ownershipDirs(m));
|
|
2491
|
+
const destinations = this.containedDestinations(name, entry.dir, files, own);
|
|
2492
|
+
if ('finding' in destinations)
|
|
2493
|
+
return this.blocked(m, [destinations.finding]);
|
|
2494
|
+
const modes = new Map(own.map((f) => [f.rel, lstatSync(f.abs).mode & 0o777]));
|
|
2495
|
+
const swap = this.swapOwnFiles(name, entry.dir, own, destinations.targets, modes);
|
|
2496
|
+
if ('finding' in swap)
|
|
2497
|
+
return this.blocked(m, [swap.finding]);
|
|
2498
|
+
this.commitSwap(swap.handle, m, () => {
|
|
2499
|
+
entry.editedAt = this.now();
|
|
2500
|
+
this.refreshRecords(m, entry.dir);
|
|
2501
|
+
findings.push(...this.recomputeWarnings(m));
|
|
2502
|
+
});
|
|
2503
|
+
return this.result(m, name, findings);
|
|
2504
|
+
}
|
|
2505
|
+
putGuards(name, entry, rel, content) {
|
|
2506
|
+
const out = [];
|
|
2507
|
+
if (rel === 'SKILL.md')
|
|
2508
|
+
out.push(...frontmatterGuard(content, name, { isCore: entry.core, file: `${entry.dir}/SKILL.md` }));
|
|
2509
|
+
out.push(...compact([nestedSkillCreateGuard(rel, name), supportFileGuard(rel, name)]));
|
|
2510
|
+
if (entry.portable)
|
|
2511
|
+
out.push(...compact([nonPortableGuard({ [rel]: content }, name)]));
|
|
2512
|
+
return out;
|
|
2513
|
+
}
|
|
2514
|
+
filesGuards(name, files) {
|
|
2515
|
+
const out = [];
|
|
2516
|
+
const keys = Object.keys(files).sort();
|
|
2517
|
+
for (const rel of keys) {
|
|
2518
|
+
try {
|
|
2519
|
+
const norm = validateRelSegments(rel).join('/');
|
|
2520
|
+
if (norm !== rel) {
|
|
2521
|
+
out.push(finding('path-invalid', 'blocking', 'file paths are normalized skill-relative POSIX paths', `${JSON.stringify(rel)} is not normalized (expected ${JSON.stringify(norm)})`, { skill: name, file: rel }));
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
catch (err) {
|
|
2525
|
+
if (!(err instanceof SkillPathError))
|
|
2526
|
+
throw err;
|
|
2527
|
+
out.push(finding('path-invalid', 'blocking', 'the path must stay inside the skill directory', err.message, { skill: name, file: rel }));
|
|
2528
|
+
}
|
|
2529
|
+
out.push(...compact([nestedSkillCreateGuard(rel, name), supportFileGuard(rel, name)]));
|
|
2530
|
+
}
|
|
2531
|
+
// Incompatible paths within ONE map (codex round 5): a key that is also a directory prefix of
|
|
2532
|
+
// another (`x` and `x/y`) cannot both land — the first makes `x` a file, the second needs it as
|
|
2533
|
+
// a directory. Refused here, before any mutation, whichever of add/replace carries the map.
|
|
2534
|
+
const ancestors = new Map();
|
|
2535
|
+
for (const rel of keys) {
|
|
2536
|
+
const segments = rel.split('/');
|
|
2537
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
2538
|
+
const prefix = segments.slice(0, i).join('/');
|
|
2539
|
+
if (!ancestors.has(prefix))
|
|
2540
|
+
ancestors.set(prefix, rel);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
for (const rel of keys) {
|
|
2544
|
+
const under = ancestors.get(rel);
|
|
2545
|
+
if (under === undefined)
|
|
2546
|
+
continue;
|
|
2547
|
+
out.push(finding('path-invalid', 'blocking', 'a files map lays each key out as a regular file, so a key cannot also be a directory on the way to another key — the two cannot both exist on disk', `${JSON.stringify(rel)} is a file AND a directory prefix of ${JSON.stringify(under)}`, { skill: name, file: rel }));
|
|
2548
|
+
}
|
|
2549
|
+
out.push(...compact([nonPortableGuard(files, name)]));
|
|
2550
|
+
return out;
|
|
2551
|
+
}
|
|
2552
|
+
addGuards(m, name, files) {
|
|
2553
|
+
const out = compact([nameGuard(name)]);
|
|
2554
|
+
if (out.length > 0)
|
|
2555
|
+
return out; // a malformed name has no dir to derive further guards from
|
|
2556
|
+
out.push(...compact([collisionGuard(this.catalogView(m), name)]));
|
|
2557
|
+
const dir = dirForUserSkill(name);
|
|
2558
|
+
if (existsSync(this.pluginPath(dir)) || this.hasBaselineDir(m, dir)) {
|
|
2559
|
+
out.push(finding('name-collision', 'blocking', 'a directory already exists where this skill would land', `${dir} exists`, { skill: name }));
|
|
2560
|
+
}
|
|
2561
|
+
out.push(...this.filesGuards(name, files));
|
|
2562
|
+
out.push(...frontmatterGuard(files['SKILL.md'], name, { isCore: this.registeredRefs().has(name), file: `${dir}/SKILL.md` }));
|
|
2563
|
+
return out;
|
|
2564
|
+
}
|
|
2565
|
+
replaceGuards(m, name, entry, files) {
|
|
2566
|
+
const out = this.filesGuards(name, files);
|
|
2567
|
+
const nestedUnder = [...this.ownershipDirs(m)].filter((d) => d !== entry.dir && d.startsWith(`${entry.dir}/`));
|
|
2568
|
+
for (const rel of Object.keys(files)) {
|
|
2569
|
+
const owner = owningSkillDir(`${entry.dir}/${rel}`, new Set([entry.dir, ...nestedUnder]));
|
|
2570
|
+
if (owner !== entry.dir) {
|
|
2571
|
+
out.push(finding('path-invalid', 'blocking', 'a nested skill owns its own files; address it through that skill', `${rel} lies under the nested skill at ${owner}`, { skill: name, file: rel }));
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
out.push(...frontmatterGuard(files['SKILL.md'], name, { isCore: entry.core, file: `${entry.dir}/SKILL.md` }));
|
|
2575
|
+
return out;
|
|
2576
|
+
}
|
|
2577
|
+
// ── Refresh (three-way per FILE) ──────────────────────────────────────────────────────────
|
|
2578
|
+
/**
|
|
2579
|
+
* Re-capture the live plugin as a new baseline and merge per file (base_old / base_new /
|
|
2580
|
+
* effective) with the v3 §7 truth table. A user DELETION is a modification: with upstream
|
|
2581
|
+
* unchanged the deletion stands; with upstream changed the deletion is kept and the file is a
|
|
2582
|
+
* `conflict` (the new side readable as `?side=baseline`). A held-back upstream skill (its
|
|
2583
|
+
* path-derived name collides with a user-added skill at another dir) is left in the baseline only
|
|
2584
|
+
* and flagged. The previous baseline dir is NOT removed here — a baseline lives while any
|
|
2585
|
+
* snapshot on disk references it (`reapBaselines`).
|
|
2586
|
+
*
|
|
2587
|
+
* ATOMIC against refusal (codex round 4): the merge is DECIDED in memory first; then EVERY
|
|
2588
|
+
* destination it would write or remove is preflighted (lstat-walked from the root, no symlink
|
|
2589
|
+
* component — `containedEffective`); only then is the new baseline captured, every taken file
|
|
2590
|
+
* STAGED into a temp dir under the root, and the swap (removals + renames) performed, followed by
|
|
2591
|
+
* the manifest commit. A refused destination — an upstream-added file whose `effective/` parent
|
|
2592
|
+
* is a symlink, say — answers the normal 2xx `blocked` `path-invalid` envelope with NOTHING
|
|
2593
|
+
* changed: no file copied or removed, no baseline captured, the revision unchanged (it used to
|
|
2594
|
+
* copy file by file and escape as a 500 after the files ahead of the refusal had landed).
|
|
2595
|
+
*/
|
|
2596
|
+
refreshBaseline(expectedRevision) {
|
|
2597
|
+
const m = this.manifest();
|
|
2598
|
+
this.assertRevision(m, expectedRevision);
|
|
2599
|
+
const source = this.requireSource('no installed wicked-garden plugin found to refresh from');
|
|
2600
|
+
const previous = m.baseline;
|
|
2601
|
+
let bundle;
|
|
2602
|
+
try {
|
|
2603
|
+
bundle = pluginBundleFiles(source.path);
|
|
2604
|
+
}
|
|
2605
|
+
catch (err) {
|
|
2606
|
+
if (!(err instanceof PluginSourceSymlinkError))
|
|
2607
|
+
throw err;
|
|
2608
|
+
// A symlink among the source's designated entries (codex round 6): refused by name as the
|
|
2609
|
+
// normal 2xx `blocked` envelope — nothing copied, no baseline captured, the revision unchanged.
|
|
2610
|
+
return {
|
|
2611
|
+
verdict: 'blocked',
|
|
2612
|
+
findings: [this.sourceSymlinkFinding(err)],
|
|
2613
|
+
revision: m.revision,
|
|
2614
|
+
previous_baseline: previous,
|
|
2615
|
+
baseline: previous,
|
|
2616
|
+
plugin_version: source.plugin_version,
|
|
2617
|
+
taken: [],
|
|
2618
|
+
kept: [],
|
|
2619
|
+
added: [],
|
|
2620
|
+
removed: [],
|
|
2621
|
+
conflicts: [],
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
const newHash = hashFileSet(bundle);
|
|
2625
|
+
const base = (extra = {}) => ({
|
|
2626
|
+
verdict: 'clear',
|
|
2627
|
+
findings: [],
|
|
2628
|
+
revision: m.revision,
|
|
2629
|
+
previous_baseline: previous,
|
|
2630
|
+
baseline: newHash,
|
|
2631
|
+
plugin_version: source.plugin_version,
|
|
2632
|
+
taken: [],
|
|
2633
|
+
kept: [],
|
|
2634
|
+
added: [],
|
|
2635
|
+
removed: [],
|
|
2636
|
+
conflicts: [],
|
|
2637
|
+
...extra,
|
|
2638
|
+
});
|
|
2639
|
+
if (newHash === previous)
|
|
2640
|
+
return base(); // byte-identical upstream: nothing to merge
|
|
2641
|
+
// ── Decide (in memory — nothing on disk moves until the preflight below has passed) ─────
|
|
2642
|
+
const newFiles = new Map(bundle.map((f) => [f.rel, sha256Hex(readFileNoFollow(f.abs))])); // the source entries the bundle walk judged, read no-follow
|
|
2643
|
+
const effective = new Map(this.scanEffective().files.map((f) => [f.rel, f.sha]));
|
|
2644
|
+
const oldFiles = new Map(Object.entries(m.files).filter(([, r]) => r.baselineHash !== null).map(([rel, r]) => [rel, r.baselineHash]));
|
|
2645
|
+
// Held-back upstream skills: a NEW upstream dir whose PATH-DERIVED name is already a manifest
|
|
2646
|
+
// key at a different dir (a user-added skill) — v3 §7's name-collision conflict. Every
|
|
2647
|
+
// name-collision flag is re-derived by this pass, so clear the previous refresh's first.
|
|
2648
|
+
const findings = [];
|
|
2649
|
+
const heldBack = new Set();
|
|
2650
|
+
const conflicts = new Set();
|
|
2651
|
+
const manifestDirs = this.manifestDirs(m);
|
|
2652
|
+
for (const entry of Object.values(m.skills)) {
|
|
2653
|
+
entry.conflict = false;
|
|
2654
|
+
entry.upstreamDir = null;
|
|
2655
|
+
}
|
|
2656
|
+
for (const dir of skillDirsOf(bundle)) {
|
|
2657
|
+
if (manifestDirs.has(dir))
|
|
2658
|
+
continue;
|
|
2659
|
+
const name = derivedSkillName(dir.slice(`${SKILLS_SUBDIR}/`.length));
|
|
2660
|
+
const existing = m.skills[name];
|
|
2661
|
+
if (existing === undefined || existing.dir === dir)
|
|
2662
|
+
continue;
|
|
2663
|
+
heldBack.add(dir);
|
|
2664
|
+
existing.conflict = true;
|
|
2665
|
+
existing.upstreamDir = dir; // `?side=baseline` reads of this skill resolve HERE (codex round 9)
|
|
2666
|
+
conflicts.add(name);
|
|
2667
|
+
findings.push(finding('refresh-conflict', 'warning', 'upstream now ships a skill under a name the operator added; the user-added skill is kept and the upstream one is left in the baseline (readable as ?side=baseline) — rename or remove one of them', `${name}: user-added at ${existing.dir}, upstream at ${dir}`, { skill: name, file: `${dir}/SKILL.md`, against: { name, core: existing.core } }));
|
|
2668
|
+
}
|
|
2669
|
+
const isHeld = (rel) => [...heldBack].some((d) => rel.startsWith(`${d}/`));
|
|
2670
|
+
const dirOfBefore = new Map(Object.entries(m.skills).map(([name, e]) => [e.dir, name]));
|
|
2671
|
+
const touched = new Set();
|
|
2672
|
+
/** Files to copy from the NEW baseline into `effective/` (the truth table's "take"). */
|
|
2673
|
+
const takes = [];
|
|
2674
|
+
/** Files to remove from `effective/` (upstream deleted, user unmodified). */
|
|
2675
|
+
const removals = [];
|
|
2676
|
+
const rels = new Set([...oldFiles.keys(), ...newFiles.keys(), ...effective.keys()]);
|
|
2677
|
+
for (const rel of [...rels].sort()) {
|
|
2678
|
+
if (isHeld(rel))
|
|
2679
|
+
continue;
|
|
2680
|
+
const bo = oldFiles.get(rel) ?? null;
|
|
2681
|
+
const bn = newFiles.get(rel) ?? null;
|
|
2682
|
+
const e = effective.get(rel) ?? null;
|
|
2683
|
+
const record = m.files[rel];
|
|
2684
|
+
const take = () => {
|
|
2685
|
+
takes.push(rel);
|
|
2686
|
+
m.files[rel] = { baselineHash: bn, effectiveHash: bn, lastPublishedHash: record?.lastPublishedHash ?? null, conflict: false };
|
|
2687
|
+
touched.add(rel);
|
|
2688
|
+
};
|
|
2689
|
+
if (bn !== null) {
|
|
2690
|
+
if (bo === null) {
|
|
2691
|
+
if (e === null || e === bn)
|
|
2692
|
+
take();
|
|
2693
|
+
else {
|
|
2694
|
+
m.files[rel] = { baselineHash: bn, effectiveHash: e, lastPublishedHash: record?.lastPublishedHash ?? null, conflict: true };
|
|
2695
|
+
touched.add(rel);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
else if (e === bo) {
|
|
2699
|
+
if (bn !== bo)
|
|
2700
|
+
take(); // user unmodified → take upstream
|
|
2701
|
+
}
|
|
2702
|
+
else if (e === null) {
|
|
2703
|
+
// The user DELETED the file — a modification. Upstream unchanged: the deletion stands
|
|
2704
|
+
// (baseline = bo = bn, effective = null). Upstream changed: keep the deletion, flag the
|
|
2705
|
+
// conflict, the new side sits in the baseline for diffing (reset restores it).
|
|
2706
|
+
if (bn !== bo) {
|
|
2707
|
+
m.files[rel] = { baselineHash: bn, effectiveHash: null, lastPublishedHash: record?.lastPublishedHash ?? null, conflict: true };
|
|
2708
|
+
touched.add(rel);
|
|
2709
|
+
}
|
|
2710
|
+
else if (record !== undefined) {
|
|
2711
|
+
record.effectiveHash = null;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
else if (e === bn) {
|
|
2715
|
+
m.files[rel] = { baselineHash: bn, effectiveHash: e, lastPublishedHash: record?.lastPublishedHash ?? null, conflict: false };
|
|
2716
|
+
}
|
|
2717
|
+
else if (bn === bo) {
|
|
2718
|
+
// user-modified, upstream unchanged → keep (the record follows the bytes on disk)
|
|
2719
|
+
if (record !== undefined)
|
|
2720
|
+
record.effectiveHash = e;
|
|
2721
|
+
}
|
|
2722
|
+
else {
|
|
2723
|
+
m.files[rel] = { baselineHash: bn, effectiveHash: e, lastPublishedHash: record?.lastPublishedHash ?? null, conflict: true };
|
|
2724
|
+
touched.add(rel);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
else if (bo !== null) {
|
|
2728
|
+
// upstream deleted
|
|
2729
|
+
if (e === bo) {
|
|
2730
|
+
removals.push(rel);
|
|
2731
|
+
delete m.files[rel];
|
|
2732
|
+
touched.add(rel);
|
|
2733
|
+
}
|
|
2734
|
+
else if (e === null) {
|
|
2735
|
+
delete m.files[rel];
|
|
2736
|
+
}
|
|
2737
|
+
else if (record !== undefined) {
|
|
2738
|
+
record.baselineHash = null; // kept as user-added
|
|
2739
|
+
record.conflict = false;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
// else: a user-added file upstream never had — kept as is
|
|
2743
|
+
}
|
|
2744
|
+
// ── Preflight EVERY destination before anything moves (codex round 4) ──────────────────
|
|
2745
|
+
// A refused path answers the 2xx `blocked` envelope with NOTHING changed: the in-memory
|
|
2746
|
+
// decisions above are simply dropped (the manifest on disk was never touched).
|
|
2747
|
+
const destinations = new Map();
|
|
2748
|
+
for (const rel of [...takes, ...removals].sort()) {
|
|
2749
|
+
try {
|
|
2750
|
+
destinations.set(rel, this.containedEffective(rel.split('/')));
|
|
2751
|
+
}
|
|
2752
|
+
catch (err) {
|
|
2753
|
+
const owner = owningSkillDir(rel, new Set(dirOfBefore.keys()));
|
|
2754
|
+
const skill = owner === null ? null : (dirOfBefore.get(owner) ?? null);
|
|
2755
|
+
return base({ verdict: 'blocked', findings: [this.pathFinding(err, skill, rel)] });
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
// ── Capture the new baseline (staging + rename; refused through a symlinked `baseline/`) ──
|
|
2759
|
+
// An existing `baseline/<newHash>` is reused only if it re-hashes to its name (codex round 7):
|
|
2760
|
+
// a corrupt one is the 2xx `blocked` `baseline-corrupt` envelope — nothing copied, nothing changed.
|
|
2761
|
+
try {
|
|
2762
|
+
this.captureBaseline(bundle, newHash, 'refuse');
|
|
2763
|
+
}
|
|
2764
|
+
catch (err) {
|
|
2765
|
+
if (!(err instanceof SkillsBaselineCorruptError))
|
|
2766
|
+
throw err;
|
|
2767
|
+
return base({ verdict: 'blocked', findings: [this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${newHash}`, err.message)] });
|
|
2768
|
+
}
|
|
2769
|
+
// ── Stage every take under the root, then swap: removals, then renames into place ─────────
|
|
2770
|
+
// Sources are walked from the root through `baseline/<newHash>/…` (a link inside the freshly
|
|
2771
|
+
// captured baseline is refused, never read through); a refusal here leaves `effective/` and
|
|
2772
|
+
// the manifest untouched and reaps the unreferenced capture.
|
|
2773
|
+
const staging = join(this.rootDir, `${STAGING_PREFIX}refresh-${randomBytes(6).toString('hex')}`);
|
|
2774
|
+
const stagedDir = join(staging, 'new');
|
|
2775
|
+
let sources;
|
|
2776
|
+
try {
|
|
2777
|
+
sources = takes.map((rel) => ({ rel, abs: this.containedBaseline(newHash, rel.split('/')) }));
|
|
2778
|
+
}
|
|
2779
|
+
catch (err) {
|
|
2780
|
+
const blocked = base({ verdict: 'blocked', findings: [this.pathFinding(err, null, `${BASELINE_DIRNAME}/${newHash}`)] });
|
|
2781
|
+
this.reapBaselines();
|
|
2782
|
+
return blocked;
|
|
2783
|
+
}
|
|
2784
|
+
// The park-and-place transaction (`swapStaged`, codex round 6): what the merge REMOVES and what
|
|
2785
|
+
// a take OVERWRITES are both parked by rename before a single placement, so a failure mid-swap
|
|
2786
|
+
// restores every effective file byte-for-byte, the manifest is never committed (the revision
|
|
2787
|
+
// is unchanged) and the unreferenced new baseline is reaped — the old code removed and
|
|
2788
|
+
// overwrote in place, leaving partial content behind a 500.
|
|
2789
|
+
const park = removals.map((rel) => ({ rel, abs: destinations.get(rel) }));
|
|
2790
|
+
for (const rel of takes) {
|
|
2791
|
+
const dest = destinations.get(rel);
|
|
2792
|
+
if (lstatOrNull(dest)?.isFile() === true)
|
|
2793
|
+
park.push({ rel, abs: dest });
|
|
2794
|
+
}
|
|
2795
|
+
const place = takes.map((rel) => ({ src: join(stagedDir, ...rel.split('/')), dest: destinations.get(rel) }));
|
|
2796
|
+
let swap;
|
|
2797
|
+
try {
|
|
2798
|
+
copyFiles(sources, stagedDir);
|
|
2799
|
+
this.restoreOwnerWrite(place.map((p) => p.src)); // the new baseline is locked; the operator's copies are theirs to edit
|
|
2800
|
+
// Re-walked and re-hashed against the new bundle's digests (v3.5 §3).
|
|
2801
|
+
const stagedProblem = this.stagedTreeProblem(stagedDir, new Map(takes.map((rel) => [rel, newFiles.get(rel) ?? ''])));
|
|
2802
|
+
if (stagedProblem !== null) {
|
|
2803
|
+
removeTreeForce(staging);
|
|
2804
|
+
const blocked = base({ verdict: 'blocked', findings: [this.stagedTreeFinding(null, `${BASELINE_DIRNAME}/${newHash}`, stagedProblem)] });
|
|
2805
|
+
this.reapBaselines();
|
|
2806
|
+
return blocked;
|
|
2807
|
+
}
|
|
2808
|
+
swap = this.swapStaged(null, `${BASELINE_DIRNAME}/${newHash}`, staging, park, place);
|
|
2809
|
+
}
|
|
2810
|
+
catch (err) {
|
|
2811
|
+
removeTreeForce(staging);
|
|
2812
|
+
this.reapBaselines();
|
|
2813
|
+
// A source swapped for a link between the walk and the copy is refused, never copied (v3.5 §3).
|
|
2814
|
+
if (err instanceof EntrySwappedError || err instanceof SymlinkComponentError) {
|
|
2815
|
+
return base({ verdict: 'blocked', findings: [this.stagedTreeFinding(null, `${BASELINE_DIRNAME}/${newHash}`, err.message)] });
|
|
2816
|
+
}
|
|
2817
|
+
throw err;
|
|
2818
|
+
}
|
|
2819
|
+
if ('finding' in swap) {
|
|
2820
|
+
const blocked = base({ verdict: 'blocked', findings: [swap.finding] });
|
|
2821
|
+
this.reapBaselines();
|
|
2822
|
+
return blocked;
|
|
2823
|
+
}
|
|
2824
|
+
// Catalog: register upstream-new dirs, drop skills with NO records left (upstream removed them
|
|
2825
|
+
// and the operator never touched them — a skill the operator deleted files from keeps its
|
|
2826
|
+
// baseline-backed records, stays in the catalog as an override, and blocks publish until reset
|
|
2827
|
+
// or disabled), recompute the rest — all of it the manifest half of the transaction
|
|
2828
|
+
// (`commitSwap`, codex round 7): a failure anywhere up to and including the manifest rename rolls
|
|
2829
|
+
// the content swap back, and the unreferenced new baseline is reaped with it.
|
|
2830
|
+
let added = [];
|
|
2831
|
+
let removed = [];
|
|
2832
|
+
let baselineDirsToRemove = [];
|
|
2833
|
+
const taken = new Set();
|
|
2834
|
+
const kept = new Set();
|
|
2835
|
+
try {
|
|
2836
|
+
this.commitSwap(swap.handle, m, () => {
|
|
2837
|
+
const before = new Set(Object.keys(m.skills));
|
|
2838
|
+
for (const [name, entry] of Object.entries(m.skills)) {
|
|
2839
|
+
if (existsSync(this.pluginPath(`${entry.dir}/SKILL.md`)))
|
|
2840
|
+
continue;
|
|
2841
|
+
if (this.ownRecords(m, entry.dir).length > 0)
|
|
2842
|
+
continue;
|
|
2843
|
+
delete m.skills[name];
|
|
2844
|
+
}
|
|
2845
|
+
// The new baseline is the one `recomputeDerived` / `hasBaselineDir` read from now on.
|
|
2846
|
+
m.baselines[newHash] = this.baselineRecord(source);
|
|
2847
|
+
m.baseline = newHash;
|
|
2848
|
+
// The previous baseline's record leaves in THIS commit when no generation on disk references
|
|
2849
|
+
// it (codex round 9: one mutation, one revision); its directory goes once the commit landed.
|
|
2850
|
+
baselineDirsToRemove = this.pruneBaselineRecords(m, this.generationsOnDisk()).dirs;
|
|
2851
|
+
findings.push(...this.rebuildCatalog(m).map((f) => ({ ...f, severity: 'warning' })));
|
|
2852
|
+
for (const [name, entry] of Object.entries(m.skills)) {
|
|
2853
|
+
if (conflicts.has(name))
|
|
2854
|
+
entry.conflict = true;
|
|
2855
|
+
else if (entry.upgradeAvailable)
|
|
2856
|
+
conflicts.add(name);
|
|
2857
|
+
}
|
|
2858
|
+
const after = new Set(Object.keys(m.skills));
|
|
2859
|
+
added = [...after].filter((n) => !before.has(n)).sort();
|
|
2860
|
+
removed = [...before].filter((n) => !after.has(n)).sort();
|
|
2861
|
+
const dirOf = new Map(Object.entries(m.skills).map(([name, e]) => [e.dir, name]));
|
|
2862
|
+
const skillOfRel = (rel) => {
|
|
2863
|
+
const owner = owningSkillDir(rel, new Set(dirOf.keys()));
|
|
2864
|
+
return owner === null ? null : (dirOf.get(owner) ?? null);
|
|
2865
|
+
};
|
|
2866
|
+
for (const rel of touched) {
|
|
2867
|
+
const name = skillOfRel(rel);
|
|
2868
|
+
if (name === null || added.includes(name))
|
|
2869
|
+
continue;
|
|
2870
|
+
if (m.files[rel]?.conflict === true)
|
|
2871
|
+
kept.add(name);
|
|
2872
|
+
else
|
|
2873
|
+
taken.add(name);
|
|
2874
|
+
}
|
|
2875
|
+
for (const [name, entry] of Object.entries(m.skills)) {
|
|
2876
|
+
if (entry.provenance !== 'shipped' && !added.includes(name) && !taken.has(name))
|
|
2877
|
+
kept.add(name);
|
|
2878
|
+
}
|
|
2879
|
+
for (const name of conflicts) {
|
|
2880
|
+
const entry = m.skills[name];
|
|
2881
|
+
if (entry === undefined)
|
|
2882
|
+
continue;
|
|
2883
|
+
findings.push(finding('refresh-conflict', 'warning', "both the operator and upstream changed this skill (a deletion by the operator counts); the operator's content is kept and the upstream side is readable as ?side=baseline — reset takes upstream wholesale", `${name} (${entry.dir}) has conflicting files`, { skill: name }));
|
|
2884
|
+
}
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
catch (err) {
|
|
2888
|
+
this.reapBaselines(); // the content is back; the new capture nothing references goes with the failed commit
|
|
2889
|
+
throw err;
|
|
2890
|
+
}
|
|
2891
|
+
// The previous baseline's directory goes only when no snapshot on disk still links its `.venv` /
|
|
2892
|
+
// records it — its record already left in the commit above.
|
|
2893
|
+
for (const dir of baselineDirsToRemove)
|
|
2894
|
+
removeTreeForce(dir);
|
|
2895
|
+
return base({
|
|
2896
|
+
verdict: verdictOf(findings),
|
|
2897
|
+
findings,
|
|
2898
|
+
revision: m.revision,
|
|
2899
|
+
taken: [...taken].sort(),
|
|
2900
|
+
kept: [...kept].sort(),
|
|
2901
|
+
added,
|
|
2902
|
+
removed,
|
|
2903
|
+
conflicts: [...conflicts].sort(),
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
// ── Publish ───────────────────────────────────────────────────────────────────────────────
|
|
2907
|
+
/** PURE dry run of the publish validation: nothing is persisted, the revision does not move. */
|
|
2908
|
+
analyze() {
|
|
2909
|
+
const m = this.manifest();
|
|
2910
|
+
const v = this.validate(m);
|
|
2911
|
+
return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* ONE publish at a time (module header): a concurrent call is refused with
|
|
2915
|
+
* `SkillsPublishInFlightError` (the route's 409) rather than queued — the caller's revision would
|
|
2916
|
+
* be stale by the time a queued publish ran.
|
|
2917
|
+
*/
|
|
2918
|
+
async publish(expectedRevision) {
|
|
2919
|
+
if (this.publishInFlight !== null)
|
|
2920
|
+
throw new SkillsPublishInFlightError(this.revision());
|
|
2921
|
+
const run = this.publishSerialized(expectedRevision);
|
|
2922
|
+
this.publishInFlight = run;
|
|
2923
|
+
try {
|
|
2924
|
+
return await run;
|
|
2925
|
+
}
|
|
2926
|
+
finally {
|
|
2927
|
+
this.publishInFlight = null;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
/** The root's identity a publish binds to: the configured path AND what it resolves to. */
|
|
2931
|
+
bindRoot() {
|
|
2932
|
+
return { root: this.rootDir, real: realpathSync(this.rootDir) };
|
|
2933
|
+
}
|
|
2934
|
+
/** After EVERY await: the root must still be the very directory the operation started on. */
|
|
2935
|
+
assertRootUnchanged(bound, revision) {
|
|
2936
|
+
let real;
|
|
2937
|
+
try {
|
|
2938
|
+
real = realpathSync(this.rootDir);
|
|
2939
|
+
}
|
|
2940
|
+
catch {
|
|
2941
|
+
real = null;
|
|
2942
|
+
}
|
|
2943
|
+
if (real === null)
|
|
2944
|
+
throw new SkillsRootChangedError(bound.root, 'vanished', revision);
|
|
2945
|
+
if (real !== bound.real)
|
|
2946
|
+
throw new SkillsRootChangedError(bound.root, `moved (its canonical path is now ${real}, it was ${bound.real})`, revision);
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Bind the root, CAS, provision the baseline env (awaited), re-check the root identity and the
|
|
2950
|
+
* CAS, validate the whole tree (a failed provisioning is a blocking `venv-failed`), then write
|
|
2951
|
+
* `snapshots/<gen>/` (staging + rename, never over an existing generation) with the generated
|
|
2952
|
+
* views, LOCK it read-only, COMMIT the manifest (venv state included), flip `current`, and reap
|
|
2953
|
+
* generations / baselines nothing references. A `blocked` verdict persists nothing — the caller's
|
|
2954
|
+
* revision stays valid. Everything after the one await is synchronous: no interleaving.
|
|
2955
|
+
*/
|
|
2956
|
+
async publishSerialized(expectedRevision) {
|
|
2957
|
+
// Refuse a root that changed identity (codex round 4) or a symlinked storage ancestor (codex
|
|
2958
|
+
// round 3) BEFORE anything is provisioned, staged or copied: a `snapshots -> /outside` would
|
|
2959
|
+
// take the first copy out of the store.
|
|
2960
|
+
this.assertRootIdentity();
|
|
2961
|
+
try {
|
|
2962
|
+
this.assertStorageAncestorsClean();
|
|
2963
|
+
}
|
|
2964
|
+
catch (err) {
|
|
2965
|
+
if (err instanceof SymlinkComponentError)
|
|
2966
|
+
throw new SkillsPublishError(`a storage directory is a symlink (${err.message}) — publish refused before writing anything`);
|
|
2967
|
+
throw err;
|
|
2968
|
+
}
|
|
2969
|
+
const bound = this.bindRoot();
|
|
2970
|
+
const pre = this.manifest();
|
|
2971
|
+
this.assertRevision(pre, expectedRevision);
|
|
2972
|
+
// The baseline must be the bundle its name claims BEFORE anything is provisioned in it (codex
|
|
2973
|
+
// round 7): a corrupt baseline is `baseline-corrupt`, blocking — nothing provisioned, nothing
|
|
2974
|
+
// written. `validate` re-derives the same hash AFTER the provisioner ran.
|
|
2975
|
+
const preProblem = this.baselineProblem(pre.baseline);
|
|
2976
|
+
if (preProblem !== null) {
|
|
2977
|
+
return { verdict: 'blocked', findings: [this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${pre.baseline}`, preProblem)], revision: pre.revision, snapshot: null };
|
|
2978
|
+
}
|
|
2979
|
+
// Provisioning FIRST (it may take minutes): a snapshot never links an env still being written.
|
|
2980
|
+
const venv = await this.ensureVenv(pre.baseline);
|
|
2981
|
+
// The world may have moved while uv ran: the root must be the same directory, the CAS re-checked.
|
|
2982
|
+
this.assertRootUnchanged(bound, this.isSeeded() ? this.revision() : expectedRevision);
|
|
2983
|
+
const m = this.manifest();
|
|
2984
|
+
this.assertRevision(m, expectedRevision);
|
|
2985
|
+
const v = this.validate(m);
|
|
2986
|
+
if (venv === 'failed') {
|
|
2987
|
+
v.findings.push(finding('venv-failed', 'blocking', "the baseline's shared read-only Python environment is REQUIRED (the bundle carries a pyproject.toml; skills `uv run` from the plugin root): a snapshot without it hands every worker a plugin whose scripts cannot start. Install uv, fix the sync error the log names, and publish again", `uv sync did not produce ${baselineVenvDir(this.baselineDir(m.baseline))} (see the daemon log)`, {}));
|
|
2988
|
+
}
|
|
2989
|
+
if (verdictOf(v.findings) === 'blocked') {
|
|
2990
|
+
return { verdict: 'blocked', findings: v.findings, revision: m.revision, snapshot: null };
|
|
2991
|
+
}
|
|
2992
|
+
const gen = this.nextGeneration(m);
|
|
2993
|
+
const views = this.viewFiles(v);
|
|
2994
|
+
const allFiles = sortedRels([...v.snapshotFiles, ...views.files]);
|
|
2995
|
+
// The shared per-baseline env is linked ONLY when it exists (a Windows junction needs its
|
|
2996
|
+
// target; a dangling link would let a worker's `uv run` create the env THROUGH it into the
|
|
2997
|
+
// baseline). The link is part of the content hash — path + link text — so `current`
|
|
2998
|
+
// verification sees it added, removed or re-pointed (codex round 5). `snapshot.json.venv`
|
|
2999
|
+
// carries the state either way.
|
|
3000
|
+
const venvDir = baselineVenvDir(this.baselineDir(m.baseline));
|
|
3001
|
+
const venvLink = venv === 'synced' && existsSync(venvDir) ? this.venvLinkText(m.baseline) : null;
|
|
3002
|
+
// The hash covers the directories the file set IMPLIES (codex round 9): the staged tree and every
|
|
3003
|
+
// later verification hash the directories they WALK, so an extra directory — empty or not — is a
|
|
3004
|
+
// mismatch, never an invisible passenger.
|
|
3005
|
+
const contentHash = hashTree(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
|
|
3006
|
+
const snapshots = this.snapshotsDir();
|
|
3007
|
+
mkdirSync(snapshots, { recursive: true });
|
|
3008
|
+
this.sweepStaging(snapshots);
|
|
3009
|
+
const staging = join(snapshots, `${STAGING_PREFIX}${randomBytes(6).toString('hex')}`);
|
|
3010
|
+
copyFiles(allFiles, staging);
|
|
3011
|
+
const record = m.baselines[m.baseline];
|
|
3012
|
+
const snapshot = {
|
|
3013
|
+
gen,
|
|
3014
|
+
contentHash,
|
|
3015
|
+
gardenSource: {
|
|
3016
|
+
kind: record?.source.kind ?? 'directory',
|
|
3017
|
+
path: record?.source.path ?? '',
|
|
3018
|
+
plugin_version: record?.plugin_version ?? '',
|
|
3019
|
+
baseline: m.baseline,
|
|
3020
|
+
},
|
|
3021
|
+
venv,
|
|
3022
|
+
skills: v.enabledSkills
|
|
3023
|
+
.map(({ name, entry }) => ({
|
|
3024
|
+
name,
|
|
3025
|
+
dir: entry.dir,
|
|
3026
|
+
kind: entry.kind,
|
|
3027
|
+
core: entry.core,
|
|
3028
|
+
portable: entry.portable,
|
|
3029
|
+
nested: isNestedSkillDir(entry.dir),
|
|
3030
|
+
}))
|
|
3031
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
3032
|
+
views: { copilot: { dir: COPILOT_VIEW_REL, skills: views.copilotSkills } },
|
|
3033
|
+
};
|
|
3034
|
+
// The exact bytes of `snapshot.json` are what `manifest.published.snapshotHash` authenticates
|
|
3035
|
+
// (codex round 7): the metadata is excluded from the content hash, so the crew-owned manifest is
|
|
3036
|
+
// what makes its claims (kind, core, portable, nested, the view membership) trustworthy at verify.
|
|
3037
|
+
const snapshotText = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
3038
|
+
writeFileAtomic(join(staging, SNAPSHOT_MANIFEST_FILENAME), snapshotText);
|
|
3039
|
+
if (venvLink !== null)
|
|
3040
|
+
this.symlink(venvLink.text, join(staging, VENV_LINKNAME), venvLink.absTarget);
|
|
3041
|
+
// Re-walk (lstat) and re-hash the staged generation AFTER the copy and BEFORE the rename (design
|
|
3042
|
+
// v3.5 §3): files and the one permitted link must hash to the content it was copied from.
|
|
3043
|
+
const stagedHash = this.snapshotHash(walkTree(staging));
|
|
3044
|
+
if (stagedHash !== contentHash) {
|
|
3045
|
+
removeTreeForce(staging);
|
|
3046
|
+
throw new SkillsPublishError(`the staged generation hashes to ${stagedHash}, not to the content it was copied from (${contentHash}) — modified between copy and rename; nothing published`);
|
|
3047
|
+
}
|
|
3048
|
+
const dest = this.snapshotDir(gen);
|
|
3049
|
+
if (this.entryExists(dest)) {
|
|
3050
|
+
removeTreeForce(staging);
|
|
3051
|
+
throw new SkillsPublishError(`${dest} already exists — a published generation is never removed or overwritten`);
|
|
3052
|
+
}
|
|
3053
|
+
renameSync(staging, dest);
|
|
3054
|
+
// Immutable by contract, and now by mode bits: a published generation is locked read-only
|
|
3055
|
+
// (verification by hash stays the authority — the lock is what makes an accidental edit fail).
|
|
3056
|
+
try {
|
|
3057
|
+
makeTreeReadOnly(dest);
|
|
3058
|
+
}
|
|
3059
|
+
catch (err) {
|
|
3060
|
+
removeTreeForce(dest);
|
|
3061
|
+
throw new SkillsPublishError(`could not lock ${dest} read-only: ${err instanceof Error ? err.message : String(err)}`);
|
|
3062
|
+
}
|
|
3063
|
+
// Manifest FIRST, then `current`: a crash in between leaves a committed generation `ensureReady`
|
|
3064
|
+
// finishes flipping to; the reverse order would leave a live `current` the manifest disowns.
|
|
3065
|
+
const included = new Set(v.snapshotFiles.map((f) => f.rel));
|
|
3066
|
+
for (const [rel, r] of Object.entries(m.files)) {
|
|
3067
|
+
if (included.has(rel))
|
|
3068
|
+
r.lastPublishedHash = r.effectiveHash;
|
|
3069
|
+
}
|
|
3070
|
+
if (record !== undefined)
|
|
3071
|
+
record.venv = venv; // provisioning state rides the SUCCESSFUL publish only
|
|
3072
|
+
m.published = { gen, contentHash, at: this.now(), snapshotHash: sha256Hex(snapshotText) };
|
|
3073
|
+
// Retention is decided BEFORE the commit and rides it (codex round 9): the generations this
|
|
3074
|
+
// publish retires, and the baseline records nothing will reference once they are gone, leave the
|
|
3075
|
+
// manifest in this very commit — one mutation, one revision — and their directories are removed
|
|
3076
|
+
// only after it landed (the new generation is on disk already, so it counts as live).
|
|
3077
|
+
const retiredGens = this.generationsToReap(gen);
|
|
3078
|
+
const prune = this.pruneBaselineRecords(m, this.generationsOnDisk().filter((g) => !retiredGens.includes(g)));
|
|
3079
|
+
try {
|
|
3080
|
+
this.commit(m);
|
|
3081
|
+
}
|
|
3082
|
+
catch (err) {
|
|
3083
|
+
// No manifest, no generation (codex round 7): a generation the manifest never came to own is
|
|
3084
|
+
// removed rather than left for later publishes to skip over.
|
|
3085
|
+
removeTreeForce(dest);
|
|
3086
|
+
throw err;
|
|
3087
|
+
}
|
|
3088
|
+
this.flipCurrent(gen);
|
|
3089
|
+
this.live.published(gen);
|
|
3090
|
+
for (const g of retiredGens)
|
|
3091
|
+
removeTreeForce(this.snapshotDir(g)); // locked read-only at publish
|
|
3092
|
+
for (const dir of prune.dirs)
|
|
3093
|
+
removeTreeForce(dir);
|
|
3094
|
+
return {
|
|
3095
|
+
verdict: verdictOf(v.findings),
|
|
3096
|
+
findings: v.findings,
|
|
3097
|
+
revision: m.revision,
|
|
3098
|
+
// The REAL path — the same spelling `currentSnapshot()` answers and the engine is handed.
|
|
3099
|
+
snapshot: { gen, path: realpathSync(dest), contentHash, skills: snapshot.skills.length },
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* The generated delivery views (design v3.2 §2/§4) as snapshot-relative file records over the
|
|
3104
|
+
* EFFECTIVE bytes the validation admitted — today the copilot view only: every enabled, PORTABLE
|
|
3105
|
+
* skill's own files (nested subtrees excluded — they are their own skills) under
|
|
3106
|
+
* `views/copilot/.github/skills/<frontmatter name>/`. A non-portable skill needs the plugin root,
|
|
3107
|
+
* the snapshot cwd or a sibling link, none of which a flat `.github/skills` layout provides.
|
|
3108
|
+
*/
|
|
3109
|
+
viewFiles(v) {
|
|
3110
|
+
const files = [];
|
|
3111
|
+
const copilotSkills = [];
|
|
3112
|
+
const byRel = new Map(v.snapshotFiles.map((f) => [f.rel, f]));
|
|
3113
|
+
const dirs = new Set(v.enabledSkills.map(({ entry }) => entry.dir));
|
|
3114
|
+
for (const { name, entry } of v.enabledSkills) {
|
|
3115
|
+
if (!entry.portable)
|
|
3116
|
+
continue;
|
|
3117
|
+
// The persisted key becomes ONE path segment of the view. Validated here again, independently
|
|
3118
|
+
// of the manifest parse (codex round 4): the generator joins only a name it checked itself.
|
|
3119
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
3120
|
+
throw new SkillsPublishError(`skill name ${JSON.stringify(name)} is not a safe path segment — the copilot view cannot lay it out`);
|
|
3121
|
+
}
|
|
3122
|
+
copilotSkills.push(name);
|
|
3123
|
+
const prefix = `${entry.dir}/`;
|
|
3124
|
+
for (const [rel, f] of byRel) {
|
|
3125
|
+
if (!rel.startsWith(prefix) || owningSkillDir(rel, dirs) !== entry.dir)
|
|
3126
|
+
continue;
|
|
3127
|
+
files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${rel.slice(prefix.length)}`, abs: f.abs });
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return { files: sortedRels(files), copilotSkills: copilotSkills.sort() };
|
|
3131
|
+
}
|
|
3132
|
+
/** The next generation: one past the highest that EXISTS (on disk or in the manifest) — never a reuse. */
|
|
3133
|
+
nextGeneration(m) {
|
|
3134
|
+
return Math.max(m.published?.gen ?? 0, ...this.generationsOnDisk()) + 1;
|
|
3135
|
+
}
|
|
3136
|
+
entryExists(path) {
|
|
3137
|
+
try {
|
|
3138
|
+
lstatSync(path);
|
|
3139
|
+
return true;
|
|
3140
|
+
}
|
|
3141
|
+
catch (err) {
|
|
3142
|
+
if (errnoCode(err) === 'ENOENT')
|
|
3143
|
+
return false;
|
|
3144
|
+
throw err;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* `current -> snapshots/<gen>`, flipped atomically: the link is created INSIDE `snapshots/` under
|
|
3149
|
+
* `.tmp-current-<hex>` — a name core's launch-time fence classifies (`snapshots/.tmp-*` is a
|
|
3150
|
+
* denied child, and v3.3 §1's listing of `snapshots/` accepts `.staging-*` / `.tmp-*` entries) —
|
|
3151
|
+
* and `rename()`d over `current` on the same filesystem, so NO unclassified child ever appears
|
|
3152
|
+
* under `skills/` (core#399 round 4: the former `skills/current.tmp-<hex>` would have refused a
|
|
3153
|
+
* launch that listed `skills/` in that window). The link's RELATIVE target (`snapshots/<gen>`) is
|
|
3154
|
+
* spelled for its FINAL location; while it sits in `snapshots/` it dangles and nothing reads it
|
|
3155
|
+
* there — a torn flip leaves a `.tmp-current-*` the next publish's staging sweep removes.
|
|
3156
|
+
*/
|
|
3157
|
+
flipCurrent(gen) {
|
|
3158
|
+
const link = this.currentLink();
|
|
3159
|
+
const tmp = join(this.snapshotsDir(), `${CURRENT_TMP_PREFIX}${randomBytes(6).toString('hex')}`);
|
|
3160
|
+
this.symlink(posix.join(SNAPSHOTS_DIRNAME, generationDirName(gen)), tmp, this.snapshotDir(gen));
|
|
3161
|
+
renameSync(tmp, link);
|
|
3162
|
+
}
|
|
3163
|
+
/** A directory symlink: the given link text on POSIX (relative); Windows junctions need the absolute target (and it must exist). */
|
|
3164
|
+
symlink(linkText, linkPath, absTarget) {
|
|
3165
|
+
if (process.platform === 'win32')
|
|
3166
|
+
symlinkSync(absTarget, linkPath, 'junction');
|
|
3167
|
+
else
|
|
3168
|
+
symlinkSync(linkText, linkPath);
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Remove every generation older than the newest `KEEP_GENERATIONS` — never the current one, and
|
|
3172
|
+
* never one a live session still pins (it is reaped by the release that frees it).
|
|
3173
|
+
*/
|
|
3174
|
+
reapGenerations(currentGen) {
|
|
3175
|
+
for (const gen of this.generationsToReap(currentGen))
|
|
3176
|
+
removeTreeForce(this.snapshotDir(gen)); // locked read-only at publish
|
|
3177
|
+
}
|
|
3178
|
+
/** The generations `reapGenerations(currentGen)` would remove: beyond the newest `KEEP_GENERATIONS`, never the current one, never a pinned one. */
|
|
3179
|
+
generationsToReap(currentGen) {
|
|
3180
|
+
const pinned = this.live.pinned();
|
|
3181
|
+
return this.generationsOnDisk()
|
|
3182
|
+
.filter((g) => g !== currentGen)
|
|
3183
|
+
.sort((a, b) => b - a)
|
|
3184
|
+
.slice(KEEP_GENERATIONS - 1)
|
|
3185
|
+
.filter((g) => !pinned.has(g));
|
|
3186
|
+
}
|
|
3187
|
+
/**
|
|
3188
|
+
* Drop, IN MEMORY, every baseline record that neither `m.baseline` nor any of `liveGens` (the
|
|
3189
|
+
* generations that will remain on disk) references — and every record whose directory is gone.
|
|
3190
|
+
* The caller commits `m` (a publish's or refresh's OWN commit, or the standalone reap's) and only
|
|
3191
|
+
* THEN removes the directories answered here (codex round 9): the record change rides a validated
|
|
3192
|
+
* commit with the revision advanced, a client never sees two manifests at one revision, and a
|
|
3193
|
+
* failed commit removes nothing.
|
|
3194
|
+
*/
|
|
3195
|
+
pruneBaselineRecords(m, liveGens) {
|
|
3196
|
+
const referenced = new Set([m.baseline]);
|
|
3197
|
+
for (const gen of liveGens) {
|
|
3198
|
+
const parsed = this.parseSnapshotManifest(this.snapshotDir(gen));
|
|
3199
|
+
if (typeof parsed !== 'string')
|
|
3200
|
+
referenced.add(parsed.gardenSource.baseline);
|
|
3201
|
+
}
|
|
3202
|
+
const parent = join(this.rootDir, BASELINE_DIRNAME);
|
|
3203
|
+
let entries = [];
|
|
3204
|
+
try {
|
|
3205
|
+
entries = readdirSync(parent);
|
|
3206
|
+
}
|
|
3207
|
+
catch (err) {
|
|
3208
|
+
if (errnoCode(err) !== 'ENOENT')
|
|
3209
|
+
throw err;
|
|
3210
|
+
}
|
|
3211
|
+
const dirs = [];
|
|
3212
|
+
let changed = false;
|
|
3213
|
+
for (const e of entries) {
|
|
3214
|
+
if (e.startsWith('.') || referenced.has(e))
|
|
3215
|
+
continue;
|
|
3216
|
+
dirs.push(join(parent, e));
|
|
3217
|
+
if (m.baselines[e] !== undefined) {
|
|
3218
|
+
delete m.baselines[e];
|
|
3219
|
+
changed = true;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
for (const hash of Object.keys(m.baselines)) {
|
|
3223
|
+
if (hash !== m.baseline && !existsSync(join(parent, hash))) {
|
|
3224
|
+
delete m.baselines[hash];
|
|
3225
|
+
changed = true;
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
return { changed, dirs };
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Remove every baseline dir (and its record) that neither the manifest nor ANY generation on
|
|
3232
|
+
* disk references — a retained snapshot keeps the `.venv` it links alive. Dropping the records is
|
|
3233
|
+
* a COMMITTED, revision-advancing mutation (the validated `manifest.json.tmp-…` → rename path,
|
|
3234
|
+
* codex round 9), landed BEFORE any directory is removed. Answers the revision that commit
|
|
3235
|
+
* produced, or `null` when no record changed (nothing to reap, or an unseeded root) — directories
|
|
3236
|
+
* with no record left are still removed in that case.
|
|
3237
|
+
*/
|
|
3238
|
+
reapBaselines() {
|
|
3239
|
+
if (!this.isSeeded())
|
|
3240
|
+
return null;
|
|
3241
|
+
const m = this.manifest();
|
|
3242
|
+
const prune = this.pruneBaselineRecords(m, this.generationsOnDisk());
|
|
3243
|
+
// Dropping a baseline record is a MUTATION like any other (codex round 9): it goes through the
|
|
3244
|
+
// validated `manifest.json.tmp-…` → rename commit with the revision ADVANCED — a client never sees
|
|
3245
|
+
// two different manifests at one revision, and a stale `expectedRevision` after a reap is the 409
|
|
3246
|
+
// it should be. The commit lands BEFORE any directory is removed, so a failed commit removes
|
|
3247
|
+
// nothing (there is nothing to roll back); the removals that follow are of directories no
|
|
3248
|
+
// generation and no record references. (A publish or refresh folds this into its OWN commit —
|
|
3249
|
+
// `pruneBaselineRecords` — so one mutation stays one revision; this standalone form serves the
|
|
3250
|
+
// event-driven reap of a generation a run stopped pinning.)
|
|
3251
|
+
let committed = null;
|
|
3252
|
+
if (prune.changed) {
|
|
3253
|
+
this.commit(m);
|
|
3254
|
+
committed = m.revision;
|
|
3255
|
+
}
|
|
3256
|
+
for (const dir of prune.dirs)
|
|
3257
|
+
removeTreeForce(dir);
|
|
3258
|
+
return committed;
|
|
3259
|
+
}
|
|
3260
|
+
/** The generations present on disk, ascending. */
|
|
3261
|
+
generationsOnDisk() {
|
|
3262
|
+
if (!existsSync(this.snapshotsDir()))
|
|
3263
|
+
return [];
|
|
3264
|
+
return readdirSync(this.snapshotsDir())
|
|
3265
|
+
.filter((e) => GENERATION_DIR_RE.test(e) && lstatSync(join(this.snapshotsDir(), e)).isDirectory())
|
|
3266
|
+
.map((e) => Number(e))
|
|
3267
|
+
.sort((a, b) => a - b);
|
|
3268
|
+
}
|
|
3269
|
+
/** The baseline hashes present on disk (diagnostics + tests). */
|
|
3270
|
+
baselinesOnDisk() {
|
|
3271
|
+
const parent = join(this.rootDir, BASELINE_DIRNAME);
|
|
3272
|
+
if (!existsSync(parent))
|
|
3273
|
+
return [];
|
|
3274
|
+
return readdirSync(parent)
|
|
3275
|
+
.filter((e) => !e.startsWith('.') && lstatSync(join(parent, e)).isDirectory())
|
|
3276
|
+
.sort();
|
|
3277
|
+
}
|
|
3278
|
+
/** The 2xx `blocked` finding for a plugin source whose designated entry is a symlink (seed/refresh; codex round 6). */
|
|
3279
|
+
sourceSymlinkFinding(err) {
|
|
3280
|
+
return finding('path-invalid', 'blocking', 'the plugin source is copied INTO the skills root and handed to every worker; a symlink among its designated files or directories would copy whatever the link reaches — the ingestion is refused by name and nothing was copied (the source root itself may be reached through a link; its contents may not)', err.message, { file: err.entry });
|
|
3281
|
+
}
|
|
3282
|
+
/**
|
|
3283
|
+
* Whole-tree validation (v3 §API refs — an ESCAPE blocking, a MISSING target a warning per design
|
|
3284
|
+
* v3.4 §1 —, §5 core closure, §6 nested ownership, the bundle closure as the ONE allowlist —
|
|
3285
|
+
* codex round 6). Mutates `m` IN MEMORY for bookkeeping only (drift into the file records,
|
|
3286
|
+
* derived fields, the core closure) — the caller decides whether that is persisted (publish
|
|
3287
|
+
* commits it; analyze and a blocked publish drop it). Never touches `effective/`.
|
|
3288
|
+
*/
|
|
3289
|
+
validate(m) {
|
|
3290
|
+
const findings = [];
|
|
3291
|
+
// The baseline the records point at must be the bundle its name claims (codex round 7): reset
|
|
3292
|
+
// restores from it, `?side=baseline` reads it, the env is provisioned in it — and publish calls
|
|
3293
|
+
// this AFTER the provisioner ran, so a provisioner that wrote outside `.venv` is caught here.
|
|
3294
|
+
const baselineProblem = this.baselineProblem(m.baseline);
|
|
3295
|
+
if (baselineProblem !== null)
|
|
3296
|
+
findings.push(this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${m.baseline}`, baselineProblem));
|
|
3297
|
+
const scan = this.scanEffective();
|
|
3298
|
+
const scanned = scan.files;
|
|
3299
|
+
const onDisk = new Map(scanned.map((f) => [f.rel, f]));
|
|
3300
|
+
// Direct filesystem edits: detected by hash, reported, recorded — never silently trusted.
|
|
3301
|
+
const drift = [];
|
|
3302
|
+
for (const f of scanned) {
|
|
3303
|
+
const record = m.files[f.rel];
|
|
3304
|
+
if (record === undefined) {
|
|
3305
|
+
m.files[f.rel] = {
|
|
3306
|
+
baselineHash: this.baselineHashOf(m, f.rel),
|
|
3307
|
+
effectiveHash: f.sha,
|
|
3308
|
+
lastPublishedHash: null,
|
|
3309
|
+
conflict: false,
|
|
3310
|
+
};
|
|
3311
|
+
drift.push(f.rel);
|
|
3312
|
+
}
|
|
3313
|
+
else if (record.effectiveHash !== f.sha) {
|
|
3314
|
+
record.effectiveHash = f.sha;
|
|
3315
|
+
drift.push(f.rel);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
for (const [rel, record] of Object.entries(m.files)) {
|
|
3319
|
+
if (onDisk.has(rel) || record.effectiveHash === null)
|
|
3320
|
+
continue;
|
|
3321
|
+
if (record.baselineHash === null)
|
|
3322
|
+
delete m.files[rel];
|
|
3323
|
+
else
|
|
3324
|
+
record.effectiveHash = null;
|
|
3325
|
+
drift.push(rel);
|
|
3326
|
+
}
|
|
3327
|
+
if (drift.length > 0) {
|
|
3328
|
+
const shown = drift.sort().slice(0, DRIFT_LIST_CAP);
|
|
3329
|
+
findings.push(finding('fs-drift', 'warning', 'these files changed on disk outside the API since the manifest last saw them; the hashes are updated and the content is published as found — review it', `${drift.length} file${drift.length === 1 ? '' : 's'} drifted: ${shown.join(', ')}${drift.length > shown.length ? ', …' : ''}`));
|
|
3330
|
+
}
|
|
3331
|
+
// Catalog shape: every SKILL.md on disk is registered; every entry has its SKILL.md.
|
|
3332
|
+
const diskDirs = skillDirsOf(scanned);
|
|
3333
|
+
const registered = new Map(Object.entries(m.skills).map(([name, e]) => [e.dir, name]));
|
|
3334
|
+
for (const dir of [...diskDirs].sort()) {
|
|
3335
|
+
if (registered.has(dir))
|
|
3336
|
+
continue;
|
|
3337
|
+
const owner = owningSkillDir(dir, new Set(registered.keys()));
|
|
3338
|
+
findings.push(finding('unregistered-skill', 'blocking', 'a SKILL.md appeared on disk that the manifest never registered — a nested skill created outside the API, or a dir whose path-derived name another skill already holds; register it with POST /skills or remove it', `${dir}/SKILL.md is unregistered${owner === null ? '' : ` (inside ${registered.get(owner) ?? owner})`}`, { file: `${dir}/SKILL.md` }));
|
|
3339
|
+
}
|
|
3340
|
+
// Derived fields are recomputed CONTAINED: a skill whose dir (or a file in it) crosses a symlink
|
|
3341
|
+
// is skipped, never read through, and BLOCKS here by name (codex round 5).
|
|
3342
|
+
findings.push(...this.recomputeDerived(m));
|
|
3343
|
+
// EVERY entry of effective/ is classified (codex round 9): a symlink ANYWHERE under it is refused
|
|
3344
|
+
// by name — there is no permitted link there (the store never follows one, and a snapshot would
|
|
3345
|
+
// otherwise copy whatever it reaches) — and so is a node that is neither a file nor a directory.
|
|
3346
|
+
// Empty directories are visible to the walk (`scan.dirs`); a snapshot is a file set, so they are
|
|
3347
|
+
// not carried, but nothing is invisible. An entry the containment recompute already refused by
|
|
3348
|
+
// the same path (a skill dir that IS a link) is not reported twice. The entries BENEATH a pruned
|
|
3349
|
+
// directory (`.venv`, `node_modules`, `__pycache__`) are classified too (codex round 10): such a
|
|
3350
|
+
// directory is not bundle content — nothing beneath it is copied or hashed — so a link or a
|
|
3351
|
+
// special node inside one is refused by name with that reason; the store's provisioned env lives
|
|
3352
|
+
// under baseline/<hash>/.venv and is linked from the snapshot root, never held under effective/.
|
|
3353
|
+
const alreadyRefused = new Set(findings.filter((f) => f.kind === 'path-invalid').map((f) => f.file));
|
|
3354
|
+
const ownerNameOf = (rel) => {
|
|
3355
|
+
const owner = owningSkillDir(rel, new Set(registered.keys()));
|
|
3356
|
+
return owner === null ? null : (registered.get(owner) ?? null);
|
|
3357
|
+
};
|
|
3358
|
+
const prunedNames = [...SKIP_DIR_NAMES].sort().join(', ');
|
|
3359
|
+
const prunedLinkReason = (prunedBy) => `a pruned directory (${prunedNames}) is not bundle content — nothing beneath one is copied into a snapshot or hashed — and holds no links: every entry beneath one is still classified, and a symlink there is refused by name${posix.basename(prunedBy) === VENV_LINKNAME ? "; provisioned environments live under baseline/, not the editable root (publish links the snapshot's .venv to baseline/<hash>/.venv)" : ''}`;
|
|
3360
|
+
for (const e of scan.links) {
|
|
3361
|
+
if (alreadyRefused.has(e.rel))
|
|
3362
|
+
continue;
|
|
3363
|
+
findings.push(e.pruned === null
|
|
3364
|
+
? finding('path-invalid', 'blocking', 'the skills root carries no symlinks — there is no permitted link under effective/ (no-follow everywhere, design v3 §API): a link would make a snapshot copy whatever it reaches on the worker host, so every entry is classified and a link is refused by name, never skipped', `${e.rel} is a symlink -> ${e.target ?? ''}`, { skill: ownerNameOf(e.rel), file: e.rel })
|
|
3365
|
+
: finding('path-invalid', 'blocking', prunedLinkReason(e.pruned), `${e.rel} is a symlink -> ${e.target ?? ''} inside the pruned directory ${e.pruned}`, {
|
|
3366
|
+
skill: ownerNameOf(e.rel),
|
|
3367
|
+
file: e.rel,
|
|
3368
|
+
}));
|
|
3369
|
+
}
|
|
3370
|
+
for (const e of scan.others) {
|
|
3371
|
+
findings.push(e.pruned === null
|
|
3372
|
+
? finding('path-invalid', 'blocking', 'only regular files and directories live under effective/: a socket, fifo or device node cannot be copied into a snapshot and is refused by name', `${e.rel} is neither a regular file nor a directory`, { skill: ownerNameOf(e.rel), file: e.rel })
|
|
3373
|
+
: finding('path-invalid', 'blocking', `a pruned directory (${prunedNames}) is classified like the rest of effective/ — nothing beneath one is copied or hashed, but what it holds is judged: a socket, fifo or device node inside one is refused by name`, `${e.rel} is neither a regular file nor a directory (inside the pruned directory ${e.pruned})`, { skill: ownerNameOf(e.rel), file: e.rel }));
|
|
3374
|
+
}
|
|
3375
|
+
const catalogMd = new Map();
|
|
3376
|
+
for (const [name, entry] of Object.entries(m.skills)) {
|
|
3377
|
+
const rel = `${entry.dir}/SKILL.md`;
|
|
3378
|
+
const f = onDisk.get(rel);
|
|
3379
|
+
if (f !== undefined)
|
|
3380
|
+
catalogMd.set(name, readFileNoFollow(f.abs).toString('utf8'));
|
|
3381
|
+
}
|
|
3382
|
+
// The core closure must be COMPLETE: a registered ref or a mandate naming no catalog skill is
|
|
3383
|
+
// a phase the engine would refuse to launch — blocking here, not a warning (v3 §3/§5).
|
|
3384
|
+
const closure = coreClosure(this.registeredRefs(), catalogMd);
|
|
3385
|
+
for (const ref of closure.missing) {
|
|
3386
|
+
findings.push(finding('core-missing', 'blocking', 'a registered workflow names this skill as a phase skill_ref, but no skill by that name is in the catalog; a run dispatching that phase would find no skill in the snapshot and the engine refuses the launch', `skill_ref ${ref} names no catalog skill`, { skill: ref, against: { name: ref, core: true } }));
|
|
3387
|
+
}
|
|
3388
|
+
for (const a of closure.absentMandates) {
|
|
3389
|
+
findings.push(finding('core-missing', 'blocking', 'a core-by-reference skill mandates this skill by qualified name, but no catalog skill answers to it — the method a governed phase depends on would be missing from the snapshot', `${a.from} names ${a.name}, which is not in the catalog`, { skill: a.from, file: `${m.skills[a.from]?.dir ?? ''}/SKILL.md`, line: a.line, against: { name: a.name, core: true } }));
|
|
3390
|
+
}
|
|
3391
|
+
// Per skill.
|
|
3392
|
+
const enabledSkills = [];
|
|
3393
|
+
const dirs = new Set([...registered.keys(), ...diskDirs]); // ownership follows the filesystem (v3 §6)
|
|
3394
|
+
const declaredBy = new Map();
|
|
3395
|
+
for (const [name, entry] of Object.entries(m.skills).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
3396
|
+
const severity = entry.enabled ? 'blocking' : 'warning';
|
|
3397
|
+
const skillMd = catalogMd.get(name);
|
|
3398
|
+
findings.push(...frontmatterGuard(skillMd, name, { isCore: entry.core, file: `${entry.dir}/SKILL.md`, severity }));
|
|
3399
|
+
if (skillMd !== undefined) {
|
|
3400
|
+
const parsed = parseFrontmatter(skillMd);
|
|
3401
|
+
const declared = parsed.ok ? parsed.fields['name'] : undefined;
|
|
3402
|
+
if (declared !== undefined && declared !== '' && declared !== name) {
|
|
3403
|
+
const list = declaredBy.get(declared);
|
|
3404
|
+
if (list === undefined)
|
|
3405
|
+
declaredBy.set(declared, [name]);
|
|
3406
|
+
else
|
|
3407
|
+
list.push(name);
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
if (entry.core && !entry.enabled)
|
|
3411
|
+
findings.push(...compact([coreDisableGuard(name, entry)]));
|
|
3412
|
+
if (entry.enabled)
|
|
3413
|
+
enabledSkills.push({ name, entry });
|
|
3414
|
+
}
|
|
3415
|
+
// Declared names are checked across the FULL catalog, disabled entries included (codex round
|
|
3416
|
+
// 2): a skill — enabled or not — whose frontmatter declares ANOTHER catalog skill's name is a
|
|
3417
|
+
// blocking collision (a disabled one downgraded to a mismatch warning could still shadow a core
|
|
3418
|
+
// skill's identity the moment it is enabled or read by a tool keyed on frontmatter).
|
|
3419
|
+
for (const [declared, holders] of [...declaredBy.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
3420
|
+
const target = m.skills[declared];
|
|
3421
|
+
for (const from of holders) {
|
|
3422
|
+
if (target !== undefined) {
|
|
3423
|
+
findings.push(finding('name-collision', 'blocking', target.core
|
|
3424
|
+
? 'the declared name is a core-by-reference skill\'s identity (a registered workflow dispatches phases to it); a second SKILL.md declaring it would shadow the one the workflow means'
|
|
3425
|
+
: 'the declared name is another catalog skill\'s identity (disabled skills count — the manifest is keyed by name); two definitions of one name cannot both be loaded', `${m.skills[from]?.dir ?? from}/SKILL.md declares ${JSON.stringify(declared)}, the name of the catalog skill at ${target.dir} (${target.enabled ? 'enabled' : 'disabled'})`, { skill: from, file: `${m.skills[from]?.dir ?? ''}/SKILL.md`, against: { name: declared, core: target.core } }));
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
const enabledHolders = holders.filter((h) => m.skills[h]?.enabled === true);
|
|
3429
|
+
if (target === undefined && enabledHolders.length > 1) {
|
|
3430
|
+
findings.push(finding('name-collision', 'blocking', 'two enabled skills declare the same frontmatter name — the plugin would load two definitions of one identity', `${enabledHolders.join(' and ')} both declare ${JSON.stringify(declared)}`, { skill: enabledHolders[0] ?? null, against: { name: declared, core: false } }));
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
// The would-be snapshot: support files + enabled skills' own files (owner computed once per file).
|
|
3434
|
+
const ownerOf = new Map(scanned.map((f) => [f.rel, owningSkillDir(f.rel, dirs)]));
|
|
3435
|
+
const snapshotFiles = [];
|
|
3436
|
+
const filesByOwner = new Map();
|
|
3437
|
+
for (const f of scanned) {
|
|
3438
|
+
const owner = ownerOf.get(f.rel) ?? null;
|
|
3439
|
+
if (owner === null) {
|
|
3440
|
+
if (f.rel.startsWith(`${SKILLS_SUBDIR}/`))
|
|
3441
|
+
continue;
|
|
3442
|
+
// A support file is admitted ONLY inside the bundle closure (the ONE allowlist, bundle.ts;
|
|
3443
|
+
// codex round 6): a file outside it under effective/ got there by a direct filesystem edit
|
|
3444
|
+
// (the API refuses the path) — reported BLOCKING by name, never shipped.
|
|
3445
|
+
if (!inBundleClosure(f.rel)) {
|
|
3446
|
+
findings.push(finding('outside-closure', 'blocking', `a file outside the bundle closure (${BUNDLE_CLOSURE_SPELLING}) sits under effective/ — a direct filesystem edit the API would have refused; a snapshot never ships it: remove it (or move it into the closure) and publish again`, `${f.rel} is outside the bundle closure`, { file: f.rel }));
|
|
3447
|
+
continue;
|
|
3448
|
+
}
|
|
3449
|
+
snapshotFiles.push({ rel: f.rel, abs: f.abs });
|
|
3450
|
+
continue;
|
|
3451
|
+
}
|
|
3452
|
+
const name = registered.get(owner);
|
|
3453
|
+
if (name === undefined || m.skills[name]?.enabled !== true)
|
|
3454
|
+
continue;
|
|
3455
|
+
const record = { rel: f.rel, abs: f.abs };
|
|
3456
|
+
snapshotFiles.push(record);
|
|
3457
|
+
const list = filesByOwner.get(owner);
|
|
3458
|
+
if (list === undefined)
|
|
3459
|
+
filesByOwner.set(owner, [record]);
|
|
3460
|
+
else
|
|
3461
|
+
list.push(record);
|
|
3462
|
+
}
|
|
3463
|
+
const snapshotSet = new Set(snapshotFiles.map((f) => f.rel));
|
|
3464
|
+
/** `ok` / `escape` (climbs out of the plugin root) / `missing` (inside, but not in the snapshot). */
|
|
3465
|
+
const resolves = (p) => {
|
|
3466
|
+
const norm = resolvePluginRootRef(p);
|
|
3467
|
+
if (norm === null)
|
|
3468
|
+
return 'escape';
|
|
3469
|
+
if (norm === '' || snapshotSet.has(norm))
|
|
3470
|
+
return 'ok';
|
|
3471
|
+
const prefix = `${norm}/`;
|
|
3472
|
+
for (const rel of snapshotSet)
|
|
3473
|
+
if (rel.startsWith(prefix))
|
|
3474
|
+
return 'ok';
|
|
3475
|
+
return 'missing';
|
|
3476
|
+
};
|
|
3477
|
+
const whyMissing = (p) => {
|
|
3478
|
+
const norm = resolvePluginRootRef(p) ?? p;
|
|
3479
|
+
const owner = owningSkillDir(norm, dirs);
|
|
3480
|
+
const ownerName = owner === null ? null : registered.get(owner);
|
|
3481
|
+
if (ownerName !== undefined && ownerName !== null && m.skills[ownerName]?.enabled === false) {
|
|
3482
|
+
return ` — it belongs to the DISABLED skill ${ownerName}`;
|
|
3483
|
+
}
|
|
3484
|
+
return onDisk.has(norm) || [...onDisk.keys()].some((rel) => rel.startsWith(`${norm}/`))
|
|
3485
|
+
? ' — present in effective/ but outside the snapshot'
|
|
3486
|
+
: ' — no such file in effective/';
|
|
3487
|
+
};
|
|
3488
|
+
for (const { name, entry } of enabledSkills) {
|
|
3489
|
+
for (const f of filesByOwner.get(entry.dir) ?? []) {
|
|
3490
|
+
const buf = readFileNoFollow(f.abs);
|
|
3491
|
+
if (looksBinary(buf))
|
|
3492
|
+
continue;
|
|
3493
|
+
const text = buf.toString('utf8');
|
|
3494
|
+
// Severity follows the TARGET (design v3.4 §1): a reference that ESCAPES the plugin root is a
|
|
3495
|
+
// boundary claim — blocking; one whose target is MISSING inside it is a content bug the
|
|
3496
|
+
// skill's author owns — a warning, published as found (the live 12.32.0 plugin carries 18 of
|
|
3497
|
+
// them; garden's own structural gate treats them as advisory, wicked-garden#1111 tracks them).
|
|
3498
|
+
const anchor = (line) => ({ skill: name, file: f.rel, line });
|
|
3499
|
+
for (const ref of extractPluginRootRefs(text)) {
|
|
3500
|
+
const verdict = resolves(ref.path);
|
|
3501
|
+
if (verdict === 'ok')
|
|
3502
|
+
continue;
|
|
3503
|
+
findings.push(verdict === 'escape'
|
|
3504
|
+
? finding('unresolved-ref', 'blocking', 'a `${CLAUDE_PLUGIN_ROOT}` reference must stay inside the plugin root; one that climbs out of it (`..`) reaches whatever lies beside the snapshot on the worker host — a boundary the snapshot must not cross', `\${CLAUDE_PLUGIN_ROOT}/${ref.path} — escapes the plugin root`, anchor(ref.line))
|
|
3505
|
+
: finding('unresolved-ref', 'warning', "the snapshot is the plugin root workers see; a `${CLAUDE_PLUGIN_ROOT}` reference that names nothing inside it fails at first use — a content bug the skill's author owns (fix it in the editor, or upstream), published as found", `\${CLAUDE_PLUGIN_ROOT}/${ref.path}${whyMissing(ref.path)}`, anchor(ref.line)));
|
|
3506
|
+
}
|
|
3507
|
+
for (const ref of extractRelativeRefs(text)) {
|
|
3508
|
+
const target = resolveRelativeRef(f.rel, ref.path);
|
|
3509
|
+
const verdict = target === null ? 'escape' : resolves(target);
|
|
3510
|
+
if (verdict === 'ok')
|
|
3511
|
+
continue;
|
|
3512
|
+
findings.push(target === null || verdict === 'escape'
|
|
3513
|
+
? finding('unresolved-ref', 'blocking', 'a `../` link must land inside the plugin root; one that climbs out of it reaches whatever lies beside the snapshot on the worker host — a boundary the snapshot must not cross', `${ref.path} — escapes the plugin root`, anchor(ref.line))
|
|
3514
|
+
: finding('unresolved-ref', 'warning', "a `../` link that lands inside the plugin root on nothing the snapshot carries (a file the bundle omits, or a skill that is disabled) is broken for every worker at first use — a content bug the skill's author owns, published as found", `${ref.path}${whyMissing(target)}`, anchor(ref.line)));
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
// The plugin manifest + the runtime catalogs: present, parseable, the right SHAPE (codex round
|
|
3519
|
+
// 2: existence alone let a manifest without `name` and a catalog holding `[]` publish clear).
|
|
3520
|
+
const parseJsonObject = (rel, f) => {
|
|
3521
|
+
let parsed;
|
|
3522
|
+
try {
|
|
3523
|
+
parsed = JSON.parse(readFileNoFollow(f.abs).toString('utf8'));
|
|
3524
|
+
}
|
|
3525
|
+
catch (err) {
|
|
3526
|
+
findings.push(finding('catalog-invalid', 'blocking', "Claude Code loads the plugin from its manifest and garden's runtime reads the catalogs beside it as JSON; one that does not parse loads nothing", `${rel}: ${err instanceof Error ? err.message : String(err)}`, { file: rel }));
|
|
3527
|
+
return null;
|
|
3528
|
+
}
|
|
3529
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
3530
|
+
findings.push(finding('catalog-invalid', 'blocking', 'the plugin manifest and the runtime catalogs are JSON OBJECTS (a top-level array or scalar is not a catalog)', `${rel}: top-level value is ${Array.isArray(parsed) ? 'an array' : typeof parsed}`, { file: rel }));
|
|
3531
|
+
return null;
|
|
3532
|
+
}
|
|
3533
|
+
return parsed;
|
|
3534
|
+
};
|
|
3535
|
+
const pluginJson = onDisk.get(PLUGIN_JSON_REL);
|
|
3536
|
+
if (pluginJson === undefined) {
|
|
3537
|
+
findings.push(finding('missing-plugin-manifest', 'blocking', 'a plugin root is its .claude-plugin/plugin.json — Claude Code loads nothing without it', `no ${PLUGIN_JSON_REL} in effective/`, { file: PLUGIN_JSON_REL }));
|
|
3538
|
+
}
|
|
3539
|
+
else {
|
|
3540
|
+
const manifestJson = parseJsonObject(PLUGIN_JSON_REL, pluginJson);
|
|
3541
|
+
if (manifestJson !== null) {
|
|
3542
|
+
const declared = manifestJson['name'];
|
|
3543
|
+
if (typeof declared !== 'string' || declared === '') {
|
|
3544
|
+
findings.push(finding('name-mismatch', 'blocking', `a plugin manifest MUST declare its \`name\` — workers invoke skills as \`${PLUGIN_NAME}:<skill>\`, and Claude Code registers the plugin under it`, `${PLUGIN_JSON_REL} declares no "name" (expected ${JSON.stringify(PLUGIN_NAME)})`, { file: PLUGIN_JSON_REL }));
|
|
3545
|
+
}
|
|
3546
|
+
else if (declared !== PLUGIN_NAME) {
|
|
3547
|
+
findings.push(finding('name-mismatch', 'blocking', `workers invoke skills as \`${PLUGIN_NAME}:<skill>\`; the plugin manifest must keep that name`, `${PLUGIN_JSON_REL} names ${JSON.stringify(declared)}`, { file: PLUGIN_JSON_REL }));
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
for (const rel of REQUIRED_PLUGIN_CATALOGS) {
|
|
3552
|
+
const f = onDisk.get(rel);
|
|
3553
|
+
if (f === undefined) {
|
|
3554
|
+
findings.push(finding('missing-plugin-manifest', 'blocking', "garden's runtime reads the plugin catalogs beside plugin.json (archetypes_v11.py raises without archetypes.json); a snapshot missing one hands every worker a plugin whose runtime cannot start", `no ${rel} in effective/`, { file: rel }));
|
|
3555
|
+
continue;
|
|
3556
|
+
}
|
|
3557
|
+
const catalog = parseJsonObject(rel, f);
|
|
3558
|
+
if (catalog === null)
|
|
3559
|
+
continue;
|
|
3560
|
+
if (rel.endsWith('/archetypes.json')) {
|
|
3561
|
+
const archetypes = catalog['archetypes'];
|
|
3562
|
+
if (typeof archetypes !== 'object' || archetypes === null) {
|
|
3563
|
+
findings.push(finding('catalog-invalid', 'blocking', "garden's archetype detector reads the `archetypes` collection of archetypes.json (archetypes_v11.py load_catalog); a catalog without it is not a catalog", `${rel} has no \`archetypes\` collection`, { file: rel }));
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
if (enabledSkills.length === 0) {
|
|
3568
|
+
findings.push(finding('empty-snapshot', 'blocking', 'a snapshot with no enabled skills would hand every worker an empty plugin', 'no enabled skills', {}));
|
|
3569
|
+
}
|
|
3570
|
+
return { findings, snapshotFiles: sortedRels(snapshotFiles), enabledSkills };
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
/** Re-export for callers that only need to classify the source-missing case. */
|
|
3574
|
+
export { NotAPluginRootError };
|
|
3575
|
+
//# sourceMappingURL=store.js.map
|