Commit Graph

1 Commits

Author SHA1 Message Date
Michal
d60ad52018 feat(cli): postInstall executor for mcpctl skills sync
Closes the biggest deferred item from PR-5. metadata.postInstall
scripts now actually run when their hash changes, with audit emission
back to mcpd.

Trust model unchanged from the corporate-appliance design: mcpd is
the source of truth, content is reviewed at publish time, the client
just runs. No sandbox, no signing, no consent prompts. The controls
that matter are already on the publishing side (RBAC, audit, reviewer
queue).

What we DO provide is ops hygiene:

- Hard timeout (default 60 s; per-skill override via
  metadata.postInstallTimeoutSec). SIGTERM at the deadline, SIGKILL
  after a 2 s grace.
- Hash pinning. The script's sha256 is recorded in
  ~/.mcpctl/skills-state.json. Re-syncs of an unchanged script are a
  cheap no-op. A re-published "same version, fixed script" still
  triggers re-execution because its hash changed.
- Curated env. PATH/HOME/USER/SHELL inherited; everything else dropped.
  MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT injected so scripts
  have stable context.
- Per-skill install log under ~/.mcpctl/skills/<name>/install.log.
  Bounded at 5 × 256 KB; old entries truncated from the front.
- Audit event back to mcpd (POST /api/v1/audit-events,
  eventKind=skill_postinstall) on every run, including hostname so
  admins can see fleet rollout state. Best-effort — failures are
  warned but never block the sync.
- Path-escape rejection. metadata.postInstall must resolve inside the
  skill bundle; relative paths that try to climb out are refused.
- Auto-chmod 0755 on the script before spawn. Some upstreams ship 0644
  + a shebang and expect a shell to handle it; we always spawn the
  path directly so we need +x.

Failure semantics: on timeout or non-zero exit, the recorded
postInstallHash is NOT updated. Next sync retries. Other skills in
the same sync run continue regardless — postInstall errors are
scoped, not fatal.

## Files

### Added
- src/cli/src/utils/postinstall.ts (~200 LOC)
- src/cli/tests/utils/postinstall.test.ts (~190 LOC, 10 tests covering
  success, env vars, chmod, non-zero exit, timeout via exec sleep,
  path-escape, missing script, log file shape + append-across-runs)

### Edited
- src/cli/src/commands/skills.ts: applyOne now invokes runPostInstall
  + emitPostInstallAudit when metadata.postInstall is set and the
  script hash differs from prior state. New SyncResult fields:
  postInstallsRan, postInstallsSkipped. Summary line surfaces
  "N postInstall ran". --skip-postinstall flag now actually does what
  it says.

## Verification

163 test files / 2171 tests green (up from 2161).

End-to-end on a real machine (after this PR ships and a skill with
metadata.postInstall is published):

```
mcpctl skills sync
# → mcpctl: 1 installed, 1 postInstall ran
cat ~/.mcpctl/skills/<name>/install.log    # see stdout/stderr
mcpctl skills sync                          # idempotent — skipped
```

If the same skill is republished with a fixed script:

```
mcpctl skills sync
# → mcpctl: 1 updated, 1 postInstall ran  (hash changed → rerun)
```

If the script fails (exit 7):

```
mcpctl skills sync
# → mcpctl: 1 updated, 1 errors
mcpctl skills sync   # state.postInstallHash NOT updated → retries
```

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:05:42 +01:00