Skip to content

Give each tier-1 sandbox its own marketplaces and plugins, through the wrapper's --settings - #244

Merged
larsvikb merged 2 commits into
mainfrom
per-container-plugins
Nov 1, 2026
Merged

larsvikb merged 2 commits into
mainfrom
per-container-plugins

Conversation

@larsvikb

@larsvikb larsvikb commented Nov 1, 2026

Copy link
Copy Markdown
Owner

What

Task 130, plan 27, with the operator's answer "warn". Marketplaces, plugins and their note were written into the config volume every tier-1 sandbox shares, so a sibling's boot rewrote them under running sandboxes, and one with no marketplaces wiped them. They are now each container's own, in /etc, beside the gateway pointer. Two commits; the first stands alone.

Refuse a marketplaces directory that overlaps the workspace. sc_marketplaces takes the canonical workspace and refuses a marketplaces directory that is it, lies inside it, or holds it: the workspace is read-write, so the plugin tree was writable through /workspace whatever the :ro said. A / workspace (only with ALLOW_UNSAFE_WORKSPACE=1) holds everything, so that override does not lift this. It is a new launch refusal, and it sees only this launch's workspace, not a sibling's.

Per-container launch settings.

  • Rendered by the user, installed by root. The root phase runs tier-setup.sh --render-launch-settings through gosu, checks the output is exactly one JSON document matching LAUNCH_SHAPE (the two keys, non-empty objects, every plugin true, marketplace names wholly in [A-Za-z0-9._-]), and installs it root-owned as /etc/claude-code/launch-settings.json. Root opens no marketplace file. An empty render installs nothing; a failing or malformed one warns and launches without plugins, never aborting the boot.
  • One managed note. The marketplace bullet joins the refs bullet in /etc/claude-code/CLAUDE.md, derived from the installed JSON. The user phase no longer writes plugin keys into $CONFIG_DIR/settings.json or appends to $CONFIG_DIR/CLAUDE.md; the "neither mounted nor known" advisory moved there.
  • The wrapper prepends [--settings F] [--mcp-config G] --strict-mcp-config (appending fails after a subcommand). A caller's own --settings replaces the launch file, and the wrapper prints one stderr line saying the host's plugins are not loaded.
  • Docs. DESIGN.md "Plugin marketplaces" (per-container, render-as-user, the scope consequence, what stays shared), NOTES.md (the measurements), README caveats, stale comments.

Measured on claude 2.1.310: --settings is command-line scope, so enabledPlugins: false in a project's or the user's settings, or plugin disable, no longer turns off a plugin the file enables (Claude Code prints "--settings keeps it on"). The operator approved this: a repo is not meant to override the host allowlist. The file wins per plugin, not wholesale: a plugin enabled in the shared user settings still loads beside it, so a sibling's in-session /plugin install reaches this sandbox's new sessions until the next boot. The note, DESIGN.md and README say both.

Checked

  • In the container: make lint, make consistency and make test pass at both commits.
  • MarketplaceWorkspaceOverlapTests runs sc_marketplaces on temp trees: equal, inside, holding, a symlink to the workspace and a / workspace are refused; a sibling and a name-prefix neighbour mount.
  • New tests/test_launch_settings.py: the render on temp trees (layouts, nameless, broken, duplicate and out-of-alphabet manifests, unmounted marketplaces); LAUNCH_SHAPE; the note; the advisory; the root block run as written with gosu/install stubbed (nothing installed for {}, a failing render, or a malformed one including two documents and a name with a trailing newline); the wrapper over every file combination and six caller argvs. Placement tests pin root and user phase duties.
  • 24 mutants of the root block, shape, render, note, advisory and wrapper, all killed; the review's 30 reran, with one survivor fixed and two equivalent.
  • claude 2.1.310 in the container, scratch config and fixture marketplace (NOTES.md): loading with and without the file, never written back, last --settings wins, project/local/user false and plugin disable against it, the subcommands behind the prepends, two launch files over one config, a user-scope enable merging beside the file, the real wrapper in front of the real binary.
  • On the maintainer's host, two concurrent sandboxes from this branch: A with the usual plugins runs its skill and carries the marketplace bullet; B with an empty marketplaces dir and SANDBOX_PLUGINS=nope@nowhere prints the advisory, has the short note and no skill; A's claude restarted after B's boot still has the skill (on main, B's boot rewrites the shared enabledPlugins). An old-image sandbox loses its appended note when a new-image one boots, until rebuilt.
  • Not run: the interactive /plugin menu.

Generated with Claude Code

larsvikb and others added 2 commits November 1, 2026 12:41
## What

Task 130, plan 27's overlap refusal, split out as the first commit so it
can go in a PR of its own.

`sc_marketplaces` takes the canonical workspace as a required argument,
passed from `run-claude-sandbox.sh`. It refuses a marketplaces directory
that is the workspace, lies inside it, or holds it. The workspace is
mounted read-write, so in each case the plugin tree was writable through
`/workspace` whatever the `:ro` on `/marketplaces` said, and the agent
could edit a skill or hook that loads on the next boot. A workspace of
`/` (only reachable with `ALLOW_UNSAFE_WORKSPACE=1`) holds everything,
so that override does not take this refusal with it.

This is a new launch refusal: a launch whose workspace is itself the
marketplace checkout, which starts today, now stops with the reason.

It sees only this launch's workspace. A sibling sandbox whose workspace
holds this launch's marketplaces can still write them; DESIGN.md
"Plugin marketplaces" says so.

## Checked

- In the container: `make lint`, `make consistency` and `make test`
  pass.
- `MarketplaceWorkspaceOverlapTests` runs `sc_marketplaces` under bash
  on temp trees: equal, inside, holding, a symlink to the workspace and
  a `/` workspace are refused; a sibling directory and one sharing only
  a name prefix (`app-marketplaces` beside `app`) mount; a missing
  workspace argument fails.
- Not run: a real launch, which needs Docker on the host.

Co-Authored-By: Claude <noreply@anthropic.com>
…e wrapper's --settings

## What

Task 130, to plan 27 and the operator's answer "warn". Marketplaces,
plugins and their note were written into the config volume every tier-1
sandbox shares, so a sibling's boot rewrote them under running sandboxes,
and one with no marketplaces wiped them. They are now the container's
own, in /etc, beside the gateway pointer.

- **Rendered by the user, installed by root.** `tier-setup.sh` gains a
  `--render-launch-settings` mode. The root phase runs it through
  `gosu`, checks that the output is exactly one JSON document matching
  `LAUNCH_SHAPE` (the two keys, each a non-empty object, every plugin
  `true`, marketplace names wholly in `[A-Za-z0-9._-]`), and installs it root-owned 0644 as
  `/etc/claude-code/launch-settings.json`, cleared first. Root opens no
  marketplace file. Nothing is installed for an empty render, and a render
  that fails or prints anything else warns and launches without plugins;
  it never aborts the boot.
- **The render** keeps today's rules, and now skips a manifest whose
  `name` falls outside `[A-Za-z0-9._-]`, since names become settings keys
  and managed-note text.
- **One managed note.** The marketplace bullet joins the refs bullet in
  `/etc/claude-code/CLAUDE.md` and is derived from the installed JSON. It
  now says that a `/plugin disable` of a host-enabled plugin, or
  `enabledPlugins: false` in a project's or the user's settings, has no
  effect, and that all of this holds only for `claude` without a
  `--settings` of the caller's own. Plugins enabled with no mounted
  marketplace get the same bullet without the marketplace line.
- **The user phase** no longer merges keys into `$CONFIG_DIR/settings.json`
  or appends to `$CONFIG_DIR/CLAUDE.md`. The "neither mounted nor known"
  advisory moved here (`plugin_advisory`), reading the installed file and
  the volume's `known_marketplaces.json`.
- **The wrapper** builds one exec by prepends:
  `[--settings F] [--mcp-config G] --strict-mcp-config "$@"`. Appending
  fails after a subcommand. A caller's own `--settings` or `--settings=`
  replaces the launch file, and the wrapper prints one stderr line saying
  the host's plugins and marketplaces are not loaded (the operator's
  "warn").
- **Docs.** DESIGN.md "Plugin marketplaces" gains the per-container and
  render-as-user decisions, the scope consequence, the reworded reason
  for not using `claude plugin marketplace add`, and what stays shared.
  NOTES.md gets the measurements. README gains three caveats. Stale
  comments are updated in the entrypoint, `sandbox-lib.sh`, the
  Dockerfile and the wrapper header.

The measured result the plan's last review asked for is that `--settings`
is command-line scope. Project `.claude/settings.json` and
`settings.local.json` with `enabledPlugins: {"x@m": false}` no longer
turn off a plugin the file enables, and neither does user scope or
`plugin disable --scope user|project`. Claude Code itself prints "still
enabled this session: --settings keeps it on". Before this change, user
scope enabled it and a project `false` won. The operator approved the plan
as written, so a repo is not meant to override the host allowlist; the
note, DESIGN.md and README say so.

The file wins per plugin, not wholesale: `enabledPlugins` merges across
scopes, so a plugin enabled in the shared user `settings.json` still
loads beside the file's. A sibling's in-session `/plugin install` can
therefore add a plugin to this sandbox's new sessions until the next
boot. DESIGN.md "What stays shared", the note and README say so.

## Checked

- In the container: `make lint`, `make consistency` and `make test`
  pass.
- **`tests/test_launch_settings.py`** (new):
  - the render on temp trees: both layouts, manifest-name keys, nameless,
    broken and duplicate manifests, names outside the alphabet, an id a
    manifest lacks, unmounted marketplaces, commas, empty output;
  - `LAUNCH_SHAPE`;
  - the note names exactly the registered marketplaces;
  - the advisory, silent and not;
  - the root phase's block run as written, with `gosu` and `install`
    stubbed: nothing installed for `{}`, a failing render or a malformed
    one (including two documents in one stream, and a marketplace name
    with a trailing newline), and refs and plugins sharing the note;
  - the wrapper with a stub binary, over every gateway and launch file
    combination and six caller argvs.
- **Placement tests** in `test_sandbox_wiring.py` and `test_refs_note.py`:
  root names no `marketplace.json` and reaches the render only through
  `gosu`; both files are cleared and then installed root-owned; the user
  phase touches `settings.json` and `CLAUDE.md` only by the template
  installs.
- **24 mutants** of the root block, `LAUNCH_SHAPE`, the render, the note,
  the advisory and the wrapper, each run against
  `test_launch_settings`: all killed. Among them, each of these reverted
  alone fails only its own new case: the one-document check (`jq -se
  "length == 1 and …"` back to `jq -e`), `\A…\z` back to `^…$`, and
  dropping `type == "object"` from the per-key check (killed by
  `{"enabledPlugins":[true]}` in `ShapeTests`).
- **`claude` 2.1.310 in the container,** with a scratch config dir and a
  fixture marketplace (NOTES.md has the method and the table):
  - loading with and without the file;
  - the file is never written back;
  - two `--settings`, last wins;
  - project, local and user `false` against the file;
  - `plugin disable` behind it;
  - `--version`, `plugin list`, `mcp list`, `plugin marketplace list`,
    `update --help` and `--help` exit 0 behind the prepends;
  - two launch files over one config dir;
  - a user-scope `enabledPlugins` entry for a second plugin loads beside
    the file's, with or without user scope registering its marketplace;
  - the repo's wrapper, pointed at the fixture, in front of the real
    binary, with and without a caller `--settings`.
- `tier-setup.sh --render-launch-settings` run as the sandbox user here
  rendered this container's own `/marketplaces` mount and allowlist.
- **On the maintainer's host,** with this branch's launcher under a
  separate image tag, two sandboxes running at once:
  - A, the usual marketplaces and plugins: the plugin's skill runs in an
    interactive `claude`, and `/etc/claude-code/CLAUDE.md` carries the
    marketplace bullet;
  - B, an empty marketplaces dir and `SANDBOX_PLUGINS=nope@nowhere`: the
    boot prints the neither-mounted-nor-known advisory for `nowhere`, and
    B's `/etc/claude-code/CLAUDE.md` is the short "Plugins are enabled
    host-side" header with no marketplace line, and the skill is absent
    from B's `claude`;
  - A again, `claude` restarted after B's boot: the skill still runs (on
    main, B's boot rewrites the shared `enabledPlugins`).
  - Seen from a sandbox on main's image: B's boot reinstalled the shared
    `CLAUDE.md` from the template, dropping that sandbox's appended
    marketplace bullet, which is this change's point; old-image sandboxes
    lose it until rebuilt.
- **Not run:** the interactive `/plugin` menu.

Co-Authored-By: Claude <noreply@anthropic.com>
@larsvikb
larsvikb merged commit a67fbce into main Nov 1, 2026
6 checks passed
@larsvikb
larsvikb deleted the per-container-plugins branch November 1, 2026 12:50
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.

1 participant