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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user