fix: pack-scoped skill ids, dangling-link repair, session-scoped skills via SDK plugins - #336
Merged
Merged
Conversation
…ls via SDK plugins Three changes to how a pack's skills reach a session, found while auditing why the only pipeline run to date died at its sixth phase with "Unknown command: /adversary". **Pack-scoped registry ids.** The daemon-wide skill/gate registries were keyed by bare id, so installing a second pack that also declared `review`, `ship`, or `tests_pass` replaced the first pack's entries last-wins — the boot log announced it — and a run from the first pack drove the second pack's skill. Packs now register under `<packId>/<id>` (new `scoped.ts`); the engine, the skill phase kind, and create-time validation resolve the run's own pack entry first and fall back to the bare id, which is how built-in gates (`always`, `manual`) and explicit-`phases` plans keep working unchanged. Phase defs keep the ids as authored, so CLI output and the Pack Browser are untouched. **Dangling-link repair.** An earlier loader linked registry skills from a temp clone under `/tmp`; after the cache moved to `~/.codeoid/packs/` those links pointed at nothing. `existsSync` is false for a dangling symlink, so `#linkSkills` tried to create it, hit EEXIST, warned, and left the skill broken on every install and trust thereafter. A dangling link is now unlinked and relinked; a real directory or a live link is still never touched. **Session-scoped skills.** Until now a trusted pack's skills reached a session one way: symlinked into `~/.claude/skills`, visible to every Claude Code session on the machine. That is the wrong scope for a machine whose `~/.claude` is owned by something else. New config `pipeline.skillScope` (`global` default, `session` opt-in): under `session` nothing is linked; PackService synthesizes a Claude-Code-plugin-shaped dir per registry (`~/.codeoid/plugins/<name>/` with a manifest and `skills → <cache>/skills`), `resolveActivation()` carries it as `skillsPluginDir`, Session passes it per turn as `TurnOpts.pluginDirs`, and the Claude provider hands it to the SDK's `plugins` option — inside the same rebuild guard as the prompt append and skill grants, since a pipeline swaps activations between phases. The read sandbox and the skill-command grant scan include the plugin tier so `!`…`` substitutions keep working. Same trust rule as linking. Verified against the real binary: a local plugin's skills resolve both bare (`/spec`) and namespaced (`/<registry>:spec`), and a plugin whose `skills` entry is a symlink is discovered — the exact layout synthesized here — so pack `command:` values need no rewrite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yash Datta <yd2590@columbia.edu>
Collaborator
Author
|
pr-shepherd summary (iteration 1) — no changes pushed.
Ready for the owner to review and merge. |
Audit of the first commit (an independent review pass plus a trace of every registry consumer and activation site) found one hole and two gaps; all three are closed here, with tests. **The plugin layout dropped the symlink guard the global path has.** `#ensureSkillPlugin` linked the WHOLE `<cache>/skills` directory into the plugin, while `#linkSkills` deliberately refuses a `skills/<name>` that is itself a symlink. Reproduced: a registry shipping `skills/evil -> /some/dir` was discovered as a skill, and — because `skillSandboxDirs` widens the read sandbox to the real parent of every entry — its target's parent landed in `additionalDirectories`, and its `!`…`` substitutions in `allowedTools`. The session scope was strictly weaker than the global one. The plugin's `skills/` is now a real directory holding one symlink per real skill directory, under the same lstat guard; stale links (a skill removed on `registry refresh`, or one pointing outside the cache) are pruned on the next activation, a whole-dir link from the pre-release layout is replaced, and a real directory an operator placed there is left alone. **Collaboration adoption lost the plugin.** The compiled goal pack was built from `adoption.subagents` but not `adoption.skillsPluginDir`, and `roleChildPosture` never carried it, so under `session` scope a `--pack` collaboration's orchestrator and role-children had none of the pack's slash skills while a global-scope adoption saw them all. Both now carry it (children on spawn; the resume path already re-derives no adoption, unchanged here). **Explicit-`phases` plans could borrow a pack's entries by bare id.** Packs no longer register bare ids, so an explicit plan naming `spec` or `tests_pass` now fails at create — that borrowing was the same cross-pack leakage from the other side, and the web UI and CLI always send `pack`. Made deliberate rather than accidental: the create error lists the qualified ids that exist (`"org-dev/spec"`), which an explicit plan can name directly; CHANGELOG and docs say so. Also: `CODEOID_PIPELINE_SKILL_SCOPE` env override for sandbox/CI images; `pipeline.pack.list` reports the daemon's live `skillScope` and `codeoid pack list` prints it; docs record the bare-name collision rule (a user- or project-tier skill wins bare `/spec`, verified against the binary; `/<registry>:spec` names the pack's). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yash Datta <yd2590@columbia.edu>
jalbrethsen-highflame
approved these changes
Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Auditing why the only pipeline run to date died at phase six (
Unknown command: /adversary) surfaced three defects in how a pack's skills reach a session:review,ship, ortests_passreplaced the first pack's entries last-wins (the boot log saysskill "review" already registered — overwriting), and a run from the first pack drove the second pack's skill./tmp. After the cache moved,existsSyncwas false for those links, so#linkSkillstried to create them, hitEEXIST, warned, and left the skill broken on every install and trust thereafter.~/.claude/skills, where every Claude Code session on the machine discovered them. On a machine whose~/.claudeis owned by something else (an org bundle linked by hand), that is the wrong scope. Subagents, constitution, and role were already session-scoped; skills were the exception.Change
scoped.ts: packs register skills and gates under<packId>/<id>. The engine, the skill phase kind, and create-time validation resolve the run's own pack entry first and fall back to the bare id, so built-in gates (always,manual) and directly registered entries behave exactly as before. Phase defs keep the ids as authored; CLI output and the Pack Browser are unchanged.#linkSkillsrepairs a dangling symlink in place. A real directory or a live link is still never touched.pipeline.skillScope(globaldefault,sessionopt-in; envCODEOID_PIPELINE_SKILL_SCOPE). Undersessionnothing is linked;PackServicesynthesizes~/.codeoid/plugins/<registry>/(plugin manifest + a realskills/holding one symlink per real skill directory, under the same lstat guard as global linking),resolveActivation()carries it asskillsPluginDir,Sessionpasses it per turn asTurnOpts.pluginDirs, and the Claude provider hands it to the SDKpluginsoption inside the existing rebuild guard. Collaboration adoption carries it to the orchestrator and role-children. The read sandbox and skill-command grant scan include the plugin tier. Same trust rule as linking. Non-Claude backends ignore it, as they already ignore pack subagents.pipeline.pack.listreports the daemon's liveskillScope(optional, additive field);codeoid pack listprints it.docs/pack-loading.md§3a; CHANGELOG under Unreleased (Added + Fixed).Behaviour change to note
An explicit-
phasesplan (wirepipeline.createwithphases, notpack) can no longer borrow an installed pack's skill or gate by its bare id. That borrowing was the same cross-pack leakage from the other side. Name the entry by its qualified id (skill: "org-dev/spec") or create withpack; the create error now lists the qualified ids that exist. The web UI and CLI always sendpack, so only direct API/SDK clients are affected.Audit (second commit)
An independent adversarial review of the first commit plus a trace of every registry consumer and activation site found, and this PR now fixes:
<cache>/skillsdir, dropping the symlink guard the global path has. Reproduced: a registryskills/evil -> <dir>was discovered as a skill and its target's parent landed inadditionalDirectories. Now per-entry links under the same guard, with pruning of stale links.skillsPluginDirfor the orchestrator and role-children. Now carried throughcompileGoalPack's activation androleChildPosture.phasesbehaviour change above was undocumented and the error was a flat "unknown". Now documented, with a helpful create error and a test.skillScopeon the pack list, a stale comment, and coverage for the prune / operator-dir / hostile-entry branches.Verification
bun x tsc --noEmiton the CLI, protocol, and core packages: clean.bun run lint: clean.bun run test: 2507 pass, 12 skip, 0 fail (11 new tests across pack-service, pack, skill-kind, collaboration, config, and the Claude provider helpers)./spec) and namespaced (/<registry>:spec); a plugin whoseskills/holds per-entry symlinks is discovered; on a bare-name collision the project/user-tier skill wins and the namespaced form selects the plugin's.Operator notes
"skillScope": "session"underpipelinein~/.codeoid/config.jsononce the daemon runs this code (an older daemon strips unknown keys on its next config write), then remove any existing~/.claude/skills/*links that point into~/.codeoid/packs/.🤖 Generated with Claude Code