Add marker-gated skill targets (gatedTools) and the Meta Muse format - #42
Merged
Merged
Conversation
Meta Muse loads skills from ~/workspace/skills. That directory is too generic to detect by existence, and the entry cannot go in "tools": every released skillinject (v0.2.2 through v0.2.4, and the 902f745 build pilot v1.13.x ships) would then write the unconverted SKILL.md into ~/workspace/skills/pilotctl/ on any host that has the directory, with no gate. New optional manifest key "gatedTools". Released builds decode the manifest with plain json.Unmarshal into a struct without it, so they ignore these rows. A gated tool: - is active only while its requireMarker file exists, and that file must be inside ~/.pilot (the Muse installer creates ~/.pilot/targets/muse). Without the marker nothing under rootDir is read, written or removed, by a tick or by Uninstall; - installs only the entrypoint skill copy (no heartbeat, no plugin); - requires rootDir inside home, skillsDir inside rootDir. All file operations go through os.Root on rootDir, a symlink at the skill file or any directory between rootDir and it is an error row, and writes go through an O_EXCL temp file with a random name; - is refused when it resolves to a regular tool's skill copy. skillFormat "muse" rewrites the entrypoint frontmatter to name: "<entrypoint, - -> _>" plus a one-line quoted description and keeps the body. It is a port of muse_frontmatter in pilot-skills muse/install.sh and produces the same bytes; a test runs the installer's own shell function against it (all 157 pilot-skills SKILL.md files match locally with PILOT_SKILLS_CORPUS). canonicalPath now resolves a not-yet-existing path through its nearest existing ancestor, so collision checks work before files are written. Tests: gated tick/plan/uninstall/offline-cache/disabled, marker and path validation, symlink and planted-temp-link refusal, and a test that the pilot-skills manifest with the new key passes the released parse and checks with no row a released daemon acts on under ~/workspace. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…ntials
Two review findings on the Muse target.
1. A marker proved only that some installer ran. muse/install.sh writes
~/.pilot/targets/muse whatever MUSE_SKILLS_DIR and
PILOT_MUSE_FRONTMATTER say, so on a host where it installed another
agent's skills folder the daemon created
~/workspace/skills/pilotctl/SKILL.md in an unrelated directory, and
after a PILOT_MUSE_FRONTMATTER=0 install the daemon and the installer
rewrote each other's copy on every run.
The marker now has to say what it is for, one key=value per line:
skills_dir=/root/workspace/skills
skill_format=muse
A gated tool is active only when skills_dir is the row's skillsDir
(compared after resolving symlinks) and skill_format its skillFormat
("canonical" for a row that copies SKILL.md unchanged). An empty
marker (a bare touch), one for another folder or format, a symlink or
directory, a file over 4 KiB, a line without '=', or a key set twice
turns nothing on: tick, Plan and Uninstall leave the directory alone.
The row itself is still checked first, so a broken row is an error on
every marked host whatever the marker says.
2. The daemon registers skillinject with a zero Config, so fetches went
through http.DefaultTransport with the proxy credentials from the
daemon's launch environment. The Muse proxy rotates them every few
minutes, and every tick after the first rotation failed with 407.
When Config.HTTPClient is nil, the client is now a
netproxy.RefreshingTransport whose refresh command is the new
Config.ProxyCommand, else $PILOT_PROXY_CMD, else "proxy_cmd" in
~/.pilot/config.json, the settings pilot-daemon itself reads (the
installers save bash -c 'printf %s "${https_proxy:-$HTTPS_PROXY}"'
there on such hosts). The command runs at the start of each tick,
once a minute while it runs, and on a 407, and the refused request is
retried once. PILOT_PROXY / config.json "proxy" set to an off word
disables it. Without a command the client is the plain one, and a
407 reported as a *netproxy.ConnectError (what pilot-daemon's
http.DefaultTransport returns after refreshing its own credentials)
is retried once. The base transport is a copy of DefaultTransport
without its CONNECT hook, so a host hook cannot hide the 407.
Tests: marker attestation (14 rejected shapes, 8 accepted spellings
including symlinked paths, the canonical/muse opt-out sequence, a broken
row under a foreign marker); a Muse-like rotating CONNECT proxy for
ticks across rotations with local-edit repair, a rotation mid-tick
(407 then retry), the command from $PILOT_PROXY_CMD and config.json, a
failing command falling back to the environment with no secret in any
error, the one retry for a host transport's 407, and the command
precedence rules. The new marker tests fail on the previous commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t does not own
si42-proxycmd-flag-only-unparseable-rejection-no-retry: with no proxy
command in sight (pilot-daemon given only the -proxy-cmd flag), fetches use
the process's http.DefaultTransport, and only a well-formed 407 reached its
CONNECT hook (which refreshes the credentials) and got a retry. A rejection
the proxy garbles ("HTTP/1.1 4O7", "malformed HTTP status code", Meta
Muse's form) never reaches that hook, so the tick failed with no retry.
get now retries such an answer once after unreadableReplyRetryDelay (2s),
by when the transport's resolver has usually re-read the credentials in the
background (netproxy starts that from the lookup that failed once its 60s
interval has passed). One retry only: without a refresh it fails after
exactly two attempts.
TestFetch_RetriesUnreadableProxyReplyOnce covers both (background refresh:
success after one rejection; none: two rejections, no loop); it fails with
the retry disabled. The rotating test proxy can now garble its rejection.
Docs: README/CHANGELOG/proxy.go name who supplies the command on sandbox
hosts (release#49 saves proxy_cmd; pilotctl daemon start, pilot-up.sh and
pilot-mcp hand the daemon $PILOT_PROXY_CMD) and the credential-preferring
sandbox command they all use.
go test -race ./... ok; go vet clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 24, 2026
TeoSlayer
added a commit
to TeoSlayer/pilot-skills
that referenced
this pull request
Sep 24, 2026
* inject-manifest: gate Meta Muse under a new gatedTools key
Adds the Muse target for the pilot-daemon skill injector:
"gatedTools": [{ "name": "muse", "rootDir": "~/workspace/skills",
"skillsDir": "~/workspace/skills",
"requireMarker": "~/.pilot/targets/muse",
"skillFormat": "muse" }]
Only skillinject builds with gated-tool support read this key
(pilot-protocol/skillinject#42). They install the entrypoint skill only
on hosts the Muse installer marked with ~/.pilot/targets/muse, rewritten
into the frontmatter shape Muse loads.
Safe for released daemons: every released skillinject decodes the
manifest with plain json.Unmarshal into a struct without the key, so it
is ignored. tests/released-skillinject runs the released module against
this manifest (a Muse-marked home plus ~/.claude): the manifest is
accepted, claude-code is still injected, and nothing under ~/workspace
is written or removed by Tick or Uninstall. A new CI job runs it for
v0.2.2, v0.2.3, the 902f745 build pilot v1.13.x ships, and v0.2.4. As a
"tools" row the same entry makes those releases write the unconverted
SKILL.md into ~/workspace/skills/pilotctl/ on any host with that
directory (checked as a negative control).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* released-skillinject: mark the Muse home the way the gated row requires
skillinject#42 now turns the gated muse row on only when
~/.pilot/targets/muse names the target:
skills_dir=<home>/workspace/skills
skill_format=muse
An empty marker, one for another skills folder (MUSE_SKILLS_DIR), or one
for the canonical frontmatter (PILOT_MUSE_FRONTMATTER=0) leaves it off,
so the daemon never writes an unrelated ~/workspace/skills and never
rewrites a copy the operator asked to keep canonical.
The released-injector test now uses that marker, so it checks that
released builds ignore the row on a host that newer builds would treat
as a Muse target. v0.2.2, v0.2.3, the 902f745 build and v0.2.4 pass.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
What
Adds Meta Muse as a skill-injection target through a new optional manifest key,
gatedTools, without changing what released daemons do.gatedToolsrows are active only while theirrequireMarkerfile exists inside~/.pilotand names the row's target (see "The marker names the target" below). The Muse installer writes~/.pilot/targets/muse. Without a matching marker, nothing underrootDiris read, written or removed, by a tick or byUninstall.Config.HTTPClient(how the daemon registers skillinject), fetches now re-read the proxy credentials with the daemon's refresh command, so ticks keep working after the Muse proxy rotates them (see "Egress proxies that rotate their credentials" below).skillFormat: "muse"rewrites the entrypoint SKILL.md frontmatter toname: "<entrypoint with - replaced by _>"and a one-line quoteddescription. The body is unchanged.Why a new key and not a
toolsrowEvery release reads the manifest with plain
json.Unmarshalinto a struct that has nogatedToolsfield, so the key is dropped. That covers v0.2.2, v0.2.3, v0.2.4, v0.2.5-beta.1, and the902f745build that pilot v1.13.2 through v1.13.10-rc.1 ship.As a
toolsrow, the same entry would be installed by every one of those releases on any host that has~/workspace/skills:~/workspace/skills/pilotctl/, with no marker check;disable allthen deletes that file.The pilot-skills PR proves both: it runs the released modules against the new manifest, plus a negative control with muse moved into
tools.A correction to the brief:
validateManifestPaths/defaultWriteRoots(which rejects a whole manifest if anytoolspath is outside the allowlisted roots) exists only on the unmergedwip/preserve-2026-08-02branch (a72ae88), not in any release.TestGatedManifest_ValidUnderReleasedRuleschecks two things:toolspath still passes that pending allowlist, so landing the hardening later will not reject this manifest. The muserootDiris outside it, which is one more reason the row must stay out oftools.The marker names the target
A marker that only exists proves that some installer ran.
muse/install.sh(pilot-skills#34) can install into any folder (MUSE_SKILLS_DIR) or keep the canonical frontmatter (PILOT_MUSE_FRONTMATTER=0), and at #34's 71789ad ittouches the marker in every case. Review found two results: the daemon created~/workspace/skills/pilotctl/SKILL.mdin an unrelated~/workspace/skillsafter an install into~/.agentx/skills, and after aPILOT_MUSE_FRONTMATTER=0install the daemon and the installer rewrote each other's copy on every run.The marker now has to say what it is for, one
key=valueper line:skills_diris itsskillsDir, compared after resolving symlinks (~/is accepted), andskill_formatis itsskillFormat(canonicalfor a row that copies SKILL.md unchanged).touch), a marker for another folder or format, a symlink or directory, a file over 4 KiB, a line without=, or a key set twice. Tick, Plan and Uninstall then leave the directory alone. Blank lines,#comments and unknown keys are ignored.#34 writes this marker since a366fe8 (with the folder it actually installed to, so an install elsewhere names that folder and this row stays off). The block tested earlier with the real 71789ad installer:
Egress proxies that rotate their credentials
The daemon registers skillinject with
skillinject.Config{}. Fetches therefore went throughhttp.DefaultTransportwith the proxy credentials from the daemon's launch environment. The Muse proxy rotates them every few minutes, so every tick after the first rotation failed with407 Proxy Authentication Required, andSKILL.mdupdates only reached Muse when the installer was rerun.Config.HTTPClientis nil, the client is anetproxy.RefreshingTransport(common v0.5.15). Its refresh command is the newConfig.ProxyCommand, else$PILOT_PROXY_CMD, else"proxy_cmd"in~/.pilot/config.json. These are the settings pilot-daemon reads. On Muse-like hosts every Pilot entry point now supplies one the zeroConfigsees: the official installer (release#49 c36ad9b) savesproxy_cmd,pilotctl daemon start(pilotprotocol#470) and pilot-sandbox'spilot-up.sh(pilot-skills#34 a366fe8, which used to pass only the-proxy-cmdflag) hand the daemon$PILOT_PROXY_CMD, and pilot-mcp#23 does the same. The command isbash -c 'case $https_proxy in *@*) printf %s "$https_proxy";; *) printf %s "${HTTPS_PROXY:-$https_proxy}";; esac'.PILOT_PROXYor config.json"proxy"set to off, none, no, false or direct turns it off.*netproxy.ConnectErroris retried once; that is what pilot-daemon'shttp.DefaultTransportreturns (pilotprotocol#470) after refreshing its own credentials. A garbled rejection (malformed HTTP status code, Meta Muse's form) never reaches that transport's CONNECT hook, so nothing refreshed on it: it is now retried once after 2s, which succeeds when the transport's resolver re-read the credentials in the background meanwhile (netproxy starts that from the failing lookup once its 60s interval has passed). This fallback only matters for a daemon started by hand with the-proxy-cmdflag alone; every Pilot entry point hands the command over where skillinject sees it.DefaultTransportwithout its CONNECT hook, so the daemon's hook cannot hide the 407 from the retry.Safety rules for gated tools (
gated.go)requireMarkermust be inside~/.pilot,rootDirinside home (and not home itself), andskillsDirinsiderootDir. The entrypoint must be a plain name. A row that breaks any of these is an error row, and the other tools are unaffected.os.Rootopened onrootDir, so a symlink cannot take a read, write or removal outside it. A symlink at the skill file, or at any directory betweenrootDirand it, is refused, even one that points insiderootDir.O_EXCLtemp file with a random name and are then renamed. A link planted at the old predictableSKILL.md.tmpname is never followed. This matters because the Muse daemon runs as root and~/workspace/skillsis writable by the agent.Uninstalldeletes the file, and the<entrypoint>/dir when that leaves it empty, only while a matching marker exists. It works offline from the cached manifest, which now keepsgatedTools.Byte parity with the Muse installer
museSkillMDis a port ofmuse_frontmatterin pilot-skillsmuse/install.sh(#34). It has to produce identical bytes; otherwise the daemon rewrites the installer's copy once and the installer puts its own back on every rerun.TestMuseSkillMD_MatchesInstallersources the installer's function (vendored intestdata/muse_frontmatter.sh) and compares both on 24 edge cases: folded, literal, quoted and plain scalars, comments, CRLF, control characters, the 1024-byte cap, UTF-8, and missing or unclosed frontmatter.PILOT_SKILLS_CORPUS=<pilot-skills checkout>it also compares everyskills/*/SKILL.md. All 157 match locally (macOS bash 3.2 + BSD awk). CI runs the edge cases on ubuntu (bash 5).Evidence
go test -race ./...passes. Coverage is 92.0% total;skillformat.gois at 100% and the gated reconcile at 97%.proxyCommandand the fetcher'sget/do/closeare at 100%.Marker tests: 14 rejected marker shapes, 8 accepted spellings (including paths through symlinks), the canonical/muse/canonical opt-out sequence, and a broken row under a foreign marker. The new marker tests fail on 4a56849.
Proxy tests use a Muse-like CONNECT-only proxy with Basic auth and rotating passwords. They cover: ticks across rotations with local-edit repair; a rotation mid-tick (407, then a retry with the new credentials); the command taken from
$PILOT_PROXY_CMDand from config.json; a failing command falling back to the environment, with no secret in any error; exactly one retry for a host transport's 407; and the precedence rules.Rotation E2E. The reviewer's scenario runs against real
raw.githubusercontent.com(pilot-skills d957c6d) through a local proxy that rotates the password every 6s:skillinject.RunwithConfig{}and a 10s interval, and config.jsonproxy_cmdset to the Muse command. A fresh bash sees the current credentials throughBASH_ENV.DENY 407andskillinject tick failed ... Proxy Authentication Requiredat every later tick.ALLOW gen=1,gen=2,gen=4,gen=5, and every tick succeeds. The t=8s local edit is repaired (rewrites=1), and the final file hasname: "pilotctl".Installer E2E with the real deps: bump common to v0.5.10 (auto-cascade v0.2.4-beta.2) #34 installer (71789ad, skills from a d957c6d archive) and this build's Tick/Plan/Uninstall:
MUSE_SKILLS_DIR=~/.agentx/skills: muse skipped;~/workspacestill holds onlytheir-skill.PILOT_MUSE_FRONTMATTER=0: muse skipped; shaa17e8c766257unchanged across ticks.identical/noop(sha379de679fca5), and a local edit isdrifted/rewriteback to it;FRONTMATTER=0givesskill_format=canonicaland muse is skipped;noop, and aFRONTMATTER=0rerun gives skipped again, with no flip-flop;MUSE_SKILLS_DIRelsewhere writes no marker.Earlier local E2E, at 4a56849, where an empty marker was enough. I ran the real installer's skill step (
PILOT_SKILLS_ONLY=1, HOME pointed at a temp dir, skills frommain), then that build against the new pilot-skills manifest:identical/noop(sha379de679fca5unchanged);Uninstallreporteddeletedand removed the emptypilotctl/dir;createwith the same bytes;skippedand nothing was written.The installer E2E above repeats the noop result with the declaring marker.
Round 3 (908afcc)
si42-muse-rotation-fix-inert-without-release49(medium): resolved in the deploy chain, not in code here. Nothing setPILOT_PROXY_CMD/proxy_cmdon a host set up by the live installer plus pilot-up. Now release#49 savesproxy_cmd, pilotctl#470 and pilot-mcp#23 hand$PILOT_PROXY_CMDto the daemon, and pilot-skills#34 a366fe8 hands it$PILOT_PROXY_CMDtoo (instead of the flag only). A daemon with this skillinject can only ship after #470 (it is bumped in pilotprotocol after this tags), so every node that gets it also gets one of those entry points. The deploy order below lists release#49 and #470 first.si42-proxycmd-flag-only-unparseable-rejection-no-retry(low): garbled rejections on a transport skillinject does not own are retried once afterunreadableReplyRetryDelay(2s).TestFetch_RetriesUnreadableProxyReplyOnce: with a background refresh the tick succeeds after one rejection; with none it fails after exactly two (no loop). It fails with the retry disabled. The rotating test proxy can garble its rejections.go test -race ./...ok;go vetclean. (Two files not touched here,service.goandzz_skillinject_test.go, are not gofmt-clean on the branch base either.)E2E, round 3 Muse sim (rotating-credential CONNECT-443-only proxy, root, no systemd): a harness calling
skillinject.Tickat this head (908afcc) with the daemon's zeroConfigexceptManifestURL(pilot-skills#35'sinject-manifest.jsonserved on loopback), on a node set up by the pilot-skills#34 one-shot (a366fe8): the marker #34 now writes (skills_dir=/root/workspace/skills,skill_format=muse, plus a#comment line) activates the row —muse … state=drifted action=rewrite, then afterrmstate=absent action=create, withname: "pilotctl"and a one-line description. (Thedriftedis catalog skew between the two unmerged branches' copies ofskills/pilotctl/SKILL.md.)Also
canonicalPathnow resolves a not-yet-existing path through its nearest existing ancestor (before, it only tried the parent). The gated/regular collision check works on the first tick, and the shared-heartbeat dedup benefits too.[Unreleased]section that shipped as v0.2.4 is relabelled[v0.2.4] - 2026-09-23, and the new entries go in a fresh[Unreleased].Deploy order (all six PRs)
notify-install-sh-syncdispatches to pilot-protocol/website →sync-install-sh.ymlputsinstall.shinto R2pilot-release-assets/install.sh(thepilot-releaseWorker reads R2 per request; no Worker deploy). Check:curl -fsSL https://pilotprotocol.network/install.sh | shasum -a 256→4384f0cb2890177cb87a2e2bc49e1b814d2850434d24c54d1730c9955fbbb671(up to 5 min cache).public/install.sh, same bytes) → the sync workflow's drift step goes green.install.sh matches pilot-protocol/release, merge.v1.13.11on main (release workflow: binaries,checksums.txt, signedlatest.json). v1.13.10 shipped without #470, so until this tag every install gets a daemon without-proxy. Checkpilot-daemon -hlists-proxyand-proxy-cmd.skills.json/setups.jsonandskills/pilotctl/SKILL.md, dispatches the website deploy; the Muse one-shot (raw.githubusercontent.com/TeoSlayer/pilot-skills/main/muse/install.sh) is live at merge.0.3.0(behaviour change; last published 0.2.13): bumppackage.json,package-lock.json,server.json(both fields),.well-known/mcp/server-card.json,src/version.js,src/openclaw-plugin/openclaw.plugin.json,src/openclaw-plugin/evaluate.jsand the pinnedpilotprotocol-mcp@0.2.13strings intest/{hermes-setup,openclaw-plugin,native-harness-setup,harness-config-contracts}.test.js(test/release-contract.test.jslocks them), move[Unreleased]to[0.3.0], push tagv0.3.0→publish.yml(tag must equalv+version; lint + tests; npm via OIDC, MCP Registryserver.json,ghcr.io/pilot-protocol/pilot-mcp).v0.2.5→ pilotprotocol follow-up PRgo get github.com/pilot-protocol/skillinject@v0.2.5 && go mod tidy(GOWORK=off) → next daemon release (v1.13.12; or fold the bump in before tagging v1.13.11).gatedToolsmuse row). Released daemons drop the key (CIreleased-skillinjectv0.2.2–v0.2.4). No manifest signing step:DefaultManifestPublicKeyHexis empty; only hosts that installed~/.pilot/skillinject.pubverifyinject-manifest.json.sig.🤖 Generated with Claude Code