Docs: marketplace, and clean up new-code warnings/lints

CLAUDE.md gets a Marketplace subsection under Key Conventions (the sync
script is app-embedded and re-uploaded on every sync, never baked into
container/ — pre-flight F9) and the Settings export/import section now
covers marketplace account tokens traveling in ExportedSecrets and the
import preview's warning on global hook and plugin installs.
HOW-TO-USE.md gets a Marketplace section (placed after Shared Claude
Authentication) with its Table of Contents entry (pre-flight N13). The
spec doc's stale keychain service name, gh-login flags and
upload_bytes_to_container signature are amended to match the shipped
code (pre-flight N10).

Also fixes the new marketplace code's remaining build/clippy warnings:
BTreeMap/Sha256/Digest imports in tree.rs gated behind #[cfg(test)]
(their only uses are on MemTree, already test-only), the unused
`pub use marketplace::*` glob re-export dropped from models/mod.rs,
gh_login::strip_ansi marked #[cfg(test)] (production streams through
AnsiStripper instead), and four clippy lints in marketplace test code
(double_ended_iterator_last, cloned_ref_to_slice_refs x2,
single_match). Flushes the unresolved getMarketplaceSyncReport promise
in MarketplaceSection.test.tsx's "opens the Marketplace filtered to
this project" test to remove its act() warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 09:54:24 -07:00
co-authored by Claude Opus 5.5
parent 9588687934
commit 2c1d6d8713
10 changed files with 99 additions and 25 deletions
+42 -3
View File
@@ -646,6 +646,32 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
- A new local window needs its own capability file (`capabilities/file-viewer.json` is the
model), and `lib.rs`'s `on_window_event` stays guarded on `label() == "main"`.
### Marketplace
- Code: models in `models/marketplace.rs`; host-side logic in `src/marketplace/` (`git.rs` gix
cache + pins, `catalog.rs` repo format, `auth.rs` credentials, `gh_login.rs`, `payload.rs`,
`sync.rs`); commands in `commands/marketplace_commands.rs`; UI in `components/marketplace/` and
`projects/home/config/MarketplaceSection.tsx`. Spec:
`docs/superpowers/specs/2026-09-27-marketplace-design.md`.
- **Tokens never enter containers.** Marketplaces are fetched on the host into
`<data_dir>/triple-c/marketplaces/<id>.git`; containers only ever receive a tar of pinned
files. Do not add a code path that passes a marketplace credential into an exec, env var, label
or file in a container.
- **Sync model:** after every container start (next to `sync_bedrock_credentials`) and on "Apply
now", the host builds the project's effective set (`global − disabled ∪ project`), then uploads
`payload.tar` **and the app-embedded script `src/marketplace/sync.sh`** (`include_str!`, not a
file in `container/`) to `~/.claude/triple-c/marketplace/incoming/` and runs it as `claude`,
once the entrypoint has finished (`pgrep -x -f 'su -s /bin/bash claude -c exec sleep
infinity'`). The script is re-uploaded on every sync rather than baked into the image, so every
existing project always gets the version that matches the running app — `container/` is never
touched for this feature. The script only removes files and hook entries it recorded in
`~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created
agents/skills/commands or user hooks. A sync failure must not fail the container start.
- Installs are **pinned** to a commit; nothing updates without the user accepting a diff. Pinned
commits are kept alive by `refs/triple-c/pins/*` in the cache.
- Marketplace changes need no container labels or recreation — they are applied by the sync, not
at create time.
## Secrets
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
@@ -677,9 +703,9 @@ nobody had reason to open. Fixtures are never live values; there is no case wher
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
deliberately out of scope — this is not a project backup.
live in the OS keychain instead: the shared Claude Code OAuth login, the model gateway's two keys,
and every marketplace account's token. Per-project settings, per-project secrets, and anything in
a project's Docker volumes are deliberately out of scope — this is not a project backup.
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
@@ -696,6 +722,19 @@ deliberately out of scope — this is not a project backup.
inside a generic "settings replaced" summary. Read this as the standing example of the class of
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
like config but is actually a live credential would have the same problem.
- **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and
`GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-<id>`), so
they follow the same "carve out of the keychain, restore before the settings replace, only
overwrite what the file actually has" treatment as the other three secrets
(`ExportedSecrets::marketplace_account_tokens`, keyed by account id). Marketplaces and install
lists themselves are ordinary `AppSettings` fields and travel with the settings replace, but are
**validated** on import the same way the add-marketplace/install commands validate them
(`validate_imported_marketplace_state`) — an import is untrusted input, not a trusted restore.
The preview warns whenever the import carries one or more **global hook installs or global
plugin installs**, in addition to the base-URL and custom-image warnings above: a hook runs
commands in every project container, and a plugin can carry its own hooks and MCP servers into
one — and an imported install skips the hook-confirm step an install from the Marketplace tab
shows, so this is the only place that confirmation happens for an import.
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
+23
View File
@@ -15,6 +15,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
- [Permission Modes](#permission-modes)
- [Project Configuration](#project-configuration)
- [Shared Claude Authentication](#shared-claude-authentication)
- [Marketplace](#marketplace)
- [Opening URLs in Your Browser (URL Relay)](#opening-urls-in-your-browser-url-relay)
- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
@@ -773,6 +774,28 @@ is next started, at which point the same recreation clears the variable.
---
## Marketplace
The marketplace installs Claude Code **agents, skills, commands, hooks and plugins** from git repositories into your containers.
1. **Settings → Marketplace → Open Marketplace** opens the Marketplace tab.
2. **Add a marketplace**: on the Browse tab choose *Add marketplace* and enter an HTTPS clone URL, for example `https://github.com/shadowdao/triple-c-marketplace.git`. For a private repository, pick an account (see below). Triple-C checks it can read the repository before saving.
3. **Install**: select an item to see what it contains. Turn on **All projects** to install it everywhere (including projects you add later), or tick individual projects. A project can opt out of an "All projects" item by unticking it, or from **Project → Config → Marketplace**.
4. **Hooks** run shell commands, so Triple-C shows every command before installing one.
5. **When it applies**: on the container's next start, or straight away for running containers with **Installed → Apply now**. New Claude sessions pick it up; sessions already open keep what they loaded.
**Updates.** Every install is pinned to the commit it came from. When an item changes in its repository, the Installed tab shows *Update available*. Review the diff and accept to move the pin.
**Accounts (private repositories).** On the Accounts tab:
- *GitHub via gh* — if the GitHub CLI is installed and logged in on this computer, Triple-C uses it. If not, it runs `gh auth login` inside a running project's container and keeps only the resulting token in your OS keychain.
- *Access token* — any host (GitHub, Gitea, GitLab). The token is stored in your OS keychain.
Credentials never enter containers. If a private repository in a GitHub organisation cannot be read, the error explains the usual causes: the org has not approved the GitHub CLI, the token is not authorised for the org's SSO, or a fine-grained token belongs to a different owner.
**If an item is skipped**: Triple-C never overwrites an agent, skill or command file you created yourself. If one has the same name as a marketplace item, the sync skips it and the project's Config → Marketplace section says so.
---
## Opening URLs in Your Browser (URL Relay)
There is no browser inside the container and no screen to put one on. Any tool that tries to open
+4 -4
View File
@@ -641,12 +641,12 @@ mod tests {
// And the target must never see a connection carrying the token —
// ideally no connection at all, since the client never follows.
match rx.recv_timeout(StdDuration::from_millis(500)) {
Ok(request) => assert!(
// no connection at all is also the expected outcome
if let Ok(request) = rx.recv_timeout(StdDuration::from_millis(500)) {
assert!(
!request.contains(FAKE) && !request.to_ascii_lowercase().contains("private-token"),
"the redirect target must never receive the token: {request}"
),
Err(_) => {} // no connection at all — the expected outcome
);
}
}
}
+3 -2
View File
@@ -67,8 +67,9 @@ pub fn valid_host(host: &str) -> bool {
/// Remove terminal control sequences and carriage returns from one complete
/// piece of text. An unterminated sequence at the end is dropped. The login
/// itself uses a streaming [`AnsiStripper`], which carries a sequence split
/// across chunks instead.
pub fn strip_ansi(s: &str) -> String {
/// across chunks instead; this one-shot form exists for tests only.
#[cfg(test)]
fn strip_ansi(s: &str) -> String {
AnsiStripper::default().push(s.as_bytes())
}
+2 -2
View File
@@ -91,7 +91,7 @@ pub fn classify_fetch_error(chain: &str) -> FetchError {
let cause = chain
.lines()
.filter_map(|l| l.trim_start().strip_prefix("└─"))
.last()
.next_back()
.unwrap_or(chain);
return FetchError::Network(first_line(cause));
}
@@ -600,7 +600,7 @@ mod tests {
want.sort();
assert_eq!(pins(&repo), want);
set_pins(&repo, &[b.clone()]).unwrap();
set_pins(&repo, std::slice::from_ref(&b)).unwrap();
assert_eq!(pins(&repo), vec![format!("{}{}", PIN_PREFIX, b)]);
}
+1 -1
View File
@@ -241,7 +241,7 @@ mod tests {
skipped: vec![script_skip.clone()],
..Default::default()
};
let merged = with_payload_skips(report, &[payload_skip.clone()]);
let merged = with_payload_skips(report, std::slice::from_ref(&payload_skip));
assert_eq!(merged.skipped, vec![payload_skip, script_skip]);
}
+2
View File
@@ -4,8 +4,10 @@
//! against [`MemTree`] with no git involved, and runs in production against
//! [`GitTree`], which reads git objects straight out of the bare cache.
#[cfg(test)]
use std::collections::BTreeMap;
#[cfg(test)]
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-1
View File
@@ -11,7 +11,6 @@ pub mod update_info;
pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*;
pub use marketplace::*;
pub use migration::*;
pub use note::*;
pub use project::*;
@@ -77,8 +77,9 @@ describe("MarketplaceSection", () => {
);
});
it("opens the Marketplace filtered to this project", () => {
it("opens the Marketplace filtered to this project", async () => {
render(<MarketplaceSection project={project} />);
await screen.findByText(/a file you created has the same name/);
fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" }));
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
expect(useAppState.getState().marketplaceFilterProjectId).toBe("p1");
@@ -41,7 +41,7 @@ publishing to a marketplace from inside Triple-C, a separate OS window for the m
(shared Claude token, gateway keys). Project secrets are restricted to `PROJECT_SECRET_KEYS`.
- Container start: `commands/project_commands.rs` `start_project_container` runs
`docker::sync_bedrock_credentials` after start (≈:1448) — the pattern the marketplace sync follows.
- Exec/upload: `docker/exec.rs` `upload_bytes_to_container(container_id, dest_dir, file_name, data)`,
- Exec/upload: `docker/exec.rs` `upload_bytes_to_container(container_id, dest_dir, file_name, data, mode)`,
`exec_oneshot_streams_as(container_id, user, cmd, env)`, `create_attached_exec_as(…, tty, user)`.
Container user is addressed as `"claude"`. Constant-script + env-data rule: header of
`commands/inspect_commands.rs`.
@@ -141,10 +141,10 @@ pub marketplace_installs: Vec<MarketplaceInstall>, // project-only additions
pub marketplace_disabled: Vec<MarketplaceItemRef>, // (marketplace_id, kind, key) opted out
```
**Secrets.** Token and GhContainer accounts keep their token in the keychain as
`marketplace-account:<id>` (new global helpers in `secure.rs` alongside the gateway ones).
GhHost accounts store nothing: every fetch runs `gh auth token --hostname <host>` so a later
`gh auth refresh`/logout on the host is honoured. Deleting an account deletes its entry.
**Secrets.** Token and GhContainer accounts keep their token in the keychain, one service per
account (`triple-c-marketplace-account-<id>`; new global helpers in `secure.rs` alongside the
gateway ones). GhHost accounts store nothing: every fetch runs `gh auth token --hostname <host>`
so a later `gh auth refresh`/logout on the host is honoured. Deleting an account deletes its entry.
**Effective set for a project** (pure function, unit-tested):
`(global − project.marketplace_disabled) ∪ project.marketplace_installs`, keyed by
@@ -161,7 +161,13 @@ the next sync removes those items from containers; the UI says so before the mar
removed and offers "Forget" to drop the stale entries.
**Export/import.** Accounts (without secrets), marketplaces and install lists go into the existing
export; account tokens follow the existing encrypted-secrets policy of `settings_export.rs`.
export as ordinary `AppSettings` fields; account tokens follow the existing encrypted-secrets
policy of `settings_export.rs` (`ExportedSecrets::marketplace_account_tokens`, keyed by account
id, restored to the keychain before the settings replace). Because the import is untrusted input
and not merely a restore, imported marketplaces and installs are validated on import the same way
the add-marketplace/install commands validate them (host, key pattern, pinned-commit shape), and
the confirmation preview warns whenever the import contains a global hook or global plugin install
— those skip the hook-confirm step an install from the Marketplace tab shows.
## 3. Fetching and signing in
@@ -186,11 +192,14 @@ Hooks' diffs always show the rendered commands.
logged in, tell the user to run `gh auth login` (we do not drive the host's gh interactively).
`gh api user --jq .login` for the display name.
- **GitHub via `gh` in a container** (no host `gh`): user picks a running project; Triple-C runs
`gh auth login --hostname <host> --web --git-protocol https --scopes repo` in an attached pty
exec with `GH_CONFIG_DIR=$(mktemp -d)`, surfaces the one-time code and URL in a dialog (same
shape as `ClaudeAuthModal`), then runs `gh auth token` with the same config dir, stores the
token in the keychain and `rm -rf`s the dir. Cancel tears the exec down. Nothing persists in the
container, so Claude in that container is not logged into the user's GitHub.
`gh auth login --hostname <host> --web --git-protocol ssh --skip-ssh-key --scopes repo` in an
attached pty exec, with `GH_CONFIG_DIR` and `GIT_CONFIG_GLOBAL` both pointed at a temp dir
(`$(mktemp -d)`) — `--git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with your
GitHub credentials?" prompt, which under `https` would otherwise write a credential helper into
`~/.gitconfig`. It surfaces the one-time code and URL in a dialog (same shape as
`ClaudeAuthModal`), then runs `gh auth token` with the same config dir, stores the token in the
keychain and `rm -rf`s the dir. Cancel tears the exec down. Nothing persists in the container,
so Claude in that container is not logged into the user's GitHub.
- **Token**: pasted once, validated via the host's "who am I" API
(GitHub `GET /user`, Gitea `GET /api/v1/user`, GitLab `GET /api/v4/user`; unknown host → test
`ls-remote`-equivalent fetch), stored in the keychain. The token is never returned to the frontend.