Repository navigation
Give each tier-1 sandbox its own marketplaces and plugins, through the wrapper's --settings - #244
Merged
Merged
Conversation
## 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>
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
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_marketplacestakes 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/workspacewhatever the:rosaid. A/workspace (only withALLOW_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.
tier-setup.sh --render-launch-settingsthroughgosu, checks the output is exactly one JSON document matchingLAUNCH_SHAPE(the two keys, non-empty objects, every plugintrue, 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./etc/claude-code/CLAUDE.md, derived from the installed JSON. The user phase no longer writes plugin keys into$CONFIG_DIR/settings.jsonor appends to$CONFIG_DIR/CLAUDE.md; the "neither mounted nor known" advisory moved there.[--settings F] [--mcp-config G] --strict-mcp-config(appending fails after a subcommand). A caller's own--settingsreplaces the launch file, and the wrapper prints one stderr line saying the host's plugins are not loaded.Measured on claude 2.1.310:
--settingsis command-line scope, soenabledPlugins: falsein a project's or the user's settings, orplugin 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 installreaches this sandbox's new sessions until the next boot. The note, DESIGN.md and README say both.Checked
make lint,make consistencyandmake testpass at both commits.MarketplaceWorkspaceOverlapTestsrunssc_marketplaceson temp trees: equal, inside, holding, a symlink to the workspace and a/workspace are refused; a sibling and a name-prefix neighbour mount.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 withgosu/installstubbed (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.claude2.1.310 in the container, scratch config and fixture marketplace (NOTES.md): loading with and without the file, never written back, last--settingswins, project/local/userfalseandplugin disableagainst 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.SANDBOX_PLUGINS=nope@nowhereprints the advisory, has the short note and no skill; A'sclauderestarted after B's boot still has the skill (on main, B's boot rewrites the sharedenabledPlugins). An old-image sandbox loses its appended note when a new-image one boots, until rebuilt./pluginmenu.Generated with Claude Code