Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers #65

Merged
jknapp merged 48 commits from feat/marketplace into main 2026-09-27 23:43:27 +00:00
10 changed files with 99 additions and 25 deletions
Showing only changes of commit 2c1d6d8713 - Show all commits
+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 - 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"`. 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 ## Secrets
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live **`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` `commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that (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 live in the OS keychain instead: the shared Claude Code OAuth login, the model gateway's two keys,
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are and every marketplace account's token. Per-project settings, per-project secrets, and anything in
deliberately out of scope — this is not a project backup. 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 - **`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 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 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 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. 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 - **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 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 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) - [Permission Modes](#permission-modes)
- [Project Configuration](#project-configuration) - [Project Configuration](#project-configuration)
- [Shared Claude Authentication](#shared-claude-authentication) - [Shared Claude Authentication](#shared-claude-authentication)
- [Marketplace](#marketplace)
- [Opening URLs in Your Browser (URL Relay)](#opening-urls-in-your-browser-url-relay) - [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) - [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
- [AWS Bedrock Configuration](#aws-bedrock-configuration) - [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) ## 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 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 — // And the target must never see a connection carrying the token —
// ideally no connection at all, since the client never follows. // ideally no connection at all, since the client never follows.
match rx.recv_timeout(StdDuration::from_millis(500)) { // no connection at all is also the expected outcome
Ok(request) => assert!( if let Ok(request) = rx.recv_timeout(StdDuration::from_millis(500)) {
assert!(
!request.contains(FAKE) && !request.to_ascii_lowercase().contains("private-token"), !request.contains(FAKE) && !request.to_ascii_lowercase().contains("private-token"),
"the redirect target must never receive the token: {request}" "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 /// Remove terminal control sequences and carriage returns from one complete
/// piece of text. An unterminated sequence at the end is dropped. The login /// piece of text. An unterminated sequence at the end is dropped. The login
/// itself uses a streaming [`AnsiStripper`], which carries a sequence split /// itself uses a streaming [`AnsiStripper`], which carries a sequence split
/// across chunks instead. /// across chunks instead; this one-shot form exists for tests only.
pub fn strip_ansi(s: &str) -> String { #[cfg(test)]
fn strip_ansi(s: &str) -> String {
AnsiStripper::default().push(s.as_bytes()) 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 let cause = chain
.lines() .lines()
.filter_map(|l| l.trim_start().strip_prefix("└─")) .filter_map(|l| l.trim_start().strip_prefix("└─"))
.last() .next_back()
.unwrap_or(chain); .unwrap_or(chain);
return FetchError::Network(first_line(cause)); return FetchError::Network(first_line(cause));
} }
@@ -600,7 +600,7 @@ mod tests {
want.sort(); want.sort();
assert_eq!(pins(&repo), want); 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)]); assert_eq!(pins(&repo), vec![format!("{}{}", PIN_PREFIX, b)]);
} }
+1 -1
View File
@@ -241,7 +241,7 @@ mod tests {
skipped: vec![script_skip.clone()], skipped: vec![script_skip.clone()],
..Default::default() ..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]); 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 //! against [`MemTree`] with no git involved, and runs in production against
//! [`GitTree`], which reads git objects straight out of the bare cache. //! [`GitTree`], which reads git objects straight out of the bare cache.
#[cfg(test)]
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(test)]
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
-1
View File
@@ -11,7 +11,6 @@ pub mod update_info;
pub use app_settings::*; pub use app_settings::*;
pub use container_config::*; pub use container_config::*;
pub use gateway_settings::*; pub use gateway_settings::*;
pub use marketplace::*;
pub use migration::*; pub use migration::*;
pub use note::*; pub use note::*;
pub use project::*; 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} />); render(<MarketplaceSection project={project} />);
await screen.findByText(/a file you created has the same name/);
fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" })); fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" }));
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY); expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
expect(useAppState.getState().marketplaceFilterProjectId).toBe("p1"); 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`. (shared Claude token, gateway keys). Project secrets are restricted to `PROJECT_SECRET_KEYS`.
- Container start: `commands/project_commands.rs` `start_project_container` runs - Container start: `commands/project_commands.rs` `start_project_container` runs
`docker::sync_bedrock_credentials` after start (≈:1448) — the pattern the marketplace sync follows. `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)`. `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 Container user is addressed as `"claude"`. Constant-script + env-data rule: header of
`commands/inspect_commands.rs`. `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 pub marketplace_disabled: Vec<MarketplaceItemRef>, // (marketplace_id, kind, key) opted out
``` ```
**Secrets.** Token and GhContainer accounts keep their token in the keychain as **Secrets.** Token and GhContainer accounts keep their token in the keychain, one service per
`marketplace-account:<id>` (new global helpers in `secure.rs` alongside the gateway ones). account (`triple-c-marketplace-account-<id>`; new global helpers in `secure.rs` alongside the
GhHost accounts store nothing: every fetch runs `gh auth token --hostname <host>` so a later gateway ones). GhHost accounts store nothing: every fetch runs `gh auth token --hostname <host>`
`gh auth refresh`/logout on the host is honoured. Deleting an account deletes its entry. 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): **Effective set for a project** (pure function, unit-tested):
`(global − project.marketplace_disabled) ∪ project.marketplace_installs`, keyed by `(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. removed and offers "Forget" to drop the stale entries.
**Export/import.** Accounts (without secrets), marketplaces and install lists go into the existing **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 ## 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). 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. `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 - **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 `gh auth login --hostname <host> --web --git-protocol ssh --skip-ssh-key --scopes repo` in an
exec with `GH_CONFIG_DIR=$(mktemp -d)`, surfaces the one-time code and URL in a dialog (same attached pty exec, with `GH_CONFIG_DIR` and `GIT_CONFIG_GLOBAL` both pointed at a temp dir
shape as `ClaudeAuthModal`), then runs `gh auth token` with the same config dir, stores the (`$(mktemp -d)`) — `--git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with your
token in the keychain and `rm -rf`s the dir. Cancel tears the exec down. Nothing persists in the GitHub credentials?" prompt, which under `https` would otherwise write a credential helper into
container, so Claude in that container is not logged into the user's GitHub. `~/.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 - **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 (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. `ls-remote`-equivalent fetch), stored in the keychain. The token is never returned to the frontend.