Skip to content

feat(plugins): declare 7 at launch, detect all 12 (#53) - #65

Merged
scttfrdmn merged 1 commit into
mainfrom
feat/53-plugins
Aug 1, 2026
Merged

feat(plugins): declare 7 at launch, detect all 12 (#53)#65
scttfrdmn merged 1 commit into
mainfrom
feat/53-plugins

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #53.

The issue's framing: "spawn-ts has zero plugin support today … That reads as 'plugins are out of scope for the browser', which is wrong in both directions."

Half of that turned out to be already-solved-but-unreachable. The engine landed in 3da2170src/core/plugins.ts parses the spore:plugin:* provenance tag and validates launch declarations, and src/aws/userdata.ts writes /etc/spawn/plugins.json. But LaunchInput had no plugins field, so spec.plugins could not be set from SpawnClient, the CLI, or the UI, and nothing called detectPlugins(). Both columns of the matrix existed as dead code. This PR is the surface.

Declare at launch (7 of 12)

  • LaunchInput.pluginsLaunchSpec → user-data. Verified against the real generated payload, not just the API: [{"ref":"jupyterlab"},{"ref":"mountpoint-s3","config":{"bucket":"my-data"}}] lands in /etc/spawn/plugins.json, byte-compatible with what spored's loadPluginDeclarations reads.
  • CLI --plugin ref[@version], repeatable, matching Go's pflag StringArray (cmd/launch_flags.go:351).
  • Dashboard launch form: a checkbox per declarable plugin, so the offered set IS the supported set and a rejection can't be reached from the form. The five that aren't offered are named underneath with the reason — an unexplained absence reads as an arbitrary omission, and worse, as "the browser has no plugin support".

Detect (12 of 12)

  • status renders decoded provenance; --plugins asks explicitly.
  • Instance cards get a plugins — line with three distinct styles, so verify=none (a supply-chain finding), verify=signature (clean) and verify=unknown (no data) can't be read alike.
  • spore-sync and tailscale render exactly like the rest. A card built on the install column alone shows nothing when five things are deployed — which is the defect the second column exists to fix.

Two deliberate divergences from Go

A ref that can't be honoured aborts the launch. Go writes any ref into plugins.json and lets a push-dependent plugin park at StatusWaitingForPush on the box (pkg/pluginruntime/runtime.go:62) — a failure invisible from the launch side. Nothing is billed at the point of refusal and the fix is a one-word edit: refusing costs a retry, launching costs an instance that can't do the job it was launched for.

```
$ spawn launch bad --ttl 4h --plugin tailscale --plugin nonesuch
launch: 2 plugins cannot be declared at launch — nothing was launched.
"tailscale" cannot be declared at launch: its local half mints an auth key and pushes it, …
"nonesuch" is not a known launch-declarable plugin. Declarable: cloudwatch-agent, …
```

validateDeclarations still returns rejections rather than throwing — the reason is data a caller may render or aggregate. The policy lives in SpawnClient.launch and the CLI.

The success line says "declared", never "installed". spored installs at boot; whether it worked is only knowable later from the tags. A launch message must not report an unobserved outcome.

Parser fix, not a workaround

parseArgs' flags map is single-value/last-wins, so --plugin a --plugin b would have installed one and dropped the other with no signal. Added ParsedArgs.lists + flagList(); flags[] stays last-wins, so every existing single-value reader is untouched. Both exported for embedders. The DOM has the identical trap — fd.get() returns only the first of several same-named checkboxes — so the form uses fd.getAll().

Honesty rules carried through from #63/#56

  • With no plugin tags, status says nothing (and the card renders no line). The tag is best-effort and is never written for launch-time declarations, so absence means "we don't know", not "no plugins installed". --plugins gets describePluginState's full caveat rather than a bare "none".
  • An unparseable tag value still reports the plugin as deployed — the tag's existence is the evidence.

Verification

503 → 538 tests, 30 files. npm run typecheck clean on both tsconfigs; npm run build clean.

Every new guard was proven against the absent behaviour first. Five mutations produced 21 failures across four suites: last-wins lists (7); silent-filter instead of refusal (4); absence-as-"no plugins installed" + dropped caveat + one CSS class for every verify tier (4); dropped escapeHtml + fd.get for fd.getAll (2); unregistered --plugins boolean + "installed" wording (4).

Found by driving the built CLI, not by the suite: a bare unparseable token was joined into the same comma list as the decoded fields, so docker: version unknown, verify=unknown, garbage-no-kv read as though the garbage had been parsed. It now gets its own unrecognised in its tag: line; a forward-compatible key=value pair still goes inline, because that one is provenance.

Also drove the dashboard in real Chromium via a new dev-only demo/direct/plugins.harness.html — the tags can only be written by the Go CLI or spored, so no path through the app itself reaches that state. Confirmed the seven checkboxes and the note, a 3-of-3 declared launch, absence before tags and presence after, and the three verify tiers computing to green / yellow / grey; then looked at the rendered screenshots.

Not ported, deliberately

spawn plugin install on a running instance (SSH tunnel to :7777 — no SSH in a browser), and the local halves of spore-sync / rclone / tailscale / github-actions-runner / globus-personal-endpoint. docs/data-movement.md already classified those; its validateDeclarations example now matches the shipped behaviour instead of implying a partial launch.

The plugin *engine* landed in 3da2170 — src/core/plugins.ts parses the
spore:plugin:* provenance tag and validates launch declarations, and
src/aws/userdata.ts writes /etc/spawn/plugins.json. None of it was
reachable. LaunchInput had no `plugins` field, so `spec.plugins` could
not be set from SpawnClient, the CLI, or the UI, and nothing called
detectPlugins(). Both halves of the issue's two-column matrix existed as
dead code. This wires the surface.

Declare at launch (7 of 12)
  - LaunchInput.plugins → LaunchSpec → user-data, verified against the
    real payload: [{"ref":"jupyterlab"},{"ref":"mountpoint-s3",…}].
  - CLI `--plugin ref[@Version]`, repeatable, matching Go's pflag
    StringArray (cmd/launch_flags.go:351).
  - Dashboard launch form: a checkbox per declarable plugin, so the
    offered set IS the supported set and a rejection can't be reached
    from the form. The five that aren't offered are named with the
    reason underneath — an unexplained absence reads as an arbitrary
    omission, and worse, as "the browser has no plugin support".

Detect (12 of 12)
  - `status` renders decoded provenance; `--plugins` asks explicitly.
  - Instance cards get a `plugins —` line with three distinct styles, so
    verify=none (a finding), verify=signature (clean) and
    verify=unknown (no data) can't be read alike.
  - spore-sync and tailscale render exactly like the rest. A card built
    on the install column alone shows nothing when five things are
    deployed, which is the defect the second column exists to fix.

Two deliberate divergences from Go
  - A ref that can't be honoured aborts the launch. Go writes any ref
    into plugins.json and lets a push-dependent plugin park at
    StatusWaitingForPush on the box (pkg/pluginruntime/runtime.go:62) —
    a failure invisible from the launch side. Nothing is billed at the
    point of refusal and the fix is a one-word edit: refusing costs a
    retry, launching costs an instance that can't do its job.
  - The success line says "declared", never "installed". spored installs
    at boot; whether it worked is only knowable later from the tags.

Parser fix, not a workaround
  parseArgs' flags map is single-value/last-wins, so `--plugin a
  --plugin b` would have installed one and dropped the other with no
  signal. Added ParsedArgs.lists + flagList(); flags[] stays last-wins,
  so every existing single-value reader is untouched. Both exported.

Honesty rules carried through from #63/#56
  - With no plugin tags, status says NOTHING (and the card renders no
    line). The tag is best-effort and is never written for launch-time
    declarations, so absence means "we don't know". --plugins gets
    describePluginState's full caveat rather than a bare "none".
  - An unparseable tag value still reports the plugin as deployed — the
    tag's existence is the evidence.

451 → 538 tests. Every new guard was proven against the absent
behaviour first: five mutations (last-wins lists, silent-filter instead
of refusal, absence-as-"no plugins installed", one class for every
verify tier, fd.get instead of getAll, unregistered --plugins) produced
21 failures across four suites.

Found by driving the built CLI, not by the suite: a bare unparseable
token was joined into the same comma list as the decoded fields, so
"verify=unknown, garbage-no-kv" read as though the garbage had been
parsed. It now gets its own line; a forward-compatible key=value pair
still goes inline, because that one IS provenance.

Also drove the dashboard in real Chromium (new dev-only
demo/direct/plugins.harness.html — the tags can only be written by the
Go CLI or spored, so no path through the app itself reaches that state)
and looked at the rendered card: the three verify tiers compute to
green / yellow / grey and the picker reads legibly.

Not ported, deliberately: `spawn plugin install` on a running instance
(SSH tunnel to :7777 — no SSH in a browser), and the local halves of
spore-sync / rclone / tailscale / github-actions-runner /
globus-personal-endpoint. docs/data-movement.md already classified
those; its `validateDeclarations` example now matches the shipped
behaviour instead of implying a partial launch.

Closes #53
@scttfrdmn
scttfrdmn merged commit 8b47e43 into main Aug 1, 2026
1 check passed
@scttfrdmn
scttfrdmn deleted the feat/53-plugins branch August 1, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugins: the browser can detect all 12 and declare 7 at launch — neither is implemented

1 participant