From 2c1d6d8713601fe082b296ba4bd11fcdd825ba69 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 09:54:24 -0700 Subject: [PATCH] Docs: marketplace, and clean up new-code warnings/lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 45 +++++++++++++++++-- HOW-TO-USE.md | 23 ++++++++++ app/src-tauri/src/marketplace/auth.rs | 8 ++-- app/src-tauri/src/marketplace/gh_login.rs | 5 ++- app/src-tauri/src/marketplace/git.rs | 4 +- app/src-tauri/src/marketplace/sync.rs | 2 +- app/src-tauri/src/marketplace/tree.rs | 2 + app/src-tauri/src/models/mod.rs | 1 - .../home/config/MarketplaceSection.test.tsx | 3 +- .../specs/2026-09-27-marketplace-design.md | 31 ++++++++----- 10 files changed, 99 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0f9d3d4..3b97fd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + `/triple-c/marketplaces/.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-`), 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 diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index f135150..d5eb175 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -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 diff --git a/app/src-tauri/src/marketplace/auth.rs b/app/src-tauri/src/marketplace/auth.rs index 2dd1d81..3b8168e 100644 --- a/app/src-tauri/src/marketplace/auth.rs +++ b/app/src-tauri/src/marketplace/auth.rs @@ -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 + ); } } } diff --git a/app/src-tauri/src/marketplace/gh_login.rs b/app/src-tauri/src/marketplace/gh_login.rs index 5ff7de9..8a4be8b 100644 --- a/app/src-tauri/src/marketplace/gh_login.rs +++ b/app/src-tauri/src/marketplace/gh_login.rs @@ -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()) } diff --git a/app/src-tauri/src/marketplace/git.rs b/app/src-tauri/src/marketplace/git.rs index bf39be8..b012543 100644 --- a/app/src-tauri/src/marketplace/git.rs +++ b/app/src-tauri/src/marketplace/git.rs @@ -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)]); } diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs index 7794c8c..f2380f7 100644 --- a/app/src-tauri/src/marketplace/sync.rs +++ b/app/src-tauri/src/marketplace/sync.rs @@ -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]); } diff --git a/app/src-tauri/src/marketplace/tree.rs b/app/src-tauri/src/marketplace/tree.rs index 7a033b0..f39dd7f 100644 --- a/app/src-tauri/src/marketplace/tree.rs +++ b/app/src-tauri/src/marketplace/tree.rs @@ -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)] diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 9935ee4..bd6b344 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -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::*; diff --git a/app/src/components/projects/home/config/MarketplaceSection.test.tsx b/app/src/components/projects/home/config/MarketplaceSection.test.tsx index e623078..6c100d5 100644 --- a/app/src/components/projects/home/config/MarketplaceSection.test.tsx +++ b/app/src/components/projects/home/config/MarketplaceSection.test.tsx @@ -77,8 +77,9 @@ describe("MarketplaceSection", () => { ); }); - it("opens the Marketplace filtered to this project", () => { + it("opens the Marketplace filtered to this project", async () => { render(); + 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"); diff --git a/docs/superpowers/specs/2026-09-27-marketplace-design.md b/docs/superpowers/specs/2026-09-27-marketplace-design.md index 1475107..40387ec 100644 --- a/docs/superpowers/specs/2026-09-27-marketplace-design.md +++ b/docs/superpowers/specs/2026-09-27-marketplace-design.md @@ -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, // project-only additions pub marketplace_disabled: Vec, // (marketplace_id, kind, key) opted out ``` -**Secrets.** Token and GhContainer accounts keep their token in the keychain as -`marketplace-account:` (new global helpers in `secure.rs` alongside the gateway ones). -GhHost accounts store nothing: every fetch runs `gh auth token --hostname ` 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-`; new global helpers in `secure.rs` alongside the +gateway ones). GhHost accounts store nothing: every fetch runs `gh auth token --hostname ` +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 --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 --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.